mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 13:38:14 -05:00
cmd/tui: restore launcher integrations menu (#17595)
This commit is contained in:
+80
-12
@@ -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
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user