mirror of
https://github.com/ollama/ollama.git
synced 2026-04-18 09:54:18 +02:00
launch: add openclaw channels setup (#15407)
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -18,12 +19,16 @@ var (
|
||||
|
||||
type confirmModel struct {
|
||||
prompt string
|
||||
yesLabel string
|
||||
noLabel string
|
||||
yes bool
|
||||
confirmed bool
|
||||
cancelled bool
|
||||
width int
|
||||
}
|
||||
|
||||
type ConfirmOptions = launch.ConfirmOptions
|
||||
|
||||
func (m confirmModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
@@ -40,22 +45,16 @@ func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "n":
|
||||
case "ctrl+c", "esc":
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
case "y":
|
||||
m.yes = true
|
||||
m.confirmed = true
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
m.confirmed = true
|
||||
return m, tea.Quit
|
||||
case "left", "h":
|
||||
case "left":
|
||||
m.yes = true
|
||||
case "right", "l":
|
||||
case "right":
|
||||
m.yes = false
|
||||
case "tab":
|
||||
m.yes = !m.yes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +67,20 @@ func (m confirmModel) View() string {
|
||||
}
|
||||
|
||||
var yesBtn, noBtn string
|
||||
yesLabel := m.yesLabel
|
||||
if yesLabel == "" {
|
||||
yesLabel = "Yes"
|
||||
}
|
||||
noLabel := m.noLabel
|
||||
if noLabel == "" {
|
||||
noLabel = "No"
|
||||
}
|
||||
if m.yes {
|
||||
yesBtn = confirmActiveStyle.Render(" Yes ")
|
||||
noBtn = confirmInactiveStyle.Render(" No ")
|
||||
yesBtn = confirmActiveStyle.Render(" " + yesLabel + " ")
|
||||
noBtn = confirmInactiveStyle.Render(" " + noLabel + " ")
|
||||
} else {
|
||||
yesBtn = confirmInactiveStyle.Render(" Yes ")
|
||||
noBtn = confirmActiveStyle.Render(" No ")
|
||||
yesBtn = confirmInactiveStyle.Render(" " + yesLabel + " ")
|
||||
noBtn = confirmActiveStyle.Render(" " + noLabel + " ")
|
||||
}
|
||||
|
||||
s := selectorTitleStyle.Render(m.prompt) + "\n\n"
|
||||
@@ -89,9 +96,26 @@ func (m confirmModel) View() string {
|
||||
// RunConfirm shows a bubbletea yes/no confirmation prompt.
|
||||
// Returns true if the user confirmed, false if cancelled.
|
||||
func RunConfirm(prompt string) (bool, error) {
|
||||
return RunConfirmWithOptions(prompt, ConfirmOptions{})
|
||||
}
|
||||
|
||||
// RunConfirmWithOptions shows a bubbletea yes/no confirmation prompt with
|
||||
// optional custom button labels.
|
||||
func RunConfirmWithOptions(prompt string, options ConfirmOptions) (bool, error) {
|
||||
yesLabel := options.YesLabel
|
||||
if yesLabel == "" {
|
||||
yesLabel = "Yes"
|
||||
}
|
||||
noLabel := options.NoLabel
|
||||
if noLabel == "" {
|
||||
noLabel = "No"
|
||||
}
|
||||
|
||||
m := confirmModel{
|
||||
prompt: prompt,
|
||||
yes: true, // default to yes
|
||||
prompt: prompt,
|
||||
yesLabel: yesLabel,
|
||||
noLabel: noLabel,
|
||||
yes: true, // default to yes
|
||||
}
|
||||
|
||||
p := tea.NewProgram(m)
|
||||
|
||||
@@ -33,6 +33,22 @@ func TestConfirmModel_View_ContainsButtons(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_View_ContainsCustomButtons(t *testing.T) {
|
||||
m := confirmModel{
|
||||
prompt: "Connect a messaging app now?",
|
||||
yesLabel: "Yes",
|
||||
noLabel: "Set up later",
|
||||
yes: true,
|
||||
}
|
||||
got := m.View()
|
||||
if !strings.Contains(got, "Yes") {
|
||||
t.Error("should contain custom yes button")
|
||||
}
|
||||
if !strings.Contains(got, "Set up later") {
|
||||
t.Error("should contain custom no button")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_View_ContainsHelp(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
got := m.View()
|
||||
@@ -109,30 +125,33 @@ func TestConfirmModel_CtrlCCancels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_NCancels(t *testing.T) {
|
||||
func TestConfirmModel_NDoesNothing(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
||||
fm := updated.(confirmModel)
|
||||
if !fm.cancelled {
|
||||
t.Error("'n' should set cancelled=true")
|
||||
if fm.cancelled {
|
||||
t.Error("'n' should not cancel")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("'n' should return tea.Quit")
|
||||
if fm.confirmed {
|
||||
t.Error("'n' should not confirm")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("'n' should not quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_YConfirmsYes(t *testing.T) {
|
||||
func TestConfirmModel_YDoesNothing(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: false}
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
||||
fm := updated.(confirmModel)
|
||||
if !fm.confirmed {
|
||||
t.Error("'y' should set confirmed=true")
|
||||
if fm.confirmed {
|
||||
t.Error("'y' should not confirm")
|
||||
}
|
||||
if !fm.yes {
|
||||
t.Error("'y' should set yes=true")
|
||||
if fm.yes {
|
||||
t.Error("'y' should not change selection")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("'y' should return tea.Quit")
|
||||
if cmd != nil {
|
||||
t.Error("'y' should not quit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,36 +159,33 @@ func TestConfirmModel_ArrowKeysNavigate(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
|
||||
// Right moves to No
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||
fm := updated.(confirmModel)
|
||||
if fm.yes {
|
||||
t.Error("right/l should move to No")
|
||||
t.Error("right should move to No")
|
||||
}
|
||||
if fm.confirmed || fm.cancelled {
|
||||
t.Error("navigation should not confirm or cancel")
|
||||
}
|
||||
|
||||
// Left moves back to Yes
|
||||
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
|
||||
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
fm = updated.(confirmModel)
|
||||
if !fm.yes {
|
||||
t.Error("left/h should move to Yes")
|
||||
t.Error("left should move to Yes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmModel_TabToggles(t *testing.T) {
|
||||
func TestConfirmModel_TabDoesNothing(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
fm := updated.(confirmModel)
|
||||
if fm.yes {
|
||||
t.Error("tab should toggle from Yes to No")
|
||||
}
|
||||
|
||||
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
fm = updated.(confirmModel)
|
||||
if !fm.yes {
|
||||
t.Error("tab should toggle from No to Yes")
|
||||
t.Error("tab should not change selection")
|
||||
}
|
||||
if fm.confirmed || fm.cancelled {
|
||||
t.Error("tab should not confirm or cancel")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -56,7 +55,7 @@ var (
|
||||
const maxSelectorItems = 10
|
||||
|
||||
// ErrCancelled is returned when the user cancels the selection.
|
||||
var ErrCancelled = errors.New("cancelled")
|
||||
var ErrCancelled = launch.ErrCancelled
|
||||
|
||||
type SelectItem struct {
|
||||
Name string
|
||||
|
||||
@@ -51,14 +51,14 @@ var mainMenuItems = []menuItem{
|
||||
description: "Start an interactive chat with a model",
|
||||
isRunModel: true,
|
||||
},
|
||||
{
|
||||
integration: "openclaw",
|
||||
},
|
||||
{
|
||||
integration: "claude",
|
||||
},
|
||||
{
|
||||
integration: "codex",
|
||||
},
|
||||
{
|
||||
integration: "openclaw",
|
||||
integration: "opencode",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -136,9 +136,9 @@ func integrationMenuItem(state launch.LauncherIntegrationState) menuItem {
|
||||
|
||||
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
pinned := map[string]bool{
|
||||
"claude": true,
|
||||
"codex": true,
|
||||
"openclaw": true,
|
||||
"claude": true,
|
||||
"opencode": true,
|
||||
}
|
||||
|
||||
var items []menuItem
|
||||
|
||||
@@ -36,6 +36,13 @@ func launcherTestState() *launch.LauncherState {
|
||||
Changeable: true,
|
||||
AutoInstallable: true,
|
||||
},
|
||||
"opencode": {
|
||||
Name: "opencode",
|
||||
DisplayName: "OpenCode",
|
||||
Description: "Anomaly's open-source coding agent",
|
||||
Selectable: true,
|
||||
Changeable: true,
|
||||
},
|
||||
"droid": {
|
||||
Name: "droid",
|
||||
DisplayName: "Droid",
|
||||
@@ -54,13 +61,25 @@ func launcherTestState() *launch.LauncherState {
|
||||
}
|
||||
}
|
||||
|
||||
func findMenuCursorByIntegration(items []menuItem, name string) int {
|
||||
for i, item := range items {
|
||||
if item.integration == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
||||
view := newModel(launcherTestState()).View()
|
||||
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Codex", "Launch OpenClaw", "More..."} {
|
||||
for _, want := range []string{"Chat with a model", "Launch OpenClaw", "Launch Claude Code", "Launch OpenCode", "More..."} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
||||
}
|
||||
}
|
||||
if strings.Contains(view, "Launch Codex") {
|
||||
t.Fatalf("expected Codex to be under More, not pinned\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuExpandsOthersFromLastSelection(t *testing.T) {
|
||||
@@ -102,7 +121,10 @@ func TestMenuRightOnRunSelectsChangeRun(t *testing.T) {
|
||||
|
||||
func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
||||
menu := newModel(launcherTestState())
|
||||
menu.cursor = 1
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected claude menu item")
|
||||
}
|
||||
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
got := updated.(model)
|
||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude"}
|
||||
@@ -113,7 +135,10 @@ func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
||||
|
||||
func TestMenuRightOnIntegrationSelectsConfigure(t *testing.T) {
|
||||
menu := newModel(launcherTestState())
|
||||
menu.cursor = 1
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected claude menu item")
|
||||
}
|
||||
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||
got := updated.(model)
|
||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude", ForceConfigure: true}
|
||||
@@ -130,7 +155,10 @@ func TestMenuIgnoresDisabledActions(t *testing.T) {
|
||||
state.Integrations["claude"] = claude
|
||||
|
||||
menu := newModel(state)
|
||||
menu.cursor = 1
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected claude menu item")
|
||||
}
|
||||
|
||||
updatedEnter, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if updatedEnter.(model).selected {
|
||||
@@ -150,7 +178,10 @@ func TestMenuShowsCurrentModelSuffixes(t *testing.T) {
|
||||
t.Fatalf("expected run row to show current model suffix\n%s", runView)
|
||||
}
|
||||
|
||||
menu.cursor = 1
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected claude menu item")
|
||||
}
|
||||
integrationView := menu.View()
|
||||
if !strings.Contains(integrationView, "(glm-5:cloud)") {
|
||||
t.Fatalf("expected integration row to show current model suffix\n%s", integrationView)
|
||||
@@ -166,8 +197,12 @@ func TestMenuShowsInstallStatusAndHint(t *testing.T) {
|
||||
codex.InstallHint = "Install from https://example.com/codex"
|
||||
state.Integrations["codex"] = codex
|
||||
|
||||
state.LastSelection = "codex"
|
||||
menu := newModel(state)
|
||||
menu.cursor = 2
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "codex")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected codex menu item in overflow section")
|
||||
}
|
||||
view := menu.View()
|
||||
if !strings.Contains(view, "(not installed)") {
|
||||
t.Fatalf("expected not-installed marker\n%s", view)
|
||||
|
||||
Reference in New Issue
Block a user