Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ func runTrusted(envName string, fallbacks []string,
fmt.Fprintln(os.Stderr, "code: trusted launcher not found:", err)
return 1
}
if !broker.configured() {
err = runChild(path, argv(path, os.Args[1:], prompt), withoutAuthEnv(os.Environ()))
if err != nil {
fmt.Fprintln(os.Stderr, "code: trusted child:", err)
}
return childStatus(err)
}
accounts, err := loadAccounts(broker)
if err != nil {
fmt.Fprintln(os.Stderr, "code: account snapshot unavailable; refusing unrestricted launch:", err)
Expand Down
9 changes: 9 additions & 0 deletions layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ func (m model) bodyLines() ([]string, int) {
// target gets the same footer shape with an honest summary instead of meters:
// its tokens are free and code has no measurement to quote.
func (m model) launchFooter() []string {
if m.noProviders {
return []string{
"",
stDim.Render(" no connected OMP providers"),
"",
"",
stDim.Render(" m open managed OMP to log in"),
}
}
acc := lipgloss.NewStyle().Foreground(lipgloss.Color(m.accent())).Bold(true).Render(" ⏎ launch")
if _, local := m.selectedRuntime(); local {
return []string{
Expand Down
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ func main() {
// The catalog decides which dials exist at all; a persisted or default
// selection must not open on a combo it never generated.
m.applyCatalog()
if cachedAvailability.accountsOK {
m.applyProviderAvailability(connectedPools(cachedAvailability.accounts))
}
// First run: no catalog anywhere and no explicit CODE_GENERATED — wrap the
// TUI in the guided onboarding that builds one (an explicit but broken
// CODE_GENERATED is an operator config error and is left visible as the
Expand Down
84 changes: 76 additions & 8 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,7 @@ func TestRawMouseBurstRemainsResponsive(t *testing.T) {
wide, _, _, _ := layoutSizes(t, m)
m = resize(t, m, wide.w, wide.h)
m.broker = brokerConfig{} // keep unrelated fetch/ticks out of the program
m.providersResolved = true
var views atomic.Int64
keySeen := make(chan burstKeyState, 1)
filter := wheelInputFilter{}
Expand Down Expand Up @@ -3593,20 +3594,27 @@ cat "$OMP_AUTH_BROKER_ACCOUNT_POOL_FILE" > "$ACCOUNT_POOL_COPY"
}
}

func TestTrustedLaunchAbortsWithoutSnapshot(t *testing.T) {
func TestTrustedLaunchWithoutBrokerUsesLocalOMPAuth(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "started")
capture := filepath.Join(dir, "env")
script := filepath.Join(dir, "omp")
if err := os.WriteFile(script, []byte("#!/bin/sh\n: > \"$MARKER\"\n"), 0o700); err != nil {
body := "#!/bin/sh\nprintf '%s|%s|%s\\n' \"${OMP_AUTH_BROKER_URL+set}\" \"${OMP_AUTH_BROKER_TOKEN+set}\" \"${OMP_AUTH_BROKER_ACCOUNT_POOL_FILE+set}\" > \"$CAPTURE\"\n"
if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("CODE_OMP", script)
t.Setenv("MARKER", marker)
if status := runTrusted("CODE_OMP", nil, managedLaunchArgv, "", brokerConfig{}, defaultAccountSelectionState()); status == 0 {
t.Fatal("trusted launch without a snapshot unexpectedly succeeded")
t.Setenv("CAPTURE", capture)
t.Setenv("OMP_AUTH_BROKER_URL", "http://incomplete")
t.Setenv("OMP_AUTH_BROKER_ACCOUNT_POOL_FILE", "/tmp/stale-pool")
if status := runTrusted("CODE_OMP", nil, managedLaunchArgv, "", brokerConfig{}, defaultAccountSelectionState()); status != 0 {
t.Fatalf("direct trusted launch status = %d", status)
}
raw, err := os.ReadFile(capture)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("trusted child started without a snapshot: %v", err)
if got := strings.TrimSpace(string(raw)); got != "||" {
t.Fatalf("direct child retained broker environment: %q", got)
}
}

Expand Down Expand Up @@ -3744,6 +3752,66 @@ func TestApplyCatalogGrowsLaneDial(t *testing.T) {
}
}

func TestProviderAvailabilityFiltersDisconnectedLanes(t *testing.T) {
m := model{
generated: map[string][]string{
"gpt-only_fast_low_nosp_nofa": {" default gpt:low"},
"gpt-led_fast_low_nosp_nofa": {" default gpt:low"},
"mixed_fast_low_nosp_nofa": {" default gpt:low"},
"claude-only_fast_low_nosp_nofa": {" default claude:low"},
"ox-only_fast_low_nosp_nofa": {" default ox:low"},
},
facets: facetDefs(defaultGlyphs()),
sel: defaultSel(),
}
m.applyCatalog()
m.applyProviderAvailability(map[string]bool{"R": true})

var lanes []string
for _, f := range m.facets {
if f.key == "lane" {
lanes = f.values
}
}
if !reflect.DeepEqual(lanes, []string{"ox-only"}) {
t.Fatalf("OpenRouter-only lanes = %v, want [ox-only]", lanes)
}
if m.sel["lane"] != "ox-only" || m.noProviders {
t.Fatalf("OpenRouter-only selection = %q noProviders=%v", m.sel["lane"], m.noProviders)
}
for _, f := range m.visibleFacets() {
if f.key == "blend" {
t.Fatalf("single available lane exposed an unavailable blend: %v", f.values)
}
}

m.applyProviderAvailability(nil)
if !m.noProviders || len(m.visibleFacets()) != 0 {
t.Fatalf("accountless generator remained actionable: noProviders=%v facets=%v", m.noProviders, m.visibleFacets())
}
next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if got := next.(model); got.genConfig != "" || cmd != nil {
t.Fatalf("accountless Enter launched: config=%q cmd=%v", got.genConfig, cmd)
}
if footer := strings.Join(m.launchFooter(), "\n"); !strings.Contains(footer, "m open managed OMP to log in") {
t.Fatalf("accountless footer is not actionable: %q", footer)
}
}

func TestDirectProviderProbeUsesOMPToken(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "omp")
body := "#!/bin/sh\n[ \"$1\" = token ] && [ \"$2\" = openrouter ]\n"
if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("CODE_OMP", script)
msg := probeProviderAvailabilityCmd()().(providerAvailabilityMsg)
if !reflect.DeepEqual(msg.pools, map[string]bool{"R": true}) {
t.Fatalf("provider probe pools = %v, want R only", msg.pools)
}
}

// TestUsageBodyDeepSeekBalanceGroup: the DeepSeek usage group renders only
// when a credential exists (an absent API key is the normal state, not "not
// authenticated"), shows the prepaid balance with no bar or reset, and
Expand Down
39 changes: 35 additions & 4 deletions model.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
package main

import (
"io"
"os"
"os/exec"
"regexp"
"strconv"
"time"

tea "github.com/charmbracelet/bubbletea"

"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)

type model struct {
Expand All @@ -28,7 +29,9 @@ type model struct {
noFable bool // no _fa_/_famain_ combos — same for fable and its main child
// hasRelief is positive-polarity: the relief segment only exists in
// catalogs with an optional pool, so the zero value keeps old ids intact.
hasRelief bool // _rel_/_norel combos exist — show the relief dial
hasRelief bool // _rel_/_norel combos exist — show the relief dial
providersResolved bool // connected-provider discovery completed
noProviders bool // discovery found no provider usable by this catalog

depth int // 0 lead · 1 full
collapse bool // p: hide the Routing section
Expand Down Expand Up @@ -98,6 +101,32 @@ func fetchUsageCmd(broker brokerConfig) tea.Cmd {
return func() tea.Msg { return usageMsg{avail: loadAvailability(broker)} }
}

type providerAvailabilityMsg struct {
pools map[string]bool
}

// probeProviderAvailabilityCmd asks the same OMP binary Code launches which
// providers have usable local credentials. Broker-backed installations get
// this information from their account snapshot instead.
func probeProviderAvailabilityCmd() tea.Cmd {
return func() tea.Msg {
pools := map[string]bool{}
path, err := resolveLaunchPath("CODE_OMP", []string{"omp"})
if err != nil {
return providerAvailabilityMsg{pools: pools}
}
for _, provider := range providerRegistry {
cmd := exec.Command(path, "token", provider.ID)
cmd.Env = withoutAuthEnv(os.Environ())
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
if cmd.Run() == nil {
pools[provider.Pool] = true
}
}
return providerAvailabilityMsg{pools: pools}
}
}

func (m *model) startUsageFetch() tea.Cmd {
cmd := fetchUsageCmd(m.broker)
if cmd != nil {
Expand Down Expand Up @@ -165,8 +194,10 @@ func (m model) ompVersionAtLeast(major, minor int) bool {

func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{probeOmpVersionCmd()}
if m.broker.URL != "" && m.broker.Token != "" {
if m.broker.configured() {
cmds = append(cmds, m.startUsageFetch(), m.spin.Tick, tickCmd())
} else if !m.providersResolved {
cmds = append(cmds, probeProviderAvailabilityCmd())
}
return tea.Batch(cmds...)
}
4 changes: 3 additions & 1 deletion render.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ func (m *model) syncPreviewAt(yoff int) {
return
}
id := comboID(m.sel, m.hasRelief)
if base, ok := m.generated[id]; ok {
if m.noProviders {
b.WriteString(stDim.Render("no connected OMP providers") + "\n")
} else if base, ok := m.generated[id]; ok {
_, roles := splitMeta(base)
roles = m.applyAdvisor(roles, m.sel["advisor"])
b.WriteString(m.renderRoute(roles, m.depth, m.selectedLaunchAvailability(), rw))
Expand Down
98 changes: 93 additions & 5 deletions routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,16 +315,24 @@ func (m model) visibleFacets() []facet {
}
return out
}
if m.noProviders {
return nil
}
lane := m.sel["lane"]
var out []facet
for _, f := range m.facets {
if f.key == "lane" {
lead, blend := laneSplit(lane)
m.sel["lead"], m.sel["blend"] = lead, blend
// mixed leads the dial: it is the default and the only lead
// without a blend child, so it anchors the left edge.
leads := []string{"mixed"}
seen := map[string]bool{"mixed": true}
var leads []string
seen := map[string]bool{}
for _, v := range f.values {
if v == "mixed" {
leads = append(leads, "mixed")
seen["mixed"] = true
break
}
}
for _, v := range f.values {
l, _ := laneSplit(v)
if !seen[l] {
Expand All @@ -334,7 +342,22 @@ func (m model) visibleFacets() []facet {
}
out = append(out, facet{"lead", leads, f.glyph})
if lead != "mixed" {
out = append(out, facet{"blend", []string{"led", "only"}, f.glyph})
availableBlends := map[string]bool{}
for _, v := range f.values {
l, b := laneSplit(v)
if l == lead {
availableBlends[b] = true
}
}
var blends []string
for _, b := range []string{"led", "only"} {
if availableBlends[b] {
blends = append(blends, b)
}
}
if len(blends) > 1 {
out = append(out, facet{"blend", blends, f.glyph})
}
}
continue
}
Expand Down Expand Up @@ -412,6 +435,71 @@ func comboID(sel map[string]string, hasRelief bool) string {
return id
}

func connectedPools(accounts map[string][]account) map[string]bool {
pools := map[string]bool{}
for providerID, providerAccounts := range accounts {
if len(providerAccounts) == 0 {
continue
}
if provider := providerByID(providerID); provider != nil {
pools[provider.Pool] = true
}
}
return pools
}

// applyProviderAvailability narrows the generated lane catalog to credentials
// OMP can actually use. Pure lanes need only their own provider; blended lanes
// need every provider represented by the catalog because their role and
// fallback chains can cross the full pool set.
func (m *model) applyProviderAvailability(connected map[string]bool) {
m.providersResolved = true
catalog := catalogLanes(m.generated)
catalogPools := map[string]bool{}
for _, lane := range catalog {
if lanePure(lane) {
if provider := providerByLane(lane); provider != nil {
catalogPools[provider.Pool] = true
}
}
}
var available []string
for _, lane := range catalog {
if provider := providerByLane(lane); lanePure(lane) && provider != nil {
if connected[provider.Pool] {
available = append(available, lane)
}
continue
}
usable := len(catalogPools) > 0
for pool := range catalogPools {
if !connected[pool] {
usable = false
break
}
}
if usable {
available = append(available, lane)
}
}
m.noProviders = len(available) == 0
if m.noProviders {
return
}
for i := range m.facets {
if m.facets[i].key == "lane" {
m.facets[i].values = available
break
}
}
served := make(map[string]bool, len(available))
for _, lane := range available {
served[lane] = true
}
m.trimLanes(served)
m.clampSel()
}

// applyCatalog records which dials this catalog can actually serve, then forces
// the rest off. A models file with no tier-0 model yields no _sp_ combos at all,
// so the shipped default (spark on) would open the TUI on a combo that was never
Expand Down
10 changes: 10 additions & 0 deletions update.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ompMajor, m.ompMinor = msg.major, msg.minor
}
return m, nil
case providerAvailabilityMsg:
m.applyProviderAvailability(msg.pools)
m.relayout()
return m, nil
case usageMsg:
scoped, scopedStale := reconcileUsage(m.avail, msg.avail)
refreshAt := time.Now().Add(refreshEvery)
Expand All @@ -30,6 +34,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
saveUsageCache(m.usageCache, scoped)
}
m.nextRefresh = refreshAt
if msg.avail.accountsOK {
m.applyProviderAvailability(connectedPools(msg.avail.accounts))
}
m.relayout()
if first {
// The first real data replaces the skeleton: run the one-time
Expand Down Expand Up @@ -179,6 +186,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.launchRuntime = target.Name
return m, tea.Quit
}
if m.noProviders {
return m, nil
}
// Enter always launches the generated profile for the current facets —
// the untouched default combo is a generated profile like any other.
// Never for a combo the catalog doesn't carry, though: genConfigYAML
Expand Down
Loading
Loading