From 8dd34b77d1bf2651f61cfff31a3cb0826b53f78b Mon Sep 17 00:00:00 2001 From: Eva H <63033505+hoyyeva@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:11:16 -0700 Subject: [PATCH] cmd/tui: restore launcher integrations menu (#17595) --- cmd/tui/tui.go | 92 +++++++++++++++++++++++++++++++++++++++------ cmd/tui/tui_test.go | 75 +++++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 14 deletions(-) diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 2eff72962..998a91e4c 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -42,6 +42,7 @@ type menuItem struct { description string integration string isRunModel bool + isOthers bool } var runModelMenuItem = menuItem{ @@ -50,33 +51,61 @@ var runModelMenuItem = menuItem{ isRunModel: true, } -// launcherMenuIntegrations is intentionally short: the root ollama command is -// a quick path to the most common launch targets. Other registered -// integrations remain available through `ollama launch `. +var othersMenuItem = menuItem{ + title: "More...", + description: "Show additional integrations", + isOthers: true, +} + +// launcherMenuIntegrations defines the integrations pinned to the root menu. +// Additional visible integrations are available through More in registry order. var launcherMenuIntegrations = []string{"claude", "opencode", "hermes", "openclaw"} type model struct { - state *launch.LauncherState - items []menuItem - cursor int - width int - quitting bool - selected bool - action TUIAction + state *launch.LauncherState + items []menuItem + cursor int + showOthers bool + width int + quitting bool + selected bool + action TUIAction } func newModel(state *launch.LauncherState) model { m := model{ state: state, } - m.items = buildMenuItems(state) + m.showOthers = shouldExpandOthers(state) + m.items = buildMenuItems(state, m.showOthers) m.cursor = initialCursor(state, m.items) return m } -func buildMenuItems(state *launch.LauncherState) []menuItem { +func shouldExpandOthers(state *launch.LauncherState) bool { + if state == nil { + return false + } + for _, item := range otherIntegrationItems(state) { + if item.integration == state.LastSelection { + return true + } + } + return false +} + +func buildMenuItems(state *launch.LauncherState, showOthers bool) []menuItem { items := []menuItem{runModelMenuItem} items = append(items, launcherIntegrationItems(state)...) + + otherItems := otherIntegrationItems(state) + switch { + case showOthers: + items = append(items, otherItems...) + case len(otherItems) > 0: + items = append(items, othersMenuItem) + } + return items } @@ -108,6 +137,34 @@ func launcherIntegrationItems(state *launch.LauncherState) []menuItem { return items } +func otherIntegrationItems(state *launch.LauncherState) []menuItem { + if state == nil { + return nil + } + + pinned := make(map[string]bool, len(launcherMenuIntegrations)) + for _, name := range launcherMenuIntegrations { + pinned[name] = true + } + + items := make([]menuItem, 0, len(state.Integrations)) + for _, info := range launch.ListIntegrationInfos() { + if pinned[info.Name] { + continue + } + integrationState, ok := state.Integrations[info.Name] + if !ok { + continue + } + items = append(items, integrationMenuItem(integrationState)) + } + return items +} + +func primaryMenuItemCount(state *launch.LauncherState) int { + return 1 + len(launcherIntegrationItems(state)) +} + func initialCursor(state *launch.LauncherState, items []menuItem) int { if state == nil || state.LastSelection == "" { return 0 @@ -143,12 +200,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } + if m.showOthers && m.cursor < primaryMenuItemCount(m.state) { + m.showOthers = false + m.items = buildMenuItems(m.state, false) + m.cursor = min(m.cursor, len(m.items)-1) + } return m, nil case "down", "j": if m.cursor < len(m.items)-1 { m.cursor++ } + if m.cursor < len(m.items) && m.items[m.cursor].isOthers && !m.showOthers { + m.showOthers = true + m.items = buildMenuItems(m.state, true) + } return m, nil case "enter", " ": @@ -231,6 +297,8 @@ func (m model) renderMenuItem(index int, item menuItem) string { if m.cursor == index { style = menuSelectedItemStyle } + } else if item.isOthers { + // More immediately expands when reached, so it always uses the default style. } else { integrationState := m.state.Integrations[item.integration] if !integrationState.Selectable { diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index 2f2b1e813..bf40ba748 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -91,6 +91,8 @@ func integrationSequence(items []menuItem) []string { switch { case item.isRunModel: sequence = append(sequence, "run") + case item.isOthers: + sequence = append(sequence, "more") case item.integration != "": sequence = append(sequence, item.integration) } @@ -105,7 +107,7 @@ func compareStrings(got, want []string) string { func TestMenuRendersRootLaunchChoices(t *testing.T) { state := launcherTestState() menu := newModel(state) - want := []string{"run", "claude", "opencode", "hermes", "openclaw"} + want := []string{"run", "claude", "opencode", "hermes", "openclaw", "more"} if diff := compareStrings(integrationSequence(menu.items), want); diff != "" { t.Fatalf("unexpected root launch choices: %s", diff) } @@ -118,18 +120,87 @@ func TestMenuRendersRootLaunchChoices(t *testing.T) { "Launch OpenCode", "Launch Hermes Agent", "Launch OpenClaw", + "More...", } { if !strings.Contains(view, want) { t.Fatalf("expected menu view to contain %q\n%s", want, view) } } - for _, hidden := range []string{"Launch ChatGPT", "Launch Codex", "Launch Droid", "Launch Pi", "More..."} { + for _, hidden := range []string{"Launch ChatGPT", "Launch Codex", "Launch Droid", "Launch Pi"} { if strings.Contains(view, hidden) { t.Fatalf("expected root menu to omit %q\n%s", hidden, view) } } } +func TestMenuExpandsMoreOnDownNavigation(t *testing.T) { + state := launcherTestState() + menu := newModel(state) + menu.cursor = findMenuCursorByIntegration(menu.items, "openclaw") + if menu.cursor == -1 { + t.Fatal("expected openclaw menu item") + } + + updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyDown}) + got := updated.(model) + if !got.showOthers { + t.Fatal("expected navigating down onto More to expand additional integrations") + } + if got.items[got.cursor].integration == "" { + t.Fatalf("expected cursor to land on the first additional integration, got %#v", got.items[got.cursor]) + } + if strings.Contains(got.View(), "More...") { + t.Fatalf("expected expanded integrations to replace More\n%s", got.View()) + } +} + +func TestMenuStartsExpandedForPreviousOverflowSelection(t *testing.T) { + state := launcherTestState() + overflow := otherIntegrationItems(state) + if len(overflow) < 2 { + t.Fatal("expected at least two additional integrations") + } + state.LastSelection = overflow[1].integration + + menu := newModel(state) + if !menu.showOthers { + t.Fatal("expected previous additional integration selection to start expanded") + } + if got := menu.items[menu.cursor].integration; got != state.LastSelection { + t.Fatalf("initial cursor integration = %q, want %q", got, state.LastSelection) + } + if strings.Contains(menu.View(), "More...") { + t.Fatalf("expected expanded menu to omit More\n%s", menu.View()) + } +} + +func TestMenuOmitsMoreWithoutAdditionalIntegrations(t *testing.T) { + state := launcherTestState() + for name := range state.Integrations { + if name != "claude" && name != "opencode" && name != "hermes" && name != "openclaw" { + delete(state.Integrations, name) + } + } + state.Integrations["claude-desktop"] = launch.LauncherIntegrationState{ + Name: "claude-desktop", + DisplayName: "Claude Desktop", + Selectable: true, + Changeable: true, + } + + menu := newModel(state) + want := []string{"run", "claude", "opencode", "hermes", "openclaw"} + if diff := compareStrings(integrationSequence(menu.items), want); diff != "" { + t.Fatalf("unexpected menu without additional integrations: %s", diff) + } + if strings.Contains(menu.View(), "More...") { + t.Fatalf("expected no More item without additional integrations\n%s", menu.View()) + } + if strings.Contains(menu.View(), "Claude Desktop") { + t.Fatalf("expected hidden integration to remain omitted\n%s", menu.View()) + } +} + func TestMenuEnterOnRunSelectsRun(t *testing.T) { menu := newModel(launcherTestState()) updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})