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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/glamour v1.0.0 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
Expand All @@ -26,6 +27,7 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
Expand All @@ -46,6 +48,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
Expand Down
36 changes: 33 additions & 3 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"fmt"
"time"

"github.com/charmbracelet/bubbles/filepicker"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
Expand All @@ -26,9 +28,14 @@ type App struct {
help help.Model
spinner spinner.Model
sessions list.Model
commandMenu list.Model
commandMenuMeta commandMenuMeta
providerPicker list.Model
modelPicker list.Model
fileBrowser filepicker.Model
progress progress.Model
transcript viewport.Model
activity viewport.Model
input textarea.Model
markdownRenderer markdownContentRenderer
codeCopyBlocks map[int]string
Expand All @@ -44,6 +51,9 @@ type App struct {
fileCandidates []string
modelRefreshID string
focus panel
runProgressValue float64
runProgressKnown bool
runProgressLabel string
width int
height int
styles styles
Expand Down Expand Up @@ -110,6 +120,20 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime
h := help.New()
h.ShowAll = false

commandMenu := newCommandMenuModel(uiStyles)

fileBrowser := filepicker.New()
fileBrowser.SetHeight(10)
fileBrowser.AutoHeight = false
fileBrowser.ShowPermissions = false
fileBrowser.ShowSize = false
fileBrowser.FileAllowed = true
fileBrowser.DirAllowed = true
fileBrowser.CurrentDirectory = cfg.Workdir

progressBar := progress.New(progress.WithDefaultGradient(), progress.WithoutPercentage())
progressBar.Width = 22

app := App{
state: UIState{
StatusText: statusReady,
Expand All @@ -126,9 +150,13 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime
help: h,
spinner: spin,
sessions: sessionList,
providerPicker: newProviderPicker(nil),
modelPicker: newModelPicker(nil),
commandMenu: commandMenu,
providerPicker: newSelectionPickerItems(nil),
modelPicker: newSelectionPickerItems(nil),
fileBrowser: fileBrowser,
progress: progressBar,
transcript: viewport.New(0, 0),
activity: viewport.New(0, 0),
input: input,
markdownRenderer: markdownRenderer,
codeCopyBlocks: make(map[int]string),
Expand Down Expand Up @@ -162,7 +190,9 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime
if err := app.refreshFileCandidates(); err != nil {
return App{}, err
}
app.resizeComponents()
app.applyComponentLayout(true)
app.refreshCommandMenu()
app.rebuildActivity()
return app, nil
}

Expand Down
208 changes: 208 additions & 0 deletions internal/tui/command_menu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package tui

import (
"path/filepath"
"strings"

"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)

const (
maxCommandMenuRows = 6
commandMenuBrowse = "@ browse files..."
)

func (a *App) refreshCommandMenu() {
input := a.input.Value()
if a.state.ActivePicker != pickerNone {
a.commandMenu.SetItems(nil)
a.commandMenuMeta = commandMenuMeta{}
return
}

items, meta := a.buildCommandMenuItems(input, a.transcript.Width)
if len(items) == 0 {
a.commandMenu.SetItems(nil)
a.commandMenuMeta = commandMenuMeta{}
return
}

selectedTitle := ""
if selected, ok := a.commandMenu.SelectedItem().(commandMenuItem); ok {
selectedTitle = selected.title
}

listItems := make([]list.Item, 0, len(items))
selectedIndex := 0
for index, item := range items {
listItems = append(listItems, item)
if selectedTitle != "" && strings.EqualFold(item.title, selectedTitle) {
selectedIndex = index
}
}

a.commandMenu.SetItems(listItems)
a.commandMenu.Select(selectedIndex)
a.commandMenuMeta = meta
a.resizeCommandMenu()
}

func (a *App) resizeCommandMenu() {
width := max(24, a.transcript.Width)
rows := clamp(len(a.commandMenu.Items()), 0, maxCommandMenuRows)
a.commandMenu.SetSize(max(16, width-4), max(1, rows))
}

func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, commandMenuMeta) {
if suggestions := a.fileMenuSuggestions(input); len(suggestions) > 0 {
return suggestions, commandMenuMeta{Title: fileMenuTitle}
}

trimmed := strings.TrimSpace(input)
if isWorkspaceCommandInput(trimmed) {
replacement := trimmed
item := commandMenuItem{
title: workspaceCommandUsage,
description: trimMiddle(a.state.CurrentWorkdir, max(24, width-28)),
highlight: true,
replacement: replacement,
}
if trimmed == workspaceCommandPrefix {
start, end, _, _ := tokenRange(input, tokenSelectorFirst)
item.replacement = workspaceCommandPrefix + " "
item.useReplaceRange = true
item.replaceStart = start
item.replaceEnd = end
}
return []commandMenuItem{item}, commandMenuMeta{Title: shellMenuTitle}
}

suggestions := a.matchingSlashCommands(trimmed)
if len(suggestions) == 0 {
return nil, commandMenuMeta{}
}

start, end, _, _ := tokenRange(input, tokenSelectorFirst)
items := make([]commandMenuItem, 0, len(suggestions))
for _, suggestion := range suggestions {
items = append(items, commandMenuItem{
title: suggestion.Command.Usage,
description: suggestion.Command.Description,
filter: suggestion.Command.Usage + " " + suggestion.Command.Description,
highlight: suggestion.Match,
replacement: suggestion.Command.Usage,
useReplaceRange: true,
replaceStart: start,
replaceEnd: end,
})
}
return items, commandMenuMeta{Title: commandMenuTitle}
}

func (a App) fileMenuSuggestions(input string) []commandMenuItem {
start, end, query, suggestions, ok := a.resolveFileReferenceSuggestions(input)
if !ok {
return nil
}
if len(suggestions) == 0 && query != "" {
return nil
}

items := make([]commandMenuItem, 0, len(suggestions)+1)
if query == "" {
items = append(items, commandMenuItem{
title: commandMenuBrowse,
description: "open workspace file browser",
filter: commandMenuBrowse,
highlight: true,
openFileBrowser: true,
})
}

for index, suggestion := range suggestions {
entry := "@" + suggestion
items = append(items, commandMenuItem{
title: entry,
description: "workspace file reference",
filter: entry,
highlight: index == 0 && query != "",
replacement: entry,
useReplaceRange: true,
replaceStart: start,
replaceEnd: end,
})
}
return items
}

func (a App) commandMenuHasSuggestions() bool {
return len(a.commandMenu.Items()) > 0
}

func (a *App) applySelectedCommandSuggestion() bool {
if !a.commandMenuHasSuggestions() {
return false
}
item, ok := a.commandMenu.SelectedItem().(commandMenuItem)
if !ok {
return false
}
if item.openFileBrowser {
a.openFileBrowser()
return true
}

current := a.input.Value()
next := current
if item.useReplaceRange {
if item.replaceStart < 0 || item.replaceEnd < item.replaceStart || item.replaceEnd > len(current) {
return false
}
next = current[:item.replaceStart] + item.replacement + current[item.replaceEnd:]
} else if strings.TrimSpace(item.replacement) != "" {
next = item.replacement
}

if next == current {
return false
}

a.input.SetValue(next)
a.state.InputText = next
a.normalizeComposerHeight()
a.applyComponentLayout(false)
a.refreshCommandMenu()
return true
}

func (a *App) updateCommandMenuSelection(msg tea.KeyMsg) (tea.Cmd, bool) {
if !a.commandMenuHasSuggestions() {
return nil, false
}

switch msg.Type {
case tea.KeyUp, tea.KeyDown, tea.KeyPgUp, tea.KeyPgDown, tea.KeyHome, tea.KeyEnd:
var cmd tea.Cmd
a.commandMenu, cmd = a.commandMenu.Update(msg)
return cmd, true
default:
return nil, false
}
}

func (a *App) openFileBrowser() {
workdir := strings.TrimSpace(a.state.CurrentWorkdir)
if workdir == "" {
return
}

absolute, err := filepath.Abs(workdir)
if err == nil {
a.fileBrowser.CurrentDirectory = absolute
}
a.state.ActivePicker = pickerFile
a.state.StatusText = statusBrowseFile
a.input.Blur()
a.applyComponentLayout(true)
}
Loading
Loading