cmd/tui: restore launcher integrations menu (#17595)

This commit is contained in:
Eva H
2026-08-07 11:11:16 -07:00
committed by GitHub
parent 35f71382de
commit 8dd34b77d1
2 changed files with 153 additions and 14 deletions
+80 -12
View File
@@ -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 <integration>`.
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 {
+73 -2
View File
@@ -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})