launch: add openclaw channels setup (#15407)

This commit is contained in:
Parth Sareen
2026-04-08 13:25:27 -07:00
committed by GitHub
parent 55308f1421
commit 4e16f562c0
15 changed files with 868 additions and 105 deletions

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)