opencode-navigator 0.19.2 → 0.20.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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.20.0] - 2026-10-05
6
+
7
+ - Add a default-visible Limits section for the selected model's provider-native quota windows, reset times, account identity, and freshness. Codex CLI support requires an explicit private account binding; unsupported providers show calm guidance.
8
+ - Review Codex banked reset credits and use them only after a separate confirmation and fresh eligibility checks. Preserve the same attempt key when reconciling an uncertain result, including after a restart.
9
+ - Save named, commit-safe project layout and MCP profiles to `.opencode/navigator.json` with the `opencode-navigator-profile` command, an explicit preview, and `--apply`; profile application remains manual.
10
+
11
+ ## [0.19.3] - 2026-10-03
12
+
13
+ - Indent grouped Todo, Subagent, Skill, and MCP rows and align MCP server status indicators with their group headings.
14
+ - Place Skill, MCP, and Quick Action bookmarks before item names. Reveal unselected bookmarks on hover or keyboard focus, keep favorites visible, and keep labels steady.
15
+
5
16
  ## [0.19.2] - 2026-10-02
6
17
 
7
18
  - Show MCP group connection states with the same radio-style indicators as individual servers, without Connect/Disconnect labels on group headings.
@@ -59,6 +70,8 @@
59
70
 
60
71
  Earlier release notes are available on [GitHub Releases](https://github.com/St1ggy/opencode-navigator/releases).
61
72
 
73
+ [0.20.0]: https://github.com/St1ggy/opencode-navigator/compare/v0.19.3...v0.20.0
74
+ [0.19.3]: https://github.com/St1ggy/opencode-navigator/compare/v0.19.2...v0.19.3
62
75
  [0.19.2]: https://github.com/St1ggy/opencode-navigator/compare/v0.19.1...v0.19.2
63
76
  [0.19.1]: https://github.com/St1ggy/opencode-navigator/compare/v0.19.0...v0.19.1
64
77
  [0.19.0]: https://github.com/St1ggy/opencode-navigator/compare/v0.18.2...v0.19.0
package/README.md CHANGED
@@ -103,6 +103,12 @@ belong to each Skill's exact source path, so assignments survive when a Skill
103
103
  is absent from the current workspace. Filters and Search Everything also match
104
104
  group names; choosing a group heading never runs its Skills.
105
105
 
106
+ Rows beneath group headings are indented in Todo, Subagents, Skills, and MCP.
107
+ Skill, MCP, and Quick Action rows reserve a leading bookmark control: unselected
108
+ bookmarks appear on hover or keyboard focus, while favorites stay visible.
109
+ Labels do not shift when the control appears. MCP server status icons align with
110
+ their group status icons, including when a server has an error detail control.
111
+
106
112
  ![Skill group manager](https://raw.githubusercontent.com/St1ggy/opencode-navigator/main/screenshots/38-skill-groups.png)
107
113
 
108
114
  Search Everything preserves one query across Skills, Subagents, MCP, and Actions:
@@ -349,6 +355,82 @@ dialog for the current session and `session.permissions` in the user-wide
349
355
  `cli.json` (`"prompt"` or `"autoaccept"`) for its startup default. Restart
350
356
  OpenCode after changing `cli.json`.
351
357
 
358
+ ## Limits: provider quotas and banked resets
359
+
360
+ Navigator keeps subscription limits separate from OpenCode's session token and
361
+ cost statistics. The first supported source is Codex's documented app-server
362
+ `account/rateLimits/read` RPC. It reports provider-owned usage windows, reset
363
+ times, account identity, and any available banked reset credits. The installed
364
+ Codex 0.160.0 protocol also exposes the separate
365
+ `account/rateLimitResetCredit/consume` RPC. Navigator requires explicit
366
+ confirmation before using it and never redeems credits in the background.
367
+ Limits follows the model selected in the current OpenCode session. A Codex
368
+ account's windows appear only if the provider explicitly maps them to that
369
+ model; otherwise the section explains that a matching quota is unavailable.
370
+ Switching provider, model, session, or account invalidates stale reads and credit actions.
371
+ CodexBar is useful for discovering providers, but its cookie, credential-file,
372
+ and undocumented endpoint integrations are not Navigator data sources.
373
+
374
+ | Source | Machine-readable subscription limits | Banked reset credits |
375
+ | --- | --- | --- |
376
+ | Codex app-server | Rate-limit windows and account ID | Available count, optional details and expiry, confirmed manual consumption |
377
+ | Claude Code (not integrated) | Session-local `rate_limits` statusline payload after a Claude Code response | No documented cross-process consumption API |
378
+ | OpenCode session statistics | Token and cost statistics, not subscription quota | Not applicable |
379
+ | Other providers | Not shown until a documented machine-readable quota API is verified | No action without a documented consume API |
380
+
381
+ Limits is visible and expanded by default, first below the session title. Change
382
+ its visibility and order under **Settings → Sections** in Global or Current
383
+ worktree scope. Hiding or collapsing it does not stop background refreshes, and
384
+ does not grant permission to spend a reset credit. **Focus Limits** is also
385
+ available from the command palette.
386
+
387
+ ![Model-specific Codex quota windows and reset times](screenshots/39-provider-limits.png)
388
+
389
+ ### Link the intended Codex account
390
+
391
+ Install a Codex CLI exposing the documented read and consume RPCs (verified with
392
+ 0.160.0), ensure `codex` is on the same machine's `PATH`, and sign in with a ChatGPT
393
+ account. Navigator starts `codex app-server --stdio` lazily; it does not start a
394
+ Codex subprocess for unsupported providers or before an explicit account link.
395
+
396
+ OpenCode does not expose which Codex CLI account its selected model uses. Select
397
+ **Link Codex CLI account** in Limits and confirm the reported account ID only if
398
+ you know it matches this OpenCode provider. The binding is private, user-wide,
399
+ and specific to the provider/model. **Unlink Codex account** removes it;
400
+ **Relink Codex account** is available when the CLI account has changed.
401
+
402
+ For a linked model, Limits shows only buckets with a provider-reported model
403
+ association, their native usage units and window durations, reset times, and
404
+ the last successful read time. It refreshes quietly every five seconds, even
405
+ when hidden. **Refresh** requests a read immediately; a failed read preserves
406
+ cached windows with a stale label and **Retry**. Unsupported mappings, changed
407
+ sign-in/account, and rate-limited reads have separate muted guidance.
408
+
409
+ ### Review and manually use a banked reset
410
+
411
+ **Review reset credits** displays the provider's available count, optional credit
412
+ details, statuses, and expiry. Selecting a credit first checks fresh eligibility
413
+ and opens a separate confirmation tied to the current account and model. Cancel
414
+ or Escape returns to the credit list without spending anything.
415
+
416
+ The provider must report ordinary usage blocked, a matching quota window, and an
417
+ available reset credit. A displayed percentage alone does not establish
418
+ eligibility. A reset can affect other eligible Codex windows as well. Navigator
419
+ checks the account and eligibility again before submission. Read-only providers
420
+ do not gain a consume action.
421
+
422
+ After an uncertain result, **Reconcile pending attempt** reuses the same
423
+ idempotency key, including after a restart. Only its unresolved account/model,
424
+ optional credit ID, and attempt key are saved in private preferences. Definitive
425
+ outcomes clear that journal; quotas themselves are not saved to disk. A pending
426
+ attempt on another account must be resolved on its original binding first.
427
+
428
+ ![Separate account-bound banked reset confirmation](screenshots/41-reset-confirmation.png)
429
+
430
+ An absent Codex credit detail list does not mean there are zero credits: the
431
+ provider may report a count without individual rows. Navigator does not read
432
+ provider credential files or save credentials in preferences.
433
+
352
434
  ## Configuration sources
353
435
 
354
436
  Navigator validates and deep-merges three simultaneous sources in this order:
@@ -451,9 +533,31 @@ sources are left untouched.
451
533
  Configured files may define behavior, layout, desired MCP states, layout and MCP
452
534
  presets, workspace-profile links, and MCP groups. Navigator deliberately ignores
453
535
  favorites, recent-item history, trusted skills, onboarding state, paths, and other
454
- private mutable data. The files are read-only defaults: UI changes continue to use
455
- the concurrency-safe preferences file under OpenCode's state directory. Resetting
456
- a scope removes only its saved override and reveals configured values again.
536
+ private mutable data. UI changes normally use the concurrency-safe preferences
537
+ file under OpenCode's state directory. Resetting a scope removes only its saved
538
+ override and reveals configured values again.
539
+
540
+ To deliberately save a named, commit-safe project profile, choose **Current
541
+ worktree** in Navigator Settings, copy the portable settings JSON, and paste it
542
+ into a temporary file. From that project's root, preview and then save it:
543
+
544
+ ```sh
545
+ opencode-navigator-profile save Focus < /path/to/portable-settings.json
546
+ opencode-navigator-profile save Focus --apply < /path/to/portable-settings.json
547
+ ```
548
+
549
+ If the command is not installed globally, run it with
550
+ `npx --yes --package=opencode-navigator opencode-navigator-profile` instead.
551
+ The command writes only the validated layout preset, desired MCP states, and
552
+ their optional link into `.opencode/navigator.json`. It preserves other project
553
+ configuration, rejects stale or symlinked targets, and never copies favorites,
554
+ history, trust choices, or private paths. Use `--replace` deliberately to update
555
+ an existing project profile. After saving, restart OpenCode to load the project
556
+ file, then open **Settings → Presets → Focus → Preview & apply**. The profile is
557
+ never applied merely by entering the project, and applying it does not save the
558
+ layout as a default. Saved Global or Current worktree presets with the same name
559
+ still take precedence, so use a distinct name if you want the project profile
560
+ to be visible in the current installation.
457
561
 
458
562
  Choose `Global` or `Current worktree` before editing from the sidebar gear button.
459
563
  Behavior changes apply immediately in the selected scope. Section visibility,
@@ -1,12 +1,11 @@
1
- import {PresetMenu} from "./tui.js"
2
1
  import {createComponent as _$createComponent} from "@opentui/solid"
3
- import {useDialogs} from "./tui.js"
2
+ import {SelectionMenu,useDialogs} from "./tui.js"
4
3
  function GroupAssignmentDialog(props){const dialogs=useDialogs(props.api)
5
4
  const options=()=>[...props.groups().map((group)=>({title:group,value:`:${group}`,description:group===props.assigned()?"Current":"Existing group"
6
5
  })),{title:"Create new group",value:"+",description:"Type a name"
7
6
  },{title:"Ungrouped",value:":",description:props.assigned()?"Remove assignment":"No group"
8
7
  }]
9
- return _$createComponent(PresetMenu,{get api(){return props.api
8
+ return _$createComponent(SelectionMenu,{get api(){return props.api
10
9
  },get title(){return props.title
11
10
  },get options(){return options()
12
11
  },onSelect:(option)=>{if(option.value!=="+"){props.onAssign(option.value.slice(1)||undefined)
@@ -21,4 +20,4 @@ dialogs.back()
21
20
  }})
22
21
  }})
23
22
  }
24
- export{PresetMenu,GroupAssignmentDialog}
23
+ export{GroupAssignmentDialog}
@@ -0,0 +1,7 @@
1
+ var PLUGIN_ID="opencode-navigator"
2
+ var TOGGLE_COMMAND=`${PLUGIN_ID}.toggle`
3
+ var FOCUS_COMMAND=`${PLUGIN_ID}.focus`
4
+ var SEARCH_COMMAND=`${PLUGIN_ID}.search`;
5
+ var QUICK_ACTION_IDS=["session.rename","session.timeline","session.copy","session.export","session.compact","session.list","session.new","session.fork","messages.copy","session.first","session.last","session.messages_last_user","session.message.next","session.message.previous","permission.mode"]
6
+ var SECTION_DEFINITIONS=[{name:"limits",label:"Limits"},{name:"todo",label:"Todo"},{name:"subagents",label:"Subagents"},{name:"skills",label:"Skills"},{name:"quick_actions",label:"Quick actions"},{name:"lsp",label:"LSP"},{name:"mcp",label:"MCP"}]
7
+ export{QUICK_ACTION_IDS,SECTION_DEFINITIONS,PLUGIN_ID}
@@ -0,0 +1,438 @@
1
+ import {SECTION_DEFINITIONS,PLUGIN_ID
2
+ } from "./chunk-cmmnfes2.js";
3
+ import {createComponent as _$createComponent7} from "@opentui/solid"
4
+ import {setProp as _$setProp5} from "@opentui/solid"
5
+ import {effect as _$effect5} from "@opentui/solid"
6
+ import {createTextNode as _$createTextNode5} from "@opentui/solid"
7
+ import {insertNode as _$insertNode5} from "@opentui/solid"
8
+ import {createElement as _$createElement5} from "@opentui/solid"
9
+ import {SelectionMenu} from "./tui.js"
10
+ import {mergeProps as _$mergeProps2} from "@opentui/solid"
11
+ import {createComponent as _$createComponent6} from "@opentui/solid"
12
+ import {memo as _$memo4} from "@opentui/solid"
13
+ import {For as For3,createMemo as createMemo3,onMount as onMount2} from "solid-js"
14
+ import {useDialogs as useDialogs3,useIcons as useIcons6} from "./tui.js"
15
+ import {createMemo} from "solid-js"
16
+ function mcpToggleAction(status){if(status==="pending")
17
+ return
18
+ return status==="connected"?"disconnect":"connect"
19
+ }
20
+ function mcpPresetPreview(items,states){const byName=new Map(items.map((item)=>[item.name,item]))
21
+ const names=[...new Set([...Object.keys(states),...byName.keys()])].sort((a,b)=>a.localeCompare(b))
22
+ const rows=names.map((name)=>{const item=byName.get(name)
23
+ const desired=Object.hasOwn(states,name)?states[name]:undefined
24
+ const action=item&&mcpToggleAction(item.status)
25
+ let change="unchanged"
26
+ if(desired&&!item)
27
+ change="missing"
28
+ else if(desired&&!action)
29
+ change="unavailable"
30
+ else if(desired==="enabled"&&action==="connect"||desired==="disabled"&&action==="disconnect"){change=action
31
+ }
32
+ return{name,status:item?.status,desired,change}})
33
+ const changes=rows.flatMap((row)=>row.change==="connect"||row.change==="disconnect"?[{name:row.name,action:row.change}]:[])
34
+ return{rows,changes}}
35
+ function createLayoutPresetPreview(preferences,name){const scope=preferences.selectedScopeKey()
36
+ const valid=()=>preferences.selectedScopeKey()===scope
37
+ const preset=createMemo(()=>preferences.layoutPresets()[name])
38
+ const preview=createMemo(()=>{const value=preset()
39
+ return value?preferences.previewLayoutPreset(value):undefined
40
+ })
41
+ return{valid,preview,scope:preferences.preferenceScopeLabel,blocked:()=>preset()?"":"Preset no longer exists.",apply(){const value=preset()
42
+ if(!valid()||!value)
43
+ return false
44
+ preferences.applyLayoutPreset(value)
45
+ return true
46
+ }}}
47
+ function createMcpPresetPreview(controller,preferences,name){const target=controller.target()
48
+ const valid=()=>{const current=controller.target()
49
+ return current.key===target.key&&current.scope===target.scope
50
+ }
51
+ const preset=createMemo(()=>preferences.mcpPresets()[name])
52
+ const preview=createMemo(()=>mcpPresetPreview(controller.list(target),preset()??{}))
53
+ const state=createMemo(()=>controller.state(target))
54
+ const blocked=createMemo(()=>{if(!preset())
55
+ return "Preset no longer exists."
56
+ if(controller.mutating(target))
57
+ return "Wait for the current MCP operation to finish."
58
+ if(state().error)
59
+ return `Could not refresh MCP: ${state().error.message}. Retry to update the preview.`
60
+ return state().status==="ready"?"":"Refreshing MCP states\u2026"
61
+ })
62
+ return{valid,preview,blocked,scope:target.scope,refresh(){if(valid()&&!controller.mutating(target))
63
+ controller.refresh(target,true).catch(()=>{})
64
+ },apply(){const value=preset()
65
+ return value&&valid()&&!blocked()?controller.applyPreset(name,value,target):undefined
66
+ }}}
67
+ import {mergeProps as _$mergeProps} from "@opentui/solid"
68
+ import {createComponent as _$createComponent5} from "@opentui/solid"
69
+ import {memo as _$memo3} from "@opentui/solid"
70
+ import {For as For2,createMemo as createMemo2,onMount} from "solid-js"
71
+ import {useDialogs as useDialogs2,useIcons as useIcons5} from "./tui.js"
72
+ import {memo as _$memo} from "@opentui/solid"
73
+ import {use as _$use} from "@opentui/solid"
74
+ import {effect as _$effect3} from "@opentui/solid"
75
+ import {createTextNode as _$createTextNode3} from "@opentui/solid"
76
+ import {insertNode as _$insertNode3} from "@opentui/solid"
77
+ import {insert as _$insert3} from "@opentui/solid"
78
+ import {setProp as _$setProp3} from "@opentui/solid"
79
+ import {createElement as _$createElement3} from "@opentui/solid"
80
+ import {createComponent as _$createComponent3} from "@opentui/solid"
81
+ import {useTerminalDimensions} from "@opentui/solid"
82
+ import {Show as Show2,createEffect,createSignal,onCleanup} from "solid-js"
83
+ import {DialogHeader,DialogSurface,useDialogScroll,useDialogs,useIcons as useIcons3} from "./tui.js"
84
+ import {createComponent as _$createComponent} from "@opentui/solid"
85
+ import {effect as _$effect} from "@opentui/solid"
86
+ import {createTextNode as _$createTextNode} from "@opentui/solid"
87
+ import {insertNode as _$insertNode} from "@opentui/solid"
88
+ import {insert as _$insert} from "@opentui/solid"
89
+ import {setProp as _$setProp} from "@opentui/solid"
90
+ import {createElement as _$createElement} from "@opentui/solid"
91
+ import {Show} from "solid-js"
92
+ import {DialogAction,useIcons} from "./tui.js"
93
+ function PresetPreviewActions(props){const icons=useIcons()
94
+ return(()=>{var _el$=_$createElement("box"),_el$2=_$createElement("box"),_el$6=_$createElement("box")
95
+ _$insertNode(_el$,_el$2)
96
+ _$insertNode(_el$,_el$6)
97
+ _$setProp(_el$,"flexDirection","row")
98
+ _$setProp(_el$,"justifyContent","space-between")
99
+ _$setProp(_el$2,"flexGrow",1)
100
+ _$insert(_el$2,_$createComponent(Show,{get when(){return props.onRefresh
101
+ },get children(){var _el$3=_$createElement("text"),_el$4=_$createTextNode(` Refresh (`),_el$5=_$createTextNode(`)`)
102
+ _$insertNode(_el$3,_el$4)
103
+ _$insertNode(_el$3,_el$5)
104
+ _$setProp(_el$3,"onMouseUp",(event)=>{event.stopPropagation()
105
+ props.onRefresh?.()
106
+ })
107
+ _$insert(_el$3,()=>icons.icon("retry"),_el$4)
108
+ _$insert(_el$3,()=>icons.key("ctrl+r"),_el$5)
109
+ _$effect((_$p)=>_$setProp(_el$3,"fg",props.api.theme.current.accent,_$p))
110
+ return _el$3
111
+ }}))
112
+ _$setProp(_el$6,"flexDirection","row")
113
+ _$setProp(_el$6,"gap",1)
114
+ _$insert(_el$6,_$createComponent(DialogAction,{get api(){return props.api
115
+ },get id(){return `${props.prefix}.cancel`
116
+ },label:"Cancel",icon:"close",get selected(){return props.active==="cancel"
117
+ },onSelect:()=>props.onSelect("cancel"),onActivate:()=>props.onActivate("cancel")
118
+ }),null)
119
+ _$insert(_el$6,_$createComponent(DialogAction,{get api(){return props.api
120
+ },get id(){return `${props.prefix}.apply`
121
+ },label:"Apply",icon:"done",get selected(){return props.active==="apply"
122
+ },get disabled(){return props.disabled
123
+ },onSelect:()=>props.onSelect("apply"),onActivate:()=>props.onActivate("apply")
124
+ }),null)
125
+ return _el$
126
+ })()
127
+ }
128
+ import {effect as _$effect2} from "@opentui/solid"
129
+ import {createTextNode as _$createTextNode2} from "@opentui/solid"
130
+ import {insertNode as _$insertNode2} from "@opentui/solid"
131
+ import {insert as _$insert2} from "@opentui/solid"
132
+ import {createComponent as _$createComponent2} from "@opentui/solid"
133
+ import {setProp as _$setProp2} from "@opentui/solid"
134
+ import {createElement as _$createElement2} from "@opentui/solid"
135
+ import {For} from "solid-js"
136
+ import {SelectionBox,useIcons as useIcons2} from "./tui.js"
137
+ function PresetPreviewSummary(props){const icons=useIcons2()
138
+ const theme=()=>props.api.theme.current
139
+ const color=(tone)=>tone==="accent"?theme().accent:tone==="warning"?theme().warning:theme().textMuted
140
+ return(()=>{var _el$=_$createElement2("box")
141
+ _$setProp2(_el$,"flexDirection","row")
142
+ _$setProp2(_el$,"flexWrap","wrap")
143
+ _$setProp2(_el$,"gap",1)
144
+ _$insert2(_el$,_$createComponent2(For,{get each(){return props.stats
145
+ },children:(stat)=>_$createComponent2(SelectionBox,{height:1,get backgroundColor(){return theme().backgroundElement
146
+ },paddingLeft:1,paddingRight:1,get children(){var _el$2=_$createElement2("text"),_el$3=_$createTextNode2(` `)
147
+ _$insertNode2(_el$2,_el$3)
148
+ _$setProp2(_el$2,"wrapMode","none")
149
+ _$setProp2(_el$2,"height",1)
150
+ _$insert2(_el$2,()=>icons.icon(stat.icon),_el$3)
151
+ _$insert2(_el$2,()=>stat.label,null)
152
+ _$effect2((_$p)=>_$setProp2(_el$2,"fg",color(stat.tone),_$p))
153
+ return _el$2
154
+ }})
155
+ }))
156
+ return _el$
157
+ })()
158
+ }
159
+ function PresetPreviewFrame(props){const icons=useIcons3()
160
+ const dialogs=useDialogs(props.api)
161
+ const theme=()=>props.api.theme.current
162
+ const dimensions=useTerminalDimensions()
163
+ const scroll=useDialogScroll()
164
+ const [active,setActive]=createSignal("apply")
165
+ let body
166
+ const prefix=`${PLUGIN_ID}.preset-preview`
167
+ function activate(action){if(!props.valid()){dialogs.close()
168
+ return
169
+ }
170
+ if(action==="cancel"){dialogs.back()
171
+ return
172
+ }
173
+ if(props.blocked)
174
+ return
175
+ try{props.onApply()
176
+ } catch(error){props.api.ui.toast({variant:"error",title:props.title,message:error instanceof Error?error.message:"Could not apply preset",duration:4000
177
+ })
178
+ }}
179
+ const unregister=props.api.keymap.registerLayer({mode:"modal",priority:1000,commands:[{name:`${prefix}.up`,run:()=>body?.scrollBy(-1)
180
+ },{name:`${prefix}.down`,run:()=>body?.scrollBy(1)
181
+ },{name:`${prefix}.move`,run:()=>{setActive((value)=>value==="apply"?"cancel":"apply")
182
+ }},{name:`${prefix}.submit`,run:()=>activate(active())
183
+ },{name:`${prefix}.refresh`,run:()=>props.onRefresh?.()
184
+ }],bindings:[{key:"up",cmd:`${prefix}.up`
185
+ },{key:"down",cmd:`${prefix}.down`
186
+ },{key:"tab",cmd:`${prefix}.move`
187
+ },{key:"left",cmd:`${prefix}.move`
188
+ },{key:"right",cmd:`${prefix}.move`
189
+ },{key:"return",cmd:`${prefix}.submit`
190
+ },{key:"ctrl+r",cmd:`${prefix}.refresh`
191
+ }]
192
+ })
193
+ onCleanup(unregister)
194
+ createEffect(()=>{if(!props.valid())
195
+ dialogs.close()
196
+ })
197
+ return _$createComponent3(DialogSurface,{get api(){return props.api
198
+ },lift:true,get children(){return [_$createComponent3(DialogHeader,{get api(){return props.api
199
+ },get title(){return props.title
200
+ },get onBack(){return dialogs.back
201
+ }}),(()=>{var _el$=_$createElement3("box"),_el$2=_$createElement3("text"),_el$3=_$createTextNode3(` Scope`),_el$4=_$createElement3("text")
202
+ _$insertNode3(_el$,_el$2)
203
+ _$insertNode3(_el$,_el$4)
204
+ _$setProp3(_el$,"flexDirection","row")
205
+ _$setProp3(_el$,"gap",1)
206
+ _$insertNode3(_el$2,_el$3)
207
+ _$setProp3(_el$2,"flexShrink",0)
208
+ _$insert3(_el$2,()=>icons.icon("scope"),_el$3)
209
+ _$setProp3(_el$4,"flexGrow",1)
210
+ _$setProp3(_el$4,"wrapMode","none")
211
+ _$setProp3(_el$4,"truncate",true)
212
+ _$setProp3(_el$4,"height",1)
213
+ _$insert3(_el$4,()=>props.scope)
214
+ _$effect3((_p$)=>{var _v$=theme().accent,_v$2=theme().textMuted
215
+ _v$!==_p$.e&&(_p$.e=_$setProp3(_el$2,"fg",_v$,_p$.e))
216
+ _v$2!==_p$.t&&(_p$.t=_$setProp3(_el$4,"fg",_v$2,_p$.t))
217
+ return _p$
218
+ },{e:undefined,t:undefined
219
+ })
220
+ return _el$
221
+ })(),_$createComponent3(PresetPreviewSummary,{get api(){return props.api
222
+ },get stats(){return props.stats
223
+ }}),(()=>{var _el$5=_$createElement3("scrollbox"),_el$6=_$createElement3("box")
224
+ _$insertNode3(_el$5,_el$6)
225
+ _$use((node)=>{body=node
226
+ scroll.ref(node)
227
+ },_el$5)
228
+ _$setProp3(_el$5,"scrollX",false)
229
+ _$setProp3(_el$5,"contentOptions",{minHeight:0
230
+ })
231
+ _$setProp3(_el$6,"gap",1)
232
+ _$insert3(_el$6,()=>props.children)
233
+ _$effect3((_p$)=>{var{restore:_v$3,save:_v$4}=scroll,_v$5=Math.min(Math.max(2,Math.floor(dimensions().height*0.75)-14),Math.max(1,props.rowCount*3-1))
234
+ _v$3!==_p$.e&&(_p$.e=_$setProp3(_el$5,"renderBefore",_v$3,_p$.e))
235
+ _v$4!==_p$.t&&(_p$.t=_$setProp3(_el$5,"renderAfter",_v$4,_p$.t))
236
+ _v$5!==_p$.a&&(_p$.a=_$setProp3(_el$5,"height",_v$5,_p$.a))
237
+ return _p$
238
+ },{e:undefined,t:undefined,a:undefined
239
+ })
240
+ return _el$5
241
+ })(),_$createComponent3(Show2,{get when(){return props.blocked||props.notice
242
+ },get children(){var _el$7=_$createElement3("box"),_el$8=_$createElement3("text"),_el$9=_$createElement3("text")
243
+ _$insertNode3(_el$7,_el$8)
244
+ _$insertNode3(_el$7,_el$9)
245
+ _$setProp3(_el$7,"flexDirection","row")
246
+ _$setProp3(_el$7,"gap",1)
247
+ _$setProp3(_el$8,"flexShrink",0)
248
+ _$insert3(_el$8,()=>icons.icon(props.blocked?"error":"info"))
249
+ _$setProp3(_el$9,"flexGrow",1)
250
+ _$setProp3(_el$9,"wrapMode","word")
251
+ _$setProp3(_el$9,"height",2)
252
+ _$insert3(_el$9,()=>props.blocked||props.notice)
253
+ _$effect3((_p$)=>{var _v$6=props.blocked?theme().warning:theme().accent,_v$7=props.blocked?theme().warning:theme().textMuted
254
+ _v$6!==_p$.e&&(_p$.e=_$setProp3(_el$8,"fg",_v$6,_p$.e))
255
+ _v$7!==_p$.t&&(_p$.t=_$setProp3(_el$9,"fg",_v$7,_p$.t))
256
+ return _p$
257
+ },{e:undefined,t:undefined
258
+ })
259
+ return _el$7
260
+ }}),_$createComponent3(PresetPreviewActions,{get api(){return props.api
261
+ },prefix,get active(){return active()
262
+ },get disabled(){return Boolean(props.blocked)
263
+ },onSelect:setActive,onActivate:activate,get onRefresh(){return props.onRefresh
264
+ }}),(()=>{var _el$0=_$createElement3("text"),_el$1=_$createTextNode3(` scroll \xB7 `),_el$10=_$createTextNode3(` select \xB7 `),_el$11=_$createTextNode3(` confirm`)
265
+ _$insertNode3(_el$0,_el$1)
266
+ _$insertNode3(_el$0,_el$10)
267
+ _$insertNode3(_el$0,_el$11)
268
+ _$insert3(_el$0,()=>icons.key("up/down"),_el$1)
269
+ _$insert3(_el$0,()=>icons.key("tab"),_el$10)
270
+ _$insert3(_el$0,()=>icons.key("enter"),_el$11)
271
+ _$effect3((_$p)=>_$setProp3(_el$0,"fg",theme().textMuted,_$p))
272
+ return _el$0
273
+ })()]
274
+ }})
275
+ }
276
+ import {createComponent as _$createComponent4} from "@opentui/solid"
277
+ import {effect as _$effect4} from "@opentui/solid"
278
+ import {createTextNode as _$createTextNode4} from "@opentui/solid"
279
+ import {insertNode as _$insertNode4} from "@opentui/solid"
280
+ import {insert as _$insert4} from "@opentui/solid"
281
+ import {setProp as _$setProp4} from "@opentui/solid"
282
+ import {createElement as _$createElement4} from "@opentui/solid"
283
+ import {memo as _$memo2} from "@opentui/solid"
284
+ import {TextAttributes} from "@opentui/core"
285
+ import {SelectionBox as SelectionBox2,useIcons as useIcons4} from "./tui.js"
286
+ function PresetPreviewRow(props){const icons=useIcons4()
287
+ const theme=()=>props.api.theme.current
288
+ const emphasized=()=>props.status!=="unchanged"
289
+ const statusColor=()=>props.status==="changed"?theme().accent:props.status==="skipped"?theme().warning:theme().textMuted
290
+ return _$createComponent4(SelectionBox2,{get backgroundColor(){return _$memo2(()=>!!emphasized())()?theme().backgroundElement:theme().backgroundPanel
291
+ },paddingLeft:1,paddingRight:1,get children(){return [(()=>{var _el$=_$createElement4("box"),_el$2=_$createElement4("text"),_el$3=_$createElement4("text"),_el$4=_$createElement4("text"),_el$5=_$createTextNode4(` `)
292
+ _$insertNode4(_el$,_el$2)
293
+ _$insertNode4(_el$,_el$3)
294
+ _$insertNode4(_el$,_el$4)
295
+ _$setProp4(_el$,"flexDirection","row")
296
+ _$setProp4(_el$,"gap",1)
297
+ _$setProp4(_el$2,"flexShrink",0)
298
+ _$insert4(_el$2,()=>props.icon)
299
+ _$setProp4(_el$3,"flexGrow",1)
300
+ _$setProp4(_el$3,"wrapMode","none")
301
+ _$setProp4(_el$3,"truncate",true)
302
+ _$setProp4(_el$3,"height",1)
303
+ _$insert4(_el$3,()=>props.title)
304
+ _$insertNode4(_el$4,_el$5)
305
+ _$setProp4(_el$4,"flexShrink",0)
306
+ _$insert4(_el$4,()=>icons.icon(props.statusIcon),_el$5)
307
+ _$insert4(_el$4,()=>props.statusLabel,null)
308
+ _$effect4((_p$)=>{var _v$=statusColor(),_v$2=emphasized()?theme().text:theme().textMuted,_v$3=emphasized()?TextAttributes.BOLD:undefined,_v$4=statusColor(),_v$5=emphasized()?TextAttributes.BOLD:undefined
309
+ _v$!==_p$.e&&(_p$.e=_$setProp4(_el$2,"fg",_v$,_p$.e))
310
+ _v$2!==_p$.t&&(_p$.t=_$setProp4(_el$3,"fg",_v$2,_p$.t))
311
+ _v$3!==_p$.a&&(_p$.a=_$setProp4(_el$3,"attributes",_v$3,_p$.a))
312
+ _v$4!==_p$.o&&(_p$.o=_$setProp4(_el$4,"fg",_v$4,_p$.o))
313
+ _v$5!==_p$.i&&(_p$.i=_$setProp4(_el$4,"attributes",_v$5,_p$.i))
314
+ return _p$
315
+ },{e:undefined,t:undefined,a:undefined,o:undefined,i:undefined
316
+ })
317
+ return _el$
318
+ })(),(()=>{var _el$6=_$createElement4("text"),_el$7=_$createTextNode4(` `)
319
+ _$insertNode4(_el$6,_el$7)
320
+ _$setProp4(_el$6,"wrapMode","none")
321
+ _$setProp4(_el$6,"truncate",true)
322
+ _$setProp4(_el$6,"height",1)
323
+ _$insert4(_el$6,()=>icons.icon("right"),_el$7)
324
+ _$insert4(_el$6,()=>props.description,null)
325
+ _$effect4((_$p)=>_$setProp4(_el$6,"fg",theme().textMuted,_$p))
326
+ return _el$6
327
+ })()]
328
+ }})
329
+ }
330
+ function reportMcpApplyError(api,request){request.catch((error)=>api.ui.toast({variant:"error",title:"Preset apply",message:error instanceof Error?error.message:"MCP preset failed",duration:4000
331
+ }))
332
+ }
333
+ function mcpPreviewRows(model,icons){return model.preview().rows.map((row)=>{const unchanged=`${row.status ?? "not available"} \xB7 unchanged`
334
+ const description=row.desired?{missing:`Not available in this workspace \xB7 target ${row.desired} \xB7 skipped`,unavailable:`${row.status} \xB7 target ${row.desired} \xB7 skipped`,connect:`${row.status} -> enabled \xB7 connect`,disconnect:`${row.status} -> disabled \xB7 disconnect`,unchanged
335
+ }[row.change]:`${unchanged} (not in preset)`
336
+ return{icon:icons.icon("mcp"),title:row.name,description,changed:row.change==="connect"||row.change==="disconnect",skipped:row.change==="missing"||row.change==="unavailable",status:row.change==="connect"||row.change==="disconnect"?"changed":row.change==="missing"||row.change==="unavailable"?"skipped":"unchanged",statusIcon:row.change==="connect"?"connected":row.change==="disconnect"?"disconnected":row.change==="missing"||row.change==="unavailable"?"pending":"done",statusLabel:row.change==="connect"?"CONNECT":row.change==="disconnect"?"DISCONNECT":row.change==="missing"||row.change==="unavailable"?"SKIPPED":"UNCHANGED"
337
+ }}).sort((a,b)=>Number(b.changed)-Number(a.changed)||Number(b.skipped)-Number(a.skipped))
338
+ }
339
+ function LayoutPresetPreview(props){const dialogs=useDialogs3(props.api)
340
+ const icons=useIcons6()
341
+ const model=createLayoutPresetPreview(props.preferences,props.name)
342
+ const linked=props.preferences.workspaceProfiles?.()[props.name]
343
+ const mcpModel=props.controller&&linked?createMcpPresetPreview(props.controller,props.preferences,linked):undefined
344
+ const rows=createMemo3(()=>model.preview()?.rows.map((row)=>{const changes=[]
345
+ if(row.visibility[0]!==row.visibility[1])
346
+ changes.push(`${row.visibility[0] ? "visible" : "hidden"} -> ${row.visibility[1] ? "visible" : "hidden"}`)
347
+ if(row.expansion[0]!==row.expansion[1])
348
+ changes.push(`${row.expansion[0] ? "expanded" : "collapsed"} -> ${row.expansion[1] ? "expanded" : "collapsed"}`)
349
+ if(row.position[0]!==row.position[1])
350
+ changes.push(`position ${row.position[0]} -> ${row.position[1]}`)
351
+ return{icon:icons.section(row.section),title:`${row.position[1]}. ${SECTION_DEFINITIONS.find((section) => section.name === row.section).label}`,description:changes.join(" \xB7 ")||"No changes",changed:changes.length>0,status:changes.length>0?"changed":"unchanged",statusIcon:changes.length>0?"edit":"done",statusLabel:changes.length>0?"CHANGED":"UNCHANGED"
352
+ }}).sort((a,b)=>Number(b.changed)-Number(a.changed))??[])
353
+ const mcpRows=createMemo3(()=>mcpModel?mcpPreviewRows(mcpModel,icons):[])
354
+ const changed=()=>rows().filter((row)=>row.changed).length
355
+ onMount2(()=>mcpModel?.refresh())
356
+ return _$createComponent6(PresetPreviewFrame,{get api(){return props.api
357
+ },get title(){return `${mcpModel ? "Workspace profile" : "Layout preview"}: ${props.name}`
358
+ },get scope(){return mcpModel?`${model.scope()} \xB7 MCP ${mcpModel.scope}`:model.scope()
359
+ },get stats(){return [{icon:"edit",label:`${changed()} layout changed`,tone:"accent"
360
+ },{icon:"done",label:`${rows().length - changed()} layout unchanged`,tone:"muted"
361
+ }]
362
+ },get rowCount(){return rows().length + mcpRows().length
363
+ },valid:()=>model.valid()&&(!mcpModel||mcpModel.valid()),get blocked(){return model.blocked()||mcpModel?.blocked()
364
+ },get onRefresh(){return mcpModel?.refresh
365
+ },notice:mcpModel?"Applies both presets; layout stays session-only.":"Applies layout changes. Save current layout as default to persist them.",onApply:()=>{const request=mcpModel?.apply()
366
+ if(mcpModel&&!request)
367
+ return
368
+ if(!model.apply())
369
+ return
370
+ if(mcpModel)
371
+ dialogs.close()
372
+ else{dialogs.back()
373
+ dialogs.back()
374
+ }
375
+ props.api.ui.toast({variant:"success",title:mcpModel?"Workspace profile":"Layout presets",message:`${props.name} applied to ${model.scope()}`,duration:3000
376
+ })
377
+ if(request)
378
+ reportMcpApplyError(props.api,request)
379
+ },get children(){return [_$createComponent6(For3,{get each(){return rows()
380
+ },children:(row)=>_$createComponent6(PresetPreviewRow,_$mergeProps2({get api(){return props.api
381
+ }},row))
382
+ }),_$createComponent6(For3,{get each(){return mcpRows()
383
+ },children:(row)=>_$createComponent6(PresetPreviewRow,_$mergeProps2({get api(){return props.api
384
+ }},row))
385
+ })]
386
+ }})
387
+ }
388
+ function createLayoutPresetActions(input){function prompt(value="",renameFrom){input.dialogs.prompt({title:renameFrom?"Rename layout preset":"Save layout preset",description:()=>(()=>{var _el$=_$createElement5("text")
389
+ _$insertNode5(_el$,_$createTextNode5(`Save the current visible, expanded, and ordered sections.`))
390
+ _$effect5((_$p)=>_$setProp5(_el$,"fg",input.api.theme.current.textMuted,_$p))
391
+ return _el$
392
+ })(),placeholder:"Preset name",value,onConfirm(nameInput){const name=renameFrom?input.preferences.renameLayoutPreset(renameFrom,nameInput):input.preferences.saveLayoutPreset(nameInput)
393
+ input.dialogs.back()
394
+ if(renameFrom)
395
+ input.dialogs.back()
396
+ input.api.ui.toast({variant:"success",title:"Layout presets",message:renameFrom?`Renamed to ${name}`:`Saved ${name}`,duration:3000
397
+ })
398
+ }})
399
+ }
400
+ function open(name){const linked=input.preferences.workspaceProfiles?.()[name]
401
+ const link=()=>input.dialogs.prompt({title:`Link MCP \xB7 ${name}`,placeholder:"Preset; empty unlinks",value:linked??"",onConfirm(value){input.preferences.linkWorkspaceProfile?.(name,value.trim()||undefined)
402
+ input.dialogs.back()
403
+ input.dialogs.back()
404
+ }})
405
+ input.dialogs.open(()=>_$createComponent7(SelectionMenu,{get api(){return input.api
406
+ },title:name,get options(){return [{title:"Preview & apply",value:"apply",icon:"info",description:linked?`profile \xB7 ${linked}`:"review layout changes"
407
+ },{title:"Update from current",value:"update",icon:"save",description:"replace saved layout"
408
+ },{title:"Rename\u2026",value:"rename",description:"change preset name",icon:"edit"
409
+ },...input.mcp?[{title:linked?"Change MCP link\u2026":"Link MCP preset\u2026",value:"link",icon:"mcp",description:linked?`${linked} \xB7 empty unlinks`:"pair layout and MCP"
410
+ }]:[],{title:"Delete",value:"delete",description:"remove preset",icon:"delete"
411
+ }]
412
+ },onSelect:(option)=>{switch(option.value){case "apply":{input.dialogs.open(()=>_$createComponent7(LayoutPresetPreview,{get api(){return input.api
413
+ },get preferences(){return input.preferences
414
+ },get controller(){return input.mcp
415
+ },name
416
+ }),"large")
417
+ break
418
+ }
419
+ case "update":{input.preferences.updateLayoutPreset(name)
420
+ input.dialogs.back()
421
+ input.api.ui.toast({variant:"success",title:"Layout presets",message:`Updated ${name}`,duration:3000
422
+ })
423
+ break
424
+ }
425
+ case "rename":{prompt(name,name)
426
+ break
427
+ }
428
+ case "link":{link()
429
+ break
430
+ }
431
+ default:{input.preferences.deleteLayoutPreset(name)
432
+ input.dialogs.back()
433
+ }}}}))
434
+ }
435
+ return{open,prompt
436
+ }}
437
+ export{createLayoutPresetActions
438
+ }
@@ -1,7 +1,7 @@
1
- import {PresetMenu,GroupAssignmentDialog
2
- } from "./chunk-r37n7w97.js";
1
+ import {GroupAssignmentDialog
2
+ } from "./chunk-c1kw6ktv.js";
3
3
  import {createComponent as _$createComponent2} from "@opentui/solid"
4
- import {useDialogs} from "./tui.js"
4
+ import {SelectionMenu,useDialogs} from "./tui.js"
5
5
  import {createComponent as _$createComponent} from "@opentui/solid"
6
6
  function McpGroupAssignmentDialog(props){return _$createComponent(GroupAssignmentDialog,{get api(){return props.api
7
7
  },get title(){return `Group for ${props.server}`
@@ -13,7 +13,7 @@ const options=()=>{const live=new Set(props.mcp.list(props.mcp.target()).map((it
13
13
  return [...new Set([...live,...Object.keys(props.preferences.mcpServerGroups?.()??{})])].sort((a,b)=>a.localeCompare(b)).map((name)=>({title:name,value:name,description:[props.preferences.mcpServerGroups?.()[name]??"Ungrouped",!live.has(name)&&"not in workspace"].filter(Boolean).join(" \xB7 ")
14
14
  }))
15
15
  }
16
- return _$createComponent2(PresetMenu,{get api(){return props.api
16
+ return _$createComponent2(SelectionMenu,{get api(){return props.api
17
17
  },title:"MCP groups \xB7 user-wide",get options(){return options()
18
18
  },onSelect:(option)=>dialogs.open(()=>_$createComponent2(McpGroupAssignmentDialog,{get api(){return props.api
19
19
  },get preferences(){return props.preferences