Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/leantui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ func builtinCommands() []ui.Command {
return []ui.Command{
{Name: "new", Desc: "Start a new session", Kind: ui.CmdBuiltin},
{Name: "compact", Desc: "Summarize and compact the conversation", Kind: ui.CmdBuiltin},
{Name: "model", Desc: "Change the model for the current agent", Kind: ui.CmdBuiltin},
{Name: "effort", Desc: "Set the model's reasoning effort (usage: /effort <level>)", Kind: ui.CmdBuiltin},
{Name: "clear", Desc: "Clear the screen", Kind: ui.CmdBuiltin},
{Name: "help", Desc: "Show keyboard shortcuts and commands", Kind: ui.CmdBuiltin},
Expand Down
56 changes: 46 additions & 10 deletions pkg/leantui/ui/autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,56 @@ import "strings"

const autocompleteMaxRows = 8

// Autocomplete drives the slash-command popup. It is active whenever the input
// is a partial command: a single token starting with "/" and no spaces yet.
// Autocomplete drives the slash-command popup for commands and scoped arguments.
type Autocomplete struct {
all []Command
matches []Command
selected int
Active bool
base []Command
all []Command
matches []Command
selected int
scopePrefix string
Active bool
}

func NewAutocomplete() *Autocomplete {
return &Autocomplete{}
}

func (a *Autocomplete) SetCommands(cmds []Command) {
a.base = cmds
a.all = cmds
a.scopePrefix = ""
}

// SetScopedCommands temporarily replaces the command list with completions for
// an argument-taking command. prefix excludes the leading slash and includes
// any desired separator, for example "model ".
func (a *Autocomplete) SetScopedCommands(prefix string, cmds []Command) {
a.all = cmds
a.scopePrefix = prefix
a.selected = 0
}

// Sync recomputes the popup state from the current editor text. It returns true
// while the popup is showing.
func (a *Autocomplete) Sync(input string) bool {
if !strings.HasPrefix(input, "/") || strings.ContainsAny(input, " \n") {
prefix := "/" + a.scopePrefix
var query string
switch {
case a.scopePrefix != "" && strings.HasPrefix(input, prefix) && !strings.Contains(input, "\n"):
query = strings.TrimPrefix(input, prefix)
case strings.HasPrefix(input, "/") && !strings.ContainsAny(input, " \n"):
query = input[1:]
default:
a.Active = false
a.matches = nil
a.selected = 0
return false
}
a.matches = FilterCommands(a.all, input[1:])
if a.scopePrefix == "" {
a.matches = FilterCommands(a.all, query)
} else {
a.matches = FilterScopedCommands(a.all, query)
}
a.Active = len(a.matches) > 0
if a.selected >= len(a.matches) {
a.selected = len(a.matches) - 1
Expand Down Expand Up @@ -70,6 +93,19 @@ func (a *Autocomplete) Dismiss() {
a.Active = false
a.matches = nil
a.selected = 0
if a.scopePrefix != "" {
a.all = a.base
a.scopePrefix = ""
}
}

// Completion returns the editor text for a selected completion.
func (a *Autocomplete) Completion(cmd Command) string {
value := cmd.Value
if value == "" {
value = cmd.Name
}
return "/" + a.scopePrefix + value
}

// Render returns the popup rows (top to bottom) for the given width.
Expand All @@ -86,7 +122,7 @@ func (a *Autocomplete) Render(width int) []string {

nameWidth := 0
for _, c := range a.matches {
if w := len(c.Name) + 1; w > nameWidth {
if w := len(a.scopePrefix) + len(c.Name) + 1; w > nameWidth {
nameWidth = w
}
}
Expand All @@ -95,7 +131,7 @@ func (a *Autocomplete) Render(width int) []string {
var rows []string
for i := start; i < end; i++ {
c := a.matches[i]
name := PadRight("/"+c.Name, nameWidth)
name := PadRight("/"+a.scopePrefix+c.Name, nameWidth)
line := " " + name + " " + c.Desc
line = Truncate(line, width)
if i == a.selected {
Expand Down
33 changes: 33 additions & 0 deletions pkg/leantui/ui/autocomplete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/modelpicker"
"github.com/docker/docker-agent/pkg/runtime"
)

func testCommands() []Command {
Expand All @@ -31,6 +34,36 @@ func TestAutocompleteActivation(t *testing.T) {
assert.False(t, a.Sync("/zzzzz")) // no matches
}

func TestAutocompleteScopedCommands(t *testing.T) {
t.Parallel()
a := NewAutocomplete()
a.SetCommands(testCommands())
gpt := runtime.ModelChoice{Name: "GPT Five", Ref: "openai/gpt-5", Provider: "openai", Model: "gpt-5"}
sonnet := runtime.ModelChoice{Name: "Claude Sonnet", Ref: "anthropic/claude-sonnet-4-6", Provider: "anthropic", Model: "claude-sonnet-4-6"}
a.SetScopedCommands("model ", []Command{
{Name: gpt.Ref, MatchScore: func(query string) (int, bool) { return modelpicker.Score(gpt, query) }, Kind: CmdBuiltin},
{Name: sonnet.Ref, MatchScore: func(query string) (int, bool) { return modelpicker.Score(sonnet, query) }, Kind: CmdBuiltin},
})

require.True(t, a.Sync("/model open"))
cmd, ok := a.Current()
require.True(t, ok)
assert.Equal(t, "openai/gpt-5", cmd.Name)
assert.Equal(t, "/model openai/gpt-5", a.Completion(cmd))

require.True(t, a.Sync("/model five"))
cmd, ok = a.Current()
require.True(t, ok)
assert.Equal(t, "openai/gpt-5", cmd.Name)

a.Dismiss()
assert.False(t, a.Sync("/model open"))
require.True(t, a.Sync("/ne"))
cmd, ok = a.Current()
require.True(t, ok)
assert.Equal(t, "new", cmd.Name)
}

func TestAutocompleteNavigation(t *testing.T) {
t.Parallel()
a := NewAutocomplete()
Expand Down
44 changes: 40 additions & 4 deletions pkg/leantui/ui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,54 @@ const (
)

type Command struct {
Name string
Desc string
Kind CommandKind
Name string
Desc string
Value string
MatchScore func(query string) (int, bool)
Kind CommandKind
}

// FilterCommands returns the commands whose name has the given prefix, built-in
// commands first, then agent commands, each group alphabetically sorted.
func FilterCommands(all []Command, prefix string) []Command {
prefix = strings.ToLower(prefix)
return filterCommands(all, func(c Command) bool {
return strings.HasPrefix(strings.ToLower(c.Name), prefix)
})
}

// FilterScopedCommands ranks argument completions with their configured matcher.
func FilterScopedCommands(all []Command, query string) []Command {
query = strings.TrimSpace(query)
type scoredCommand struct {
command Command
score int
}
matches := make([]scoredCommand, 0, len(all))
for _, command := range all {
score, ok := 0, query == ""
if command.MatchScore != nil {
score, ok = command.MatchScore(query)
}
if ok {
matches = append(matches, scoredCommand{command: command, score: score})
}
}
sort.SliceStable(matches, func(i, j int) bool {
return matches[i].score > matches[j].score
})

result := make([]Command, len(matches))
for i, match := range matches {
result[i] = match.command
}
return result
}

func filterCommands(all []Command, match func(Command) bool) []Command {
var out []Command
for _, c := range all {
if strings.HasPrefix(strings.ToLower(c.Name), prefix) {
if match(c) {
out = append(out, c)
}
}
Expand Down
72 changes: 69 additions & 3 deletions pkg/leantui/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/leantui/ui"
"github.com/docker/docker-agent/pkg/modelpicker"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/tui/messages"
)
Expand Down Expand Up @@ -104,8 +105,9 @@ func (m *model) handleInterrupt() {
func (m *model) handleEnter(ctx context.Context) {
if m.screen.Autocomplete.Active {
if cmd, ok := m.screen.Autocomplete.Current(); ok {
completion := m.screen.Autocomplete.Completion(cmd)
m.screen.Autocomplete.Dismiss()
m.submitEditor(ctx, "/"+cmd.Name)
m.submitEditor(ctx, completion)
return
}
}
Expand All @@ -117,7 +119,7 @@ func (m *model) handleTab() {
return
}
if cmd, ok := m.screen.Autocomplete.Current(); ok {
m.screen.Editor.SetText("/" + cmd.Name + " ")
m.screen.Editor.SetText(m.screen.Autocomplete.Completion(cmd) + " ")
m.screen.Autocomplete.Sync(m.screen.Editor.Text())
}
}
Expand Down Expand Up @@ -244,6 +246,9 @@ func (m *model) handleSlash(ctx context.Context, text string, mode busySubmitMod
m.addUserEcho(text)
m.startCompact(ctx, rest)
return true
case "model":
m.handleModelCommand(ctx, rest)
return true
case "effort":
m.handleSetThinkingLevel(ctx, rest)
return true
Expand All @@ -268,6 +273,61 @@ func (m *model) handleSlash(ctx context.Context, text string, mode busySubmitMod
return false
}

func (m *model) handleModelCommand(ctx context.Context, modelRef string) {
if m.app == nil || !m.app.SupportsModelSwitching() {
m.addNotice("", "Model switching is not supported with this runtime", ui.StMuted())
return
}

if modelRef == "" {
models := m.app.AvailableModels(ctx)
if len(models) == 0 {
m.addNotice("", "No models available for selection", ui.StMuted())
return
}
cmds := make([]ui.Command, 0, len(models))
for _, choice := range models {
desc := choice.Name
if choice.IsCurrent {
desc = strings.TrimSpace(desc + " (current)")
} else if choice.IsDefault {
desc = strings.TrimSpace(desc + " (default)")
}
value := choice.Ref
if choice.IsDefault {
value = "default"
}
cmds = append(cmds, ui.Command{
Name: choice.Ref,
Desc: desc,
Value: value,
MatchScore: func(query string) (int, bool) {
return modelpicker.Score(choice, query)
},
Kind: ui.CmdBuiltin,
})
}
m.screen.Autocomplete.SetScopedCommands("model ", cmds)
m.screen.Editor.SetText("/model ")
m.screen.Autocomplete.Sync(m.screen.Editor.Text())
return
}

if modelRef == "default" {
modelRef = ""
}
if err := m.app.SetCurrentAgentModel(ctx, modelRef); err != nil {
m.addNotice("✗ ", "Failed to change model: "+err.Error(), ui.StError())
return
}
m.refreshCommands(ctx)
if modelRef == "" {
m.addNotice("", "Model reset to default", ui.StMuted())
return
}
m.addNotice("", "Model changed to "+modelRef, ui.StMuted())
}

func (m *model) dispatchUserMessage(ctx context.Context, display, content string, mode busySubmitMode) {
if m.app.IsReadOnly() {
m.addUserEcho(display)
Expand Down Expand Up @@ -351,7 +411,12 @@ func (m *model) startSkillFork(ctx context.Context, name, task string) {
}

func (m *model) refreshCommands(ctx context.Context) {
cmds := builtinCommands()
cmds := make([]ui.Command, 0)
for _, cmd := range builtinCommands() {
if !m.disabledCommands[cmd.Name] {
cmds = append(cmds, cmd)
}
}
for name, c := range m.app.CurrentAgentCommands(ctx) {
if m.disabledCommands[name] {
continue
Expand Down Expand Up @@ -469,6 +534,7 @@ func (m *model) commitHelp() {
ui.StBold().Render("Commands"),
ui.StMuted().Render(" /new start a new session"),
ui.StMuted().Render(" /compact summarize and compact the conversation"),
ui.StMuted().Render(" /model change the model for the current agent"),
ui.StMuted().Render(" /effort set the model's reasoning effort (e.g. /effort high)"),
ui.StMuted().Render(" /clear clear the screen"),
ui.StMuted().Render(" /help show this help"),
Expand Down
Loading
Loading