mirror of
https://github.com/ollama/ollama.git
synced 2026-07-23 09:10:53 -05:00
cmd/tui: simplify integration selection and update menu description (#17159)
This commit is contained in:
102
cmd/tui/tui.go
102
cmd/tui/tui.go
@@ -42,68 +42,41 @@ type menuItem struct {
|
||||
description string
|
||||
integration string
|
||||
isRunModel bool
|
||||
isOthers bool
|
||||
}
|
||||
|
||||
const pinnedIntegrationCount = 4
|
||||
|
||||
var runModelMenuItem = menuItem{
|
||||
title: "Chat, Code, & Work",
|
||||
description: "Chat with models, code, search the web, and do work",
|
||||
description: "Chat with models, code, search the web, and delegate real work",
|
||||
isRunModel: true,
|
||||
}
|
||||
|
||||
var othersMenuItem = menuItem{
|
||||
title: "More...",
|
||||
description: "Show additional integrations",
|
||||
isOthers: 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 launcherMenuIntegrations = []string{"claude", "opencode", "hermes", "openclaw"}
|
||||
|
||||
type model struct {
|
||||
state *launch.LauncherState
|
||||
items []menuItem
|
||||
cursor int
|
||||
showOthers bool
|
||||
width int
|
||||
quitting bool
|
||||
selected bool
|
||||
action TUIAction
|
||||
state *launch.LauncherState
|
||||
items []menuItem
|
||||
cursor int
|
||||
width int
|
||||
quitting bool
|
||||
selected bool
|
||||
action TUIAction
|
||||
}
|
||||
|
||||
func newModel(state *launch.LauncherState) model {
|
||||
m := model{
|
||||
state: state,
|
||||
}
|
||||
m.showOthers = shouldExpandOthers(state)
|
||||
m.items = buildMenuItems(state, m.showOthers)
|
||||
m.items = buildMenuItems(state)
|
||||
m.cursor = initialCursor(state, m.items)
|
||||
return m
|
||||
}
|
||||
|
||||
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 {
|
||||
func buildMenuItems(state *launch.LauncherState) []menuItem {
|
||||
items := []menuItem{runModelMenuItem}
|
||||
items = append(items, pinnedIntegrationItems(state)...)
|
||||
|
||||
otherItems := otherIntegrationItems(state)
|
||||
switch {
|
||||
case showOthers:
|
||||
items = append(items, otherItems...)
|
||||
case len(otherItems) > 0:
|
||||
items = append(items, othersMenuItem)
|
||||
}
|
||||
|
||||
items = append(items, launcherIntegrationItems(state)...)
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -119,30 +92,14 @@ func integrationMenuItem(state launch.LauncherIntegrationState) menuItem {
|
||||
}
|
||||
}
|
||||
|
||||
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
ordered := orderedIntegrationItems(state)
|
||||
if len(ordered) <= pinnedIntegrationCount {
|
||||
return nil
|
||||
}
|
||||
return ordered[pinnedIntegrationCount:]
|
||||
}
|
||||
|
||||
func pinnedIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
ordered := orderedIntegrationItems(state)
|
||||
if len(ordered) <= pinnedIntegrationCount {
|
||||
return ordered
|
||||
}
|
||||
return ordered[:pinnedIntegrationCount]
|
||||
}
|
||||
|
||||
func orderedIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
func launcherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]menuItem, 0, len(state.Integrations))
|
||||
for _, info := range launch.ListIntegrationInfos() {
|
||||
integrationState, ok := state.Integrations[info.Name]
|
||||
items := make([]menuItem, 0, len(launcherMenuIntegrations))
|
||||
for _, name := range launcherMenuIntegrations {
|
||||
integrationState, ok := state.Integrations[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -151,10 +108,6 @@ func orderedIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
return items
|
||||
}
|
||||
|
||||
func primaryMenuItemCount(state *launch.LauncherState) int {
|
||||
return 1 + len(pinnedIntegrationItems(state))
|
||||
}
|
||||
|
||||
func initialCursor(state *launch.LauncherState, items []menuItem) int {
|
||||
if state == nil || state.LastSelection == "" {
|
||||
return 0
|
||||
@@ -190,21 +143,12 @@ 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", " ":
|
||||
@@ -235,7 +179,7 @@ func (m model) selectableItem(item menuItem) bool {
|
||||
if item.isRunModel {
|
||||
return true
|
||||
}
|
||||
if item.integration == "" || item.isOthers {
|
||||
if item.integration == "" {
|
||||
return false
|
||||
}
|
||||
state, ok := m.state.Integrations[item.integration]
|
||||
@@ -243,7 +187,7 @@ func (m model) selectableItem(item menuItem) bool {
|
||||
}
|
||||
|
||||
func (m model) changeableItem(item menuItem) bool {
|
||||
if item.integration == "" || item.isOthers {
|
||||
if item.integration == "" {
|
||||
return false
|
||||
}
|
||||
state, ok := m.state.Integrations[item.integration]
|
||||
@@ -287,10 +231,6 @@ func (m model) renderMenuItem(index int, item menuItem) string {
|
||||
if m.cursor == index {
|
||||
style = menuSelectedItemStyle
|
||||
}
|
||||
} else if item.isOthers {
|
||||
if m.cursor == index {
|
||||
style = menuSelectedItemStyle
|
||||
}
|
||||
} else {
|
||||
integrationState := m.state.Integrations[item.integration]
|
||||
if !integrationState.Selectable {
|
||||
|
||||
@@ -91,8 +91,6 @@ 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)
|
||||
}
|
||||
@@ -104,88 +102,31 @@ func compareStrings(got, want []string) string {
|
||||
return cmp.Diff(want, got)
|
||||
}
|
||||
|
||||
func expectedCollapsedSequence(state *launch.LauncherState) []string {
|
||||
sequence := []string{"run"}
|
||||
for _, item := range pinnedIntegrationItems(state) {
|
||||
sequence = append(sequence, item.integration)
|
||||
}
|
||||
if len(otherIntegrationItems(state)) > 0 {
|
||||
sequence = append(sequence, "more")
|
||||
}
|
||||
return sequence
|
||||
}
|
||||
|
||||
func expectedExpandedSequence(state *launch.LauncherState) []string {
|
||||
sequence := []string{"run"}
|
||||
for _, item := range pinnedIntegrationItems(state) {
|
||||
sequence = append(sequence, item.integration)
|
||||
}
|
||||
for _, item := range otherIntegrationItems(state) {
|
||||
sequence = append(sequence, item.integration)
|
||||
}
|
||||
return sequence
|
||||
}
|
||||
|
||||
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
||||
func TestMenuRendersRootLaunchChoices(t *testing.T) {
|
||||
state := launcherTestState()
|
||||
menu := newModel(state)
|
||||
wantPrefix := []string{"run", "claude", "codex-app", "hermes", "openclaw"}
|
||||
if findMenuCursorByIntegration(menu.items, "codex-app") == -1 {
|
||||
wantPrefix = []string{"run", "claude", "hermes", "openclaw", "opencode"}
|
||||
}
|
||||
if got := integrationSequence(menu.items); len(got) < len(wantPrefix) {
|
||||
t.Fatalf("expected at least %d menu items, got %v", len(wantPrefix), got)
|
||||
} else if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" {
|
||||
t.Fatalf("unexpected primary TUI order: %s", diff)
|
||||
want := []string{"run", "claude", "opencode", "hermes", "openclaw"}
|
||||
if diff := compareStrings(integrationSequence(menu.items), want); diff != "" {
|
||||
t.Fatalf("unexpected root launch choices: %s", diff)
|
||||
}
|
||||
|
||||
view := menu.View()
|
||||
for _, want := range []string{
|
||||
"Chat, Code, & Work",
|
||||
"Chat with models, code, search the web, and do work",
|
||||
"Chat with models, code, search the web, and delegate real work",
|
||||
"Launch Claude Code",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
if findMenuCursorByIntegration(menu.items, "codex-app") != -1 && !strings.Contains(view, "Launch Codex App") {
|
||||
t.Fatalf("expected menu view to contain Codex App\n%s", view)
|
||||
}
|
||||
if strings.Contains(view, "Launch Claude Desktop") {
|
||||
t.Fatalf("expected hidden Claude Desktop to be absent\n%s", view)
|
||||
}
|
||||
wantOrder := expectedCollapsedSequence(state)
|
||||
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
|
||||
t.Fatalf("unexpected pinned order: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuExpandsOthersFromLastSelection(t *testing.T) {
|
||||
state := launcherTestState()
|
||||
overflow := otherIntegrationItems(state)
|
||||
if len(overflow) == 0 {
|
||||
t.Fatal("expected at least one overflow integration")
|
||||
}
|
||||
state.LastSelection = overflow[0].integration
|
||||
|
||||
menu := newModel(state)
|
||||
if !menu.showOthers {
|
||||
t.Fatal("expected others section to expand when last selection is in the overflow list")
|
||||
}
|
||||
view := menu.View()
|
||||
if !strings.Contains(view, overflow[0].title) {
|
||||
t.Fatalf("expected expanded view to contain overflow integration\n%s", view)
|
||||
}
|
||||
if strings.Contains(view, "More...") {
|
||||
t.Fatalf("expected expanded view to replace More... item\n%s", view)
|
||||
}
|
||||
wantOrder := expectedExpandedSequence(state)
|
||||
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
|
||||
t.Fatalf("unexpected expanded order: %s", diff)
|
||||
for _, hidden := range []string{"Launch Codex App", "Launch Codex", "Launch Droid", "Launch Pi", "More..."} {
|
||||
if strings.Contains(view, hidden) {
|
||||
t.Fatalf("expected root menu to omit %q\n%s", hidden, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,24 +221,24 @@ func TestMenuShowsCurrentModelSuffixes(t *testing.T) {
|
||||
|
||||
func TestMenuShowsInstallStatusAndHint(t *testing.T) {
|
||||
state := launcherTestState()
|
||||
codex := state.Integrations["codex"]
|
||||
codex.Installed = false
|
||||
codex.Selectable = false
|
||||
codex.Changeable = false
|
||||
codex.InstallHint = "Install from https://example.com/codex"
|
||||
state.Integrations["codex"] = codex
|
||||
opencode := state.Integrations["opencode"]
|
||||
opencode.Installed = false
|
||||
opencode.Selectable = false
|
||||
opencode.Changeable = false
|
||||
opencode.InstallHint = "Install from https://example.com/opencode"
|
||||
state.Integrations["opencode"] = opencode
|
||||
|
||||
state.LastSelection = "codex"
|
||||
state.LastSelection = "opencode"
|
||||
menu := newModel(state)
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "codex")
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "opencode")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected codex menu item in overflow section")
|
||||
t.Fatal("expected opencode menu item")
|
||||
}
|
||||
view := menu.View()
|
||||
if !strings.Contains(view, "(not installed)") {
|
||||
t.Fatalf("expected not-installed marker\n%s", view)
|
||||
}
|
||||
if !strings.Contains(view, codex.InstallHint) {
|
||||
if !strings.Contains(view, opencode.InstallHint) {
|
||||
t.Fatalf("expected install hint in description\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user