wip: refactoring tui

This commit is contained in:
adamdottv 2025-06-12 16:00:20 -05:00
parent ca0ea3f94d
commit 653965ef59
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
14 changed files with 502 additions and 1402 deletions

View file

@ -119,6 +119,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.attachments = append(m.attachments, msg.Attachment)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
if m.textarea.Value() != "" {
m.textarea.Reset()
return m, func() tea.Msg {
return nil
}
}
case "shift+enter":
value := m.textarea.Value()
m.textarea.SetValue(value + "\n")
@ -264,8 +271,12 @@ func (m *editorComponent) View() string {
)
textarea = styles.BaseStyle().
Width(m.width).
Border(lipgloss.NormalBorder(), true, true).
BorderForeground(t.Border()).
PaddingTop(1).
PaddingBottom(1).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder(), false, true).
BorderForeground(t.BorderActive()).
BorderBackground(t.Background()).
Render(textarea)
hint := base("enter") + muted(" send ") + base("shift") + muted("+") + base("enter") + muted(" newline")
@ -287,6 +298,7 @@ func (m *editorComponent) View() string {
content := lipgloss.JoinVertical(
lipgloss.Top,
// m.attachmentsContent(),
"",
textarea,
info,
)
@ -409,21 +421,21 @@ func (m *editorComponent) attachmentsContent() string {
func createTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.Background()
bgColor := t.BackgroundElement()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := textarea.New()
ta.Placeholder = "It's prompting time..."
ta.Styles.Blurred.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Blurred.CursorLine = styles.BaseStyle().Background(bgColor)
ta.Styles.Blurred.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.Styles.Blurred.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Focused.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Focused.CursorLine = styles.BaseStyle().Background(bgColor)
ta.Styles.Focused.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.Styles.Focused.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Blurred.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Blurred.CursorLine = lipgloss.NewStyle().Background(bgColor)
ta.Styles.Blurred.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
ta.Styles.Blurred.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Focused.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Focused.CursorLine = lipgloss.NewStyle().Background(bgColor)
ta.Styles.Focused.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
ta.Styles.Focused.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Cursor.Color = t.Primary()
ta.Prompt = " "
ta.ShowLineNumbers = false

View file

@ -292,8 +292,8 @@ func renderToolInvocation(
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
body = ""
filename := toolArgsMap["filePath"].(string)
if metadata["preview"] != nil {
if metadata["preview"] != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = metadata["preview"].(string)
body = renderFile(filename, body, WithTruncate(6))
}

View file

@ -237,7 +237,7 @@ func (m *messagesComponent) renderView() {
}
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()))
m.viewport.SetContent(strings.Join(centered, "\n"))
m.viewport.SetContent("\n" + strings.Join(centered, "\n") + "\n")
}
func (m *messagesComponent) header() string {

View file

@ -6,196 +6,115 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type helpComponent struct {
width int
height int
keys []key.Binding
type helpDialog struct {
width int
height int
modal *modal.Modal
bindings []key.Binding
}
func (h *helpComponent) Init() tea.Cmd {
// func (i bindingItem) Render(selected bool, width int) string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle().
// Width(width - 2).
// Background(t.BackgroundElement())
//
// if selected {
// baseStyle = baseStyle.
// Background(t.Primary()).
// Foreground(t.BackgroundElement()).
// Bold(true)
// } else {
// baseStyle = baseStyle.
// Foreground(t.Text())
// }
//
// return baseStyle.Padding(0, 1).Render(i.binding.Help().Desc)
// }
func (h *helpDialog) Init() tea.Cmd {
return nil
}
func (h *helpComponent) SetBindings(k []key.Binding) {
h.keys = k
}
func (h *helpComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
h.width = 90
h.width = msg.Width
h.height = msg.Height
}
return h, nil
}
func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
seen := make(map[string]struct{})
result := make([]key.Binding, 0, len(bindings))
// func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
// seen := make(map[string]struct{})
// result := make([]key.Binding, 0, len(bindings))
//
// // Process bindings in reverse order
// for i := len(bindings) - 1; i >= 0; i-- {
// b := bindings[i]
// k := strings.Join(b.Keys(), " ")
// if _, ok := seen[k]; ok {
// // duplicate, skip
// continue
// }
// seen[k] = struct{}{}
// // Add to the beginning of result to maintain original order
// result = append([]key.Binding{b}, result...)
// }
//
// return result
// }
// Process bindings in reverse order
for i := len(bindings) - 1; i >= 0; i-- {
b := bindings[i]
k := strings.Join(b.Keys(), " ")
if _, ok := seen[k]; ok {
// duplicate, skip
continue
}
seen[k] = struct{}{}
// Add to the beginning of result to maintain original order
result = append([]key.Binding{b}, result...)
}
return result
}
func (h *helpComponent) render() string {
func (h *helpDialog) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
helpKeyStyle := styles.Bold().
Background(t.Background()).
keyStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.Text()).
Padding(0, 1, 0, 0)
helpDescStyle := styles.Regular().
Background(t.Background()).
Bold(true)
descStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.TextMuted())
// Compile list of bindings to render
bindings := removeDuplicateBindings(h.keys)
// Enumerate through each group of bindings, populating a series of
// pairs of columns, one for keys, one for descriptions
var (
pairs []string
width int
rows = 12 - 2
)
for i := 0; i < len(bindings); i += rows {
var (
keys []string
descs []string
)
for j := i; j < min(i+rows, len(bindings)); j++ {
keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
}
// Render pair of columns; beyond the first pair, render a three space
// left margin, in order to visually separate the pairs.
var cols []string
if len(pairs) > 0 {
cols = []string{baseStyle.Render(" ")}
}
maxDescWidth := 0
for _, desc := range descs {
if maxDescWidth < lipgloss.Width(desc) {
maxDescWidth = lipgloss.Width(desc)
}
}
for i := range descs {
remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
if remainingWidth > 0 {
descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
}
}
maxKeyWidth := 0
for _, key := range keys {
if maxKeyWidth < lipgloss.Width(key) {
maxKeyWidth = lipgloss.Width(key)
}
}
for i := range keys {
remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
if remainingWidth > 0 {
keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
lines := []string{}
for _, b := range h.bindings {
content := keyStyle.Render(b.Help().Key)
content += descStyle.Render(" " + b.Help().Desc)
for i, key := range b.Keys() {
if i == 0 {
keyString := " (" + strings.ToUpper(key) + ")"
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
// spacer := strings.Repeat(" ", space)
// content += descStyle.Render(spacer)
content += descStyle.Render(keyString)
}
}
cols = append(cols,
strings.Join(keys, "\n"),
strings.Join(descs, "\n"),
)
pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
// check whether it exceeds the maximum width avail (the width of the
// terminal, subtracting 2 for the borders).
width += lipgloss.Width(pair)
if width > h.width-2 {
break
}
pairs = append(pairs, pair)
lines = append(lines, content)
}
// https://github.com/charmbracelet/lipgloss/v2/issues/209
if len(pairs) > 1 {
prefix := pairs[:len(pairs)-1]
lastPair := pairs[len(pairs)-1]
prefix = append(prefix, lipgloss.Place(
lipgloss.Width(lastPair), // width
lipgloss.Height(prefix[0]), // height
lipgloss.Left, // x
lipgloss.Top, // y
lastPair, // content
// lipgloss.WithWhitespaceBackground(t.Background()),
))
content := baseStyle.Width(h.width).Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
prefix...,
),
)
return content
return strings.Join(lines, "\n")
}
func (h *helpDialog) Render(background string) string {
return h.modal.Render(h.View(), background)
}
func (h *helpDialog) Close() tea.Cmd {
return nil
}
type HelpDialog interface {
layout.Modal
}
func NewHelpDialog(bindings ...key.Binding) HelpDialog {
return &helpDialog{
bindings: bindings,
modal: modal.New(),
}
// Join pairs of columns and enclose in a border
content := baseStyle.Width(h.width).Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
pairs...,
),
)
return content
}
func (h *helpComponent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
content := h.render()
header := baseStyle.
Bold(true).
Width(lipgloss.Width(content)).
Foreground(t.Primary()).
Render("Keyboard Shortcuts")
return baseStyle.Padding(1).
Border(lipgloss.RoundedBorder()).
BorderForeground(t.TextMuted()).
Width(h.width).
BorderBackground(t.Background()).
Render(
lipgloss.JoinVertical(lipgloss.Center,
header,
baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
content,
),
)
}
type HelpComponent interface {
layout.ModelWithView
SetBindings([]key.Binding)
}
func NewHelpCmp() HelpComponent {
return &helpComponent{}
}

View file

@ -11,7 +11,9 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@ -19,25 +21,16 @@ import (
)
const (
numVisibleModels = 10
numVisibleModels = 6
maxDialogWidth = 40
)
// CloseModelDialogMsg is sent when a model is selected
type CloseModelDialogMsg struct {
Provider *client.ProviderInfo
Model *client.ProviderModel
}
// ModelDialog interface for the model selection dialog
type ModelDialog interface {
layout.ModelWithView
layout.Bindings
SetProviders(providers []client.ProviderInfo)
layout.Modal
}
type modelDialogComponent struct {
type modelDialog struct {
app *app.App
availableProviders []client.ProviderInfo
provider client.ProviderInfo
@ -48,6 +41,8 @@ type modelDialogComponent struct {
scrollOffset int
hScrollOffset int
hScrollPossible bool
modal *modal.Modal
}
type modelKeyMap struct {
@ -57,27 +52,23 @@ type modelKeyMap struct {
Right key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
H key.Binding
L key.Binding
}
var modelKeys = modelKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithKeys("up", "k"),
key.WithHelp("↑", "previous model"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithKeys("down", "j"),
key.WithHelp("↓", "next model"),
),
Left: key.NewBinding(
key.WithKeys("left"),
key.WithKeys("left", "h"),
key.WithHelp("←", "scroll left"),
),
Right: key.NewBinding(
key.WithKeys("right"),
key.WithKeys("right", "l"),
key.WithHelp("→", "scroll right"),
),
Enter: key.NewBinding(
@ -88,25 +79,9 @@ var modelKeys = modelKeyMap{
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next model"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous model"),
),
H: key.NewBinding(
key.WithKeys("h"),
key.WithHelp("h", "scroll left"),
),
L: key.NewBinding(
key.WithKeys("l"),
key.WithHelp("l", "scroll right"),
),
}
func (m *modelDialogComponent) Init() tea.Cmd {
func (m *modelDialog) Init() tea.Cmd {
// cfg := config.Get()
// modelInfo := GetSelectedModel(cfg)
// m.availableProviders = getEnabledProviders(cfg)
@ -116,40 +91,31 @@ func (m *modelDialogComponent) Init() tea.Cmd {
// m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
// m.setupModelsForProvider(m.provider)
m.availableProviders, _ = m.app.ListProviders(context.Background())
m.hScrollOffset = 0
m.hScrollPossible = len(m.availableProviders) > 1
m.provider = m.availableProviders[m.hScrollOffset]
return nil
}
func (m *modelDialogComponent) SetProviders(providers []client.ProviderInfo) {
m.availableProviders = providers
}
func (m *modelDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
case key.Matches(msg, modelKeys.Up):
m.moveSelectionUp()
case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
case key.Matches(msg, modelKeys.Down):
m.moveSelectionDown()
case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
case key.Matches(msg, modelKeys.Left):
if m.hScrollPossible {
m.switchProvider(-1)
}
case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
case key.Matches(msg, modelKeys.Right):
if m.hScrollPossible {
m.switchProvider(1)
}
case key.Matches(msg, modelKeys.Enter):
models := m.models()
return m, util.CmdHandler(CloseModelDialogMsg{Provider: &m.provider, Model: &models[m.selectedIdx]})
cmd := util.CmdHandler(state.ModelSelectedMsg{Provider: m.provider, Model: models[m.selectedIdx]})
return m, tea.Batch(cmd, util.CmdHandler(modal.CloseModalMsg{}))
case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(CloseModelDialogMsg{})
return m, util.CmdHandler(modal.CloseModalMsg{})
}
case tea.WindowSizeMsg:
m.width = msg.Width
@ -159,7 +125,7 @@ func (m *modelDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *modelDialogComponent) models() []client.ProviderModel {
func (m *modelDialog) models() []client.ProviderModel {
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ProviderModel) int {
return strings.Compare(*a.Name, *b.Name)
})
@ -167,7 +133,7 @@ func (m *modelDialogComponent) models() []client.ProviderModel {
}
// moveSelectionUp moves the selection up or wraps to bottom
func (m *modelDialogComponent) moveSelectionUp() {
func (m *modelDialog) moveSelectionUp() {
if m.selectedIdx > 0 {
m.selectedIdx--
} else {
@ -182,7 +148,7 @@ func (m *modelDialogComponent) moveSelectionUp() {
}
// moveSelectionDown moves the selection down or wraps to top
func (m *modelDialogComponent) moveSelectionDown() {
func (m *modelDialog) moveSelectionDown() {
if m.selectedIdx < len(m.provider.Models)-1 {
m.selectedIdx++
} else {
@ -196,7 +162,7 @@ func (m *modelDialogComponent) moveSelectionDown() {
}
}
func (m *modelDialogComponent) switchProvider(offset int) {
func (m *modelDialog) switchProvider(offset int) {
newOffset := m.hScrollOffset + offset
// Ensure we stay within bounds
@ -212,9 +178,11 @@ func (m *modelDialogComponent) switchProvider(offset int) {
m.setupModelsForProvider(m.provider.Id)
}
func (m *modelDialogComponent) View() string {
func (m *modelDialog) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
baseStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.Text())
// Capitalize first letter of provider name
title := baseStyle.
@ -232,8 +200,10 @@ func (m *modelDialogComponent) View() string {
for i := m.scrollOffset; i < endIdx; i++ {
itemStyle := baseStyle.Width(maxDialogWidth)
if i == m.selectedIdx {
itemStyle = itemStyle.Background(t.Primary()).
Foreground(t.Background()).Bold(true)
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Bold(true)
}
modelItems = append(modelItems, itemStyle.Render(*models[i].Name))
}
@ -247,15 +217,10 @@ func (m *modelDialogComponent) View() string {
scrollIndicator,
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
return content
}
func (m *modelDialogComponent) getScrollIndicators(maxWidth int) string {
func (m *modelDialog) getScrollIndicators(maxWidth int) string {
var indicator string
if len(m.provider.Models) > numVisibleModels {
@ -291,10 +256,6 @@ func (m *modelDialogComponent) getScrollIndicators(maxWidth int) string {
Render(indicator)
}
func (m *modelDialogComponent) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(modelKeys)
}
// findProviderIndex returns the index of the provider in the list, or -1 if not found
// func findProviderIndex(providers []string, provider string) int {
// for i, p := range providers {
@ -305,7 +266,7 @@ func (m *modelDialogComponent) BindingKeys() []key.Binding {
// return -1
// }
func (m *modelDialogComponent) setupModelsForProvider(_ string) {
func (m *modelDialog) setupModelsForProvider(_ string) {
m.selectedIdx = 0
m.scrollOffset = 0
@ -331,8 +292,22 @@ func (m *modelDialogComponent) setupModelsForProvider(_ string) {
// }
}
func NewModelDialogCmp(app *app.App) ModelDialog {
return &modelDialogComponent{
app: app,
func (m *modelDialog) Render(background string) string {
return m.modal.Render(m.View(), background)
}
func (s *modelDialog) Close() tea.Cmd {
return nil
}
func NewModelDialog(app *app.App) ModelDialog {
availableProviders, _ := app.ListProviders(context.Background())
return &modelDialog{
availableProviders: availableProviders,
hScrollOffset: 0,
hScrollPossible: len(availableProviders) > 1,
provider: availableProviders[0],
modal: modal.New(),
}
}

View file

@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@ -14,14 +15,17 @@ import (
const question = "Are you sure you want to quit?"
type CloseQuitMsg struct{}
// QuitDialog interface for the quit confirmation dialog
type QuitDialog interface {
layout.ModelWithView
layout.Bindings
layout.Modal
IsQuitDialog() bool
}
type quitDialogComponent struct {
type quitDialog struct {
width int
height int
modal *modal.Modal
selectedNo bool
}
@ -30,12 +34,11 @@ type helpMapping struct {
EnterSpace key.Binding
Yes key.Binding
No key.Binding
Tab key.Binding
}
var helpKeys = helpMapping{
LeftRight: key.NewBinding(
key.WithKeys("left", "right"),
key.WithKeys("left", "right", "h", "l", "tab"),
key.WithHelp("←/→", "switch options"),
),
EnterSpace: key.NewBinding(
@ -43,58 +46,61 @@ var helpKeys = helpMapping{
key.WithHelp("enter/space", "confirm"),
),
Yes: key.NewBinding(
key.WithKeys("y", "Y"),
key.WithKeys("y", "Y", "ctrl+c"),
key.WithHelp("y/Y", "yes"),
),
No: key.NewBinding(
key.WithKeys("n", "N"),
key.WithHelp("n/N", "no"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "switch options"),
),
}
func (q *quitDialogComponent) Init() tea.Cmd {
func (q *quitDialog) Init() tea.Cmd {
return nil
}
func (q *quitDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (q *quitDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
q.width = msg.Width
q.height = msg.Height
case tea.KeyMsg:
switch {
case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
case key.Matches(msg, helpKeys.LeftRight):
q.selectedNo = !q.selectedNo
return q, nil
case key.Matches(msg, helpKeys.EnterSpace):
if !q.selectedNo {
return q, tea.Quit
}
return q, util.CmdHandler(CloseQuitMsg{})
return q, tea.Batch(
util.CmdHandler(modal.CloseModalMsg{}),
)
case key.Matches(msg, helpKeys.Yes):
return q, tea.Quit
case key.Matches(msg, helpKeys.No):
return q, util.CmdHandler(CloseQuitMsg{})
return q, tea.Batch(
util.CmdHandler(modal.CloseModalMsg{}),
)
}
}
return q, nil
}
func (q *quitDialogComponent) View() string {
func (q *quitDialog) Render(background string) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
yesStyle := baseStyle
noStyle := baseStyle
spacerStyle := baseStyle.Background(t.Background())
spacerStyle := baseStyle.Background(t.BackgroundElement())
if q.selectedNo {
noStyle = noStyle.Background(t.Primary()).Foreground(t.Background())
yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary())
noStyle = noStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
yesStyle = yesStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
} else {
yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background())
noStyle = noStyle.Background(t.Background()).Foreground(t.Primary())
yesStyle = yesStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
noStyle = noStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
}
yesButton := yesStyle.Padding(0, 1).Render("Yes")
@ -117,20 +123,21 @@ func (q *quitDialogComponent) View() string {
),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
return q.modal.Render(content, background)
}
func (q *quitDialogComponent) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(helpKeys)
func (q *quitDialog) Close() tea.Cmd {
return nil
}
func NewQuitCmp() QuitDialog {
return &quitDialogComponent{
func (q *quitDialog) IsQuitDialog() bool {
return true
}
// NewQuitDialog creates a new quit confirmation dialog
func NewQuitDialog() QuitDialog {
return &quitDialog{
selectedNo: true,
modal: modal.New(),
}
}

View file

@ -1,29 +1,23 @@
package dialog
import (
"github.com/charmbracelet/bubbles/v2/key"
"context"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/modal"
utilComponents "github.com/sst/opencode/internal/components/util"
components "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
// CloseSessionDialogMsg is sent when the session dialog is closed
type CloseSessionDialogMsg struct {
Session *client.SessionInfo
}
// SessionDialog interface for the session switching dialog
type SessionDialog interface {
tea.Model
layout.Bindings
SetSessions(sessions []client.SessionInfo)
SetSelectedSession(sessionID string)
Render(background string) string
layout.Modal
}
type sessionItem struct {
@ -49,163 +43,70 @@ func (s sessionItem) Render(selected bool, width int) string {
return baseStyle.Padding(0, 1).Render(s.session.Title)
}
// sessionDialogContent is the inner content of the session dialog
type sessionDialogContent struct {
sessions []client.SessionInfo
type sessionDialog struct {
width int
height int
modal *modal.Modal
selectedSessionID string
list utilComponents.SimpleList[sessionItem]
list components.SimpleList[sessionItem]
}
type sessionKeyMap struct {
Enter key.Binding
Escape key.Binding
}
var sessionKeys = sessionKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select session"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
}
func (s *sessionDialogContent) Init() tea.Cmd {
func (s *sessionDialog) Init() tea.Cmd {
return nil
}
func (s *sessionDialogContent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.width = msg.Width
s.height = msg.Height
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
case tea.KeyMsg:
switch {
case key.Matches(msg, sessionKeys.Enter):
switch msg.String() {
case "enter":
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
selectedSession := item.session
s.selectedSessionID = selectedSession.Id
return s, util.CmdHandler(CloseSessionDialogMsg{
Session: &selectedSession,
})
return s, tea.Batch(
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
util.CmdHandler(modal.CloseModalMsg{}),
)
}
case key.Matches(msg, sessionKeys.Escape):
return s, util.CmdHandler(CloseSessionDialogMsg{})
default:
// Pass other key messages to the list component
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(utilComponents.SimpleList[sessionItem])
return s, cmd
}
}
// For non-key messages
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(utilComponents.SimpleList[sessionItem])
s.list = listModel.(components.SimpleList[sessionItem])
return s, cmd
}
func (s *sessionDialogContent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.BackgroundElement())
width := layout.Current.Container.Width - 12
if len(s.sessions) == 0 {
return baseStyle.Padding(1, 2).
Foreground(t.TextMuted()).
Width(width).
Render("No sessions available")
}
// Set the max width for the list
s.list.SetMaxWidth(width)
return s.list.View()
func (s *sessionDialog) Render(background string) string {
return s.modal.Render(s.list.View(), background)
}
func (s *sessionDialogContent) BindingKeys() []key.Binding {
// Combine session dialog keys with list keys
dialogKeys := layout.KeyMapToSlice(sessionKeys)
listKeys := s.list.BindingKeys()
return append(dialogKeys, listKeys...)
func (s *sessionDialog) Close() tea.Cmd {
return nil
}
// sessionDialogComponent wraps the content with a modal
type sessionDialogComponent struct {
content *sessionDialogContent
modal *modal.Modal
}
// NewSessionDialog creates a new session switching dialog
func NewSessionDialog(app *app.App) SessionDialog {
sessions, _ := app.ListSessions(context.Background())
func (s *sessionDialogComponent) Init() tea.Cmd {
return s.modal.Init()
}
func (s *sessionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m, cmd := s.modal.Update(msg)
s.modal = m.(*modal.Modal)
return s, cmd
}
func (s *sessionDialogComponent) View() string {
return s.modal.View()
}
func (s *sessionDialogComponent) Render(background string) string {
return s.modal.Render(background)
}
func (s *sessionDialogComponent) BindingKeys() []key.Binding {
return s.modal.BindingKeys()
}
func (s *sessionDialogComponent) SetSessions(sessions []client.SessionInfo) {
s.content.sessions = sessions
// Convert sessions to sessionItems
var sessionItems []sessionItem
for _, sess := range sessions {
sessionItems = append(sessionItems, sessionItem{session: sess})
}
s.content.list.SetItems(sessionItems)
}
func (s *sessionDialogComponent) SetSelectedSession(sessionID string) {
s.content.selectedSessionID = sessionID
// Update the selected index if sessions are already loaded
if len(s.content.sessions) > 0 {
// Re-set the sessions to update the selection
s.SetSessions(s.content.sessions)
}
}
// NewSessionDialogCmp creates a new session switching dialog
func NewSessionDialogCmp() SessionDialog {
list := utilComponents.NewSimpleList[sessionItem](
[]sessionItem{},
list := components.NewSimpleList(
sessionItems,
10, // maxVisibleSessions
"No sessions available",
true, // useAlphaNumericKeys
)
content := &sessionDialogContent{
sessions: []client.SessionInfo{},
selectedSessionID: "",
list: list,
}
return &sessionDialogComponent{
content: content,
modal: modal.New(content, modal.WithTitle("Switch Session")),
return &sessionDialog{
list: list,
modal: modal.New(modal.WithTitle("Switch Session"), modal.WithMaxWidth(80)),
}
}

View file

@ -1,9 +1,9 @@
package dialog
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/components/modal"
components "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
@ -16,184 +16,113 @@ type ThemeChangedMsg struct {
ThemeName string
}
// CloseThemeDialogMsg is sent when the theme dialog is closed
type CloseThemeDialogMsg struct{}
// ThemeDialog interface for the theme switching dialog
type ThemeDialog interface {
layout.ModelWithView
layout.Bindings
layout.Modal
}
type themeDialogComponent struct {
themes []string
selectedIdx int
width int
height int
currentTheme string
type themeItem struct {
name string
}
type themeKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
func (t themeItem) Render(selected bool, width int) string {
th := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Width(width - 2).
Background(th.BackgroundElement())
var themeKeys = themeKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous theme"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next theme"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select theme"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next theme"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous theme"),
),
}
func (t *themeDialogComponent) Init() tea.Cmd {
// Load available themes and update selectedIdx based on current theme
t.themes = theme.AvailableThemes()
t.currentTheme = theme.CurrentThemeName()
// Find the current theme in the list
for i, name := range t.themes {
if name == t.currentTheme {
t.selectedIdx = i
break
}
if selected {
baseStyle = baseStyle.
Background(th.Primary()).
Foreground(th.BackgroundElement()).
Bold(true)
} else {
baseStyle = baseStyle.
Foreground(th.Text())
}
return baseStyle.Padding(0, 1).Render(t.name)
}
type themeDialog struct {
width int
height int
modal *modal.Modal
list components.SimpleList[themeItem]
}
func (t *themeDialog) Init() tea.Cmd {
return nil
}
func (t *themeDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch {
case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
if t.selectedIdx > 0 {
t.selectedIdx--
}
return t, nil
case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J):
if t.selectedIdx < len(t.themes)-1 {
t.selectedIdx++
}
return t, nil
case key.Matches(msg, themeKeys.Enter):
if len(t.themes) > 0 {
switch msg.String() {
case "enter":
if item, idx := t.list.GetSelectedItem(); idx >= 0 {
previousTheme := theme.CurrentThemeName()
selectedTheme := t.themes[t.selectedIdx]
selectedTheme := item.name
if previousTheme == selectedTheme {
return t, util.CmdHandler(CloseThemeDialogMsg{})
return t, util.CmdHandler(modal.CloseModalMsg{})
}
if err := theme.SetTheme(selectedTheme); err != nil {
status.Error(err.Error())
return t, nil
}
return t, util.CmdHandler(ThemeChangedMsg{
ThemeName: selectedTheme,
})
return t, tea.Batch(
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
util.CmdHandler(modal.CloseModalMsg{}),
)
}
case key.Matches(msg, themeKeys.Escape):
return t, util.CmdHandler(CloseThemeDialogMsg{})
}
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
}
return t, nil
var cmd tea.Cmd
listModel, cmd := t.list.Update(msg)
t.list = listModel.(components.SimpleList[themeItem])
return t, cmd
}
func (t *themeDialogComponent) View() string {
currentTheme := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
func (t *themeDialog) Render(background string) string {
return t.modal.Render(t.list.View(), background)
}
if len(t.themes) == 0 {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(currentTheme.Background()).
BorderForeground(currentTheme.TextMuted()).
Width(40).
Render("No themes available")
}
func (t *themeDialog) Close() tea.Cmd {
return nil
}
// Calculate max width needed for theme names
maxWidth := 40 // Minimum width
for _, themeName := range t.themes {
if len(themeName) > maxWidth-4 { // Account for padding
maxWidth = len(themeName) + 4
// NewThemeDialog creates a new theme switching dialog
func NewThemeDialog() ThemeDialog {
themes := theme.AvailableThemes()
currentTheme := theme.CurrentThemeName()
var themeItems []themeItem
var selectedIdx int
for i, name := range themes {
themeItems = append(themeItems, themeItem{name: name})
if name == currentTheme {
selectedIdx = i
}
}
maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow
// Build the theme list
themeItems := make([]string, 0, len(t.themes))
for i, themeName := range t.themes {
itemStyle := baseStyle.Width(maxWidth)
if i == t.selectedIdx {
itemStyle = itemStyle.
Background(currentTheme.Primary()).
Foreground(currentTheme.Background()).
Bold(true)
}
themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName))
}
title := baseStyle.
Foreground(currentTheme.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Select Theme")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)),
baseStyle.Width(maxWidth).Render(""),
list := components.NewSimpleList(
themeItems,
10, // maxVisibleThemes
"No themes available",
true,
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(currentTheme.Background()).
BorderForeground(currentTheme.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
// Set the initial selection to the current theme
list.SetSelectedIndex(selectedIdx)
func (t *themeDialogComponent) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(themeKeys)
}
// NewThemeDialogCmp creates a new theme switching dialog
func NewThemeDialogCmp() ThemeDialog {
return &themeDialogComponent{
themes: []string{},
selectedIdx: 0,
currentTheme: "",
return &themeDialog{
list: list,
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
}
}

View file

@ -1,25 +1,23 @@
package modal
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
// CloseModalMsg is a message to signal that the active modal should be closed.
type CloseModalMsg struct{}
// Modal is a reusable modal component that handles frame rendering and overlay placement
type Modal struct {
content tea.Model
width int
height int
title string
showBorder bool
borderStyle lipgloss.Border
maxWidth int
maxHeight int
centerContent bool
width int
height int
title string
maxWidth int
maxHeight int
fitContent bool
}
// ModalOption is a function that configures a Modal
@ -32,24 +30,11 @@ func WithTitle(title string) ModalOption {
}
}
// WithBorder enables/disables the border
func WithBorder(show bool) ModalOption {
return func(m *Modal) {
m.showBorder = show
}
}
// WithBorderStyle sets the border style
func WithBorderStyle(style lipgloss.Border) ModalOption {
return func(m *Modal) {
m.borderStyle = style
}
}
// WithMaxWidth sets the maximum width
func WithMaxWidth(width int) ModalOption {
return func(m *Modal) {
m.maxWidth = width
m.fitContent = false
}
}
@ -60,22 +45,18 @@ func WithMaxHeight(height int) ModalOption {
}
}
// WithCenterContent centers the content within the modal
func WithCenterContent(center bool) ModalOption {
func WithFitContent(fit bool) ModalOption {
return func(m *Modal) {
m.centerContent = center
m.fitContent = fit
}
}
// New creates a new Modal with the given content and options
func New(content tea.Model, opts ...ModalOption) *Modal {
// New creates a new Modal with the given options
func New(opts ...ModalOption) *Modal {
m := &Modal{
content: content,
showBorder: true,
borderStyle: lipgloss.ThickBorder(),
maxWidth: 0,
maxHeight: 0,
centerContent: false,
maxWidth: 0,
maxHeight: 0,
fitContent: true,
}
for _, opt := range opts {
@ -85,40 +66,24 @@ func New(content tea.Model, opts ...ModalOption) *Modal {
return m
}
func (m *Modal) Init() tea.Cmd {
return m.content.Init()
}
func (m *Modal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
// Pass all messages to the content
var cmd tea.Cmd
m.content, cmd = m.content.Update(msg)
return m, cmd
}
func (m *Modal) View() string {
// Render renders the modal centered on the screen
func (m *Modal) Render(contentView string, background string) string {
t := theme.CurrentTheme()
// Get the content view
contentView := ""
if v, ok := m.content.(layout.ModelWithView); ok {
contentView = v.View()
}
// Calculate dimensions
outerWidth := layout.Current.Container.Width - 8
if m.maxWidth > 0 && outerWidth > m.maxWidth {
outerWidth = m.maxWidth
}
if m.fitContent {
titleWidth := lipgloss.Width(m.title)
contentWidth := lipgloss.Width(contentView)
largestWidth := max(titleWidth+2, contentWidth)
outerWidth = largestWidth + 6
}
innerWidth := outerWidth - 4
// Base style for the modal
baseStyle := styles.BaseStyle().
Background(t.BackgroundElement()).
@ -132,7 +97,7 @@ func (m *Modal) View() string {
Bold(true).
Width(innerWidth).
Padding(0, 1)
titleView := titleStyle.Render(m.title)
finalContent = lipgloss.JoinVertical(
lipgloss.Left,
@ -143,56 +108,36 @@ func (m *Modal) View() string {
finalContent = contentView
}
// Apply modal styling
modalStyle := baseStyle.
PaddingTop(1).
PaddingBottom(1).
PaddingLeft(2).
PaddingRight(2)
PaddingRight(2).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderLeftForeground(t.BackgroundSubtle()).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundSubtle()).
BorderRightBackground(t.Background())
if m.showBorder {
modalStyle = modalStyle.
BorderStyle(m.borderStyle).
BorderLeft(true).
BorderRight(true).
BorderLeftForeground(t.BackgroundSubtle()).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundSubtle()).
BorderRightBackground(t.Background())
}
return modalStyle.
modalView := modalStyle.
Width(outerWidth).
Render(finalContent)
}
// Render renders the modal centered on the screen
func (m *Modal) Render(background string) string {
modalView := m.View()
// Calculate position for centering
bgHeight := lipgloss.Height(background)
bgWidth := lipgloss.Width(background)
modalHeight := lipgloss.Height(modalView)
modalWidth := lipgloss.Width(modalView)
row := (bgHeight - modalHeight) / 2
col := (bgWidth - modalWidth) / 2
// Use PlaceOverlay to render the modal on top of the background
return layout.PlaceOverlay(
col,
row,
modalView,
background,
true, // shadow
)
}
// BindingKeys returns the key bindings from the content if it implements layout.Bindings
func (m *Modal) BindingKeys() []key.Binding {
if b, ok := m.content.(layout.Bindings); ok {
return b.BindingKeys()
}
return []key.Binding{}
}

View file

@ -20,6 +20,7 @@ type SimpleList[T SimpleListItem] interface {
GetSelectedItem() (item T, idx int)
SetItems(items []T)
GetItems() []T
SetSelectedIndex(idx int)
}
type simpleListComponent[T SimpleListItem] struct {
@ -109,6 +110,12 @@ func (c *simpleListComponent[T]) SetMaxWidth(width int) {
c.maxWidth = width
}
func (c *simpleListComponent[T]) SetSelectedIndex(idx int) {
if idx >= 0 && idx < len(c.items) {
c.selectedIdx = idx
}
}
func (c *simpleListComponent[T]) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()

View file

@ -36,6 +36,12 @@ type LayoutInfo struct {
Container Dimensions
}
type Modal interface {
tea.Model
Render(background string) string
Close() tea.Cmd
}
type Focusable interface {
Focus() tea.Cmd
Blur() tea.Cmd

View file

@ -8,15 +8,9 @@ import (
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
"github.com/muesli/termenv"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// Most of this code is borrowed from
// https://github.com/charmbracelet/lipgloss/v2/pull/102
// as well as the lipgloss library, with some modification for what I needed.
// Split a string into lines, additionally returning the size of the widest line.
func getLines(s string) (lines []string, widest int) {
lines = strings.Split(s, "\n")
@ -33,42 +27,18 @@ func getLines(s string) (lines []string, widest int) {
func PlaceOverlay(
x, y int,
fg, bg string,
shadow bool, opts ...WhitespaceOption,
opts ...WhitespaceOption,
) string {
fgLines, fgWidth := getLines(fg)
bgLines, bgWidth := getLines(bg)
bgHeight := len(bgLines)
fgHeight := len(fgLines)
shadow = false
if shadow {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
var shadowbg string = ""
shadowchar := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.Background()).
Render("░")
bgchar := baseStyle.Render(" ")
for i := 0; i <= fgHeight; i++ {
if i == 0 {
shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"
} else {
shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n"
}
}
fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...)
fgLines, fgWidth = getLines(fg)
fgHeight = len(fgLines)
}
if fgWidth >= bgWidth && fgHeight >= bgHeight {
// FIXME: return fg or bg?
return fg
}
// TODO: allow placement outside of the bg box?
x = util.Clamp(x, 0, bgWidth-fgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
@ -122,13 +92,6 @@ func cutLeft(s string, cutWidth int) string {
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
type whitespace struct {
style termenv.Style
chars string

View file

@ -12,10 +12,8 @@ import (
"github.com/sst/opencode/internal/components/chat"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
var ChatPage PageID = "chat"
@ -30,17 +28,12 @@ type chatPage struct {
}
type ChatKeyMap struct {
NewSession key.Binding
Cancel key.Binding
ToggleTools key.Binding
ShowCompletionDialog key.Binding
}
var keyMap = ChatKeyMap{
NewSession: key.NewBinding(
key.WithKeys("ctrl+n"),
key.WithHelp("ctrl+n", "new session"),
),
Cancel: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
@ -101,17 +94,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.showCompletionDialog = false
p.app.SetCompletionDialogOpen(false)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
_, cmd := p.editor.Update(msg)
if cmd != nil {
return p, cmd
}
}
switch {
case key.Matches(msg, keyMap.ShowCompletionDialog):
p.showCompletionDialog = true
p.app.SetCompletionDialogOpen(true)
// Continue sending keys to layout->chat
case key.Matches(msg, keyMap.NewSession):
p.app.Session = &client.SessionInfo{}
p.app.Messages = []client.MessageInfo{}
return p, tea.Batch(
util.CmdHandler(state.SessionClearedMsg{}),
)
case key.Matches(msg, keyMap.Cancel):
if p.app.Session.Id != "" {
// Cancel the current session's generation process
@ -173,7 +168,6 @@ func (p *chatPage) View() string {
layoutHeight-editorHeight-lipgloss.Height(overlay),
overlay,
layoutView,
false,
)
}
@ -208,7 +202,7 @@ func NewChatPage(app *app.App) layout.ModelWithView {
layout.WithDirection(layout.FlexDirectionVertical),
layout.WithPaneSizes(
layout.FlexPaneSizeGrow,
layout.FlexPaneSizeFixed(6),
layout.FlexPaneSizeFixed(5),
),
),
}

View file

@ -3,7 +3,6 @@ package tui
import (
"context"
"log/slog"
"strings"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/key"
@ -14,6 +13,7 @@ import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/core"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/page"
"github.com/sst/opencode/internal/state"
@ -25,69 +25,41 @@ import (
)
type keyMap struct {
Quit key.Binding
Help key.Binding
NewSession key.Binding
SwitchSession key.Binding
Commands key.Binding
Filepicker key.Binding
Models key.Binding
SwitchModel key.Binding
SwitchTheme key.Binding
Tools key.Binding
Quit key.Binding
}
const (
quitKey = "q"
)
var keys = keyMap{
Quit: key.NewBinding(
key.WithKeys("ctrl+c"),
key.WithHelp("ctrl+c", "quit"),
),
Help: key.NewBinding(
key.WithKeys("ctrl+_"),
key.WithHelp("ctrl+?", "toggle help"),
key.WithKeys("f1", "super+/", "super+h"),
key.WithHelp("/help", "show help"),
),
NewSession: key.NewBinding(
key.WithKeys("f2", "super+n"),
key.WithHelp("/new", "new session"),
),
SwitchSession: key.NewBinding(
key.WithKeys("ctrl+s"),
key.WithHelp("ctrl+s", "switch session"),
key.WithKeys("f3", "super+s"),
key.WithHelp("/sessions", "switch session"),
),
Commands: key.NewBinding(
key.WithKeys("ctrl+k"),
key.WithHelp("ctrl+k", "commands"),
SwitchModel: key.NewBinding(
key.WithKeys("f4", "super+m"),
key.WithHelp("/model", "switch model"),
),
Filepicker: key.NewBinding(
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "select files to upload"),
),
Models: key.NewBinding(
key.WithKeys("ctrl+o"),
key.WithHelp("ctrl+o", "model selection"),
),
SwitchTheme: key.NewBinding(
key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+t", "switch theme"),
key.WithKeys("f5", "super+t"),
key.WithHelp("/theme", "switch theme"),
),
Tools: key.NewBinding(
key.WithKeys("f9"),
key.WithHelp("f9", "show available tools"),
Quit: key.NewBinding(
key.WithKeys("f10", "ctrl+c", "super+q"),
key.WithHelp("/quit", "quit"),
),
}
var helpEsc = key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
)
var returnKey = key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
)
type appModel struct {
width, height int
currentPage page.PageID
@ -96,72 +68,22 @@ type appModel struct {
loadedPages map[page.PageID]bool
status core.StatusComponent
app *app.App
showPermissions bool
permissions dialog.PermissionDialogComponent
showHelp bool
help dialog.HelpComponent
showQuit bool
quit dialog.QuitDialog
showSessionDialog bool
sessionDialog dialog.SessionDialog
showCommandDialog bool
commandDialog dialog.CommandDialog
commands []dialog.Command
showModelDialog bool
modelDialog dialog.ModelDialog
showInitDialog bool
initDialog dialog.InitDialogCmp
showFilepicker bool
filepicker dialog.FilepickerComponent
showThemeDialog bool
themeDialog dialog.ThemeDialog
showMultiArgumentsDialog bool
multiArgumentsDialog dialog.MultiArgumentsDialogCmp
showToolsDialog bool
toolsDialog dialog.ToolsDialog
modal layout.Modal
commands []dialog.Command
}
func (a appModel) Init() tea.Cmd {
t := theme.CurrentTheme()
var cmds []tea.Cmd
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
// cmds = append(cmds, tea.SetForegroundColor(t.Background()))
cmds = append(cmds, tea.RequestBackgroundColor)
cmd := a.pages[a.currentPage].Init()
a.loadedPages[a.currentPage] = true
cmds = append(cmds, cmd)
cmd = a.status.Init()
cmds = append(cmds, cmd)
cmd = a.quit.Init()
cmds = append(cmds, cmd)
cmd = a.help.Init()
cmds = append(cmds, cmd)
cmd = a.sessionDialog.Init()
cmds = append(cmds, cmd)
cmd = a.commandDialog.Init()
cmds = append(cmds, cmd)
cmd = a.modelDialog.Init()
cmds = append(cmds, cmd)
cmd = a.initDialog.Init()
cmds = append(cmds, cmd)
cmd = a.filepicker.Init()
cmds = append(cmds, cmd)
cmd = a.themeDialog.Init()
cmds = append(cmds, cmd)
cmd = a.toolsDialog.Init()
cmds = append(cmds, cmd)
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@ -192,6 +114,41 @@ func (a appModel) updateAllPages(msg tea.Msg) (tea.Model, tea.Cmd) {
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
if a.modal != nil {
isModalTrigger := false
if _, ok := msg.(modal.CloseModalMsg); ok {
a.modal = nil
return a, nil
}
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() {
case "esc":
a.modal = nil
return a, nil
case "ctrl+c":
if _, ok := a.modal.(dialog.QuitDialog); !ok {
quitDialog := dialog.NewQuitDialog()
a.modal = quitDialog
return a, nil
}
}
isModalTrigger = key.Matches(msg, keys.NewSession) ||
key.Matches(msg, keys.SwitchSession) ||
key.Matches(msg, keys.SwitchModel) ||
key.Matches(msg, keys.SwitchTheme) ||
key.Matches(msg, keys.Help) ||
key.Matches(msg, keys.Quit)
}
if !isModalTrigger {
updatedModal, cmd := a.modal.Update(msg)
a.modal = updatedModal.(layout.Modal)
return a, cmd
}
}
switch msg := msg.(type) {
case tea.BackgroundColorMsg:
@ -248,39 +205,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
},
}
s, _ := a.status.Update(msg)
s, cmd := a.status.Update(msg)
a.status = s.(core.StatusComponent)
if cmd != nil {
cmds = append(cmds, cmd)
}
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
cmds = append(cmds, cmd)
if cmd != nil {
cmds = append(cmds, cmd)
}
prm, permCmd := a.permissions.Update(msg)
a.permissions = prm.(dialog.PermissionDialogComponent)
cmds = append(cmds, permCmd)
help, helpCmd := a.help.Update(msg)
a.help = help.(dialog.HelpComponent)
cmds = append(cmds, helpCmd)
session, sessionCmd := a.sessionDialog.Update(msg)
a.sessionDialog = session.(dialog.SessionDialog)
cmds = append(cmds, sessionCmd)
command, commandCmd := a.commandDialog.Update(msg)
a.commandDialog = command.(dialog.CommandDialog)
cmds = append(cmds, commandCmd)
filepicker, filepickerCmd := a.filepicker.Update(msg)
a.filepicker = filepicker.(dialog.FilepickerComponent)
cmds = append(cmds, filepickerCmd)
a.initDialog.SetSize(msg.Width, msg.Height)
if a.showMultiArgumentsDialog {
a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
args, argsCmd := a.multiArgumentsDialog.Update(msg)
a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
if a.modal != nil {
s, cmd := a.modal.Update(msg)
a.modal = s.(layout.Modal)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return a, tea.Batch(cmds...)
@ -300,36 +242,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// case dialog.PermissionDeny:
// a.app.Permissions.Deny(context.Background(), msg.Permission)
// }
a.showPermissions = false
// a.showPermissions = false
return a, nil
case page.PageChangeMsg:
return a, a.moveToPage(msg.ID)
case dialog.CloseQuitMsg:
a.showQuit = false
return a, nil
case dialog.CloseSessionDialogMsg:
a.showSessionDialog = false
if msg.Session != nil {
return a, util.CmdHandler(state.SessionSelectedMsg(msg.Session))
}
return a, nil
case state.SessionSelectedMsg:
a.app.Session = msg
a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id)
return a.updateAllPages(msg)
case dialog.CloseModelDialogMsg:
a.showModelDialog = false
slog.Debug("closing model dialog", "msg", msg)
if msg.Provider != nil && msg.Model != nil {
return a, util.CmdHandler(state.ModelSelectedMsg{Provider: *msg.Provider, Model: *msg.Model})
}
return a, nil
case state.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
@ -338,388 +261,83 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.SaveConfig()
return a.updateAllPages(msg)
case dialog.CloseCommandDialogMsg:
a.showCommandDialog = false
return a, nil
case dialog.CloseThemeDialogMsg:
a.showThemeDialog = false
return a, nil
case dialog.CloseToolsDialogMsg:
a.showToolsDialog = false
return a, nil
case dialog.ShowToolsDialogMsg:
a.showToolsDialog = msg.Show
return a, nil
case dialog.ThemeChangedMsg:
a.app.Config.Theme = msg.ThemeName
a.app.SaveConfig()
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
if cmd != nil {
cmds = append(cmds, cmd)
}
t := theme.CurrentTheme()
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
// cmds = append(cmds, tea.RequestBackgroundColor)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
a.showThemeDialog = false
status.Info("Theme changed to: " + msg.ThemeName)
return a, tea.Batch(cmds...)
case dialog.ShowInitDialogMsg:
a.showInitDialog = msg.Show
return a, nil
case dialog.CloseInitDialogMsg:
a.showInitDialog = false
if msg.Initialize {
return a, a.app.InitializeProject(context.Background())
} else {
// Mark the project as initialized without running the command
if err := a.app.MarkProjectInitialized(context.Background()); err != nil {
status.Error(err.Error())
return a, nil
}
}
return a, nil
case dialog.CommandSelectedMsg:
a.showCommandDialog = false
// Execute the command handler if available
if msg.Command.Handler != nil {
return a, msg.Command.Handler(msg.Command)
}
status.Info("Command selected: " + msg.Command.Title)
return a, nil
case dialog.ShowMultiArgumentsDialogMsg:
// Show multi-arguments dialog
a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
a.showMultiArgumentsDialog = true
return a, a.multiArgumentsDialog.Init()
case dialog.CloseMultiArgumentsDialogMsg:
// Close multi-arguments dialog
a.showMultiArgumentsDialog = false
// If submitted, replace all named arguments and run the command
if msg.Submit {
content := msg.Content
// Replace each named argument with its value
for name, value := range msg.Args {
placeholder := "$" + name
content = strings.ReplaceAll(content, placeholder, value)
}
// Execute the command with arguments
return a, util.CmdHandler(dialog.CommandRunCustomMsg{
Content: content,
Args: msg.Args,
})
}
return a, nil
case tea.KeyMsg:
// If multi-arguments dialog is open, let it handle the key press first
if a.showMultiArgumentsDialog {
args, cmd := a.multiArgumentsDialog.Update(msg)
a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
return a, cmd
switch msg.String() {
case "ctrl+c":
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
if cmd != nil {
return a, cmd
}
}
switch {
case key.Matches(msg, keys.Quit):
a.showQuit = !a.showQuit
if a.showHelp {
a.showHelp = false
}
if a.showSessionDialog {
a.showSessionDialog = false
}
if a.showCommandDialog {
a.showCommandDialog = false
}
if a.showFilepicker {
a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker)
a.app.SetFilepickerOpen(a.showFilepicker)
}
if a.showModelDialog {
a.showModelDialog = false
}
if a.showMultiArgumentsDialog {
a.showMultiArgumentsDialog = false
}
if a.showToolsDialog {
a.showToolsDialog = false
}
return a, nil
case key.Matches(msg, keys.SwitchSession):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
// Close other dialogs
a.showToolsDialog = false
a.showThemeDialog = false
a.showModelDialog = false
a.showFilepicker = false
// Load sessions and show the dialog
sessions, err := a.app.ListSessions(context.Background())
if err != nil {
status.Error(err.Error())
return a, nil
}
if len(sessions) == 0 {
status.Warn("No sessions available")
return a, nil
}
a.sessionDialog.SetSessions(sessions)
a.showSessionDialog = true
return a, nil
}
return a, nil
case key.Matches(msg, keys.Commands):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
// Close other dialogs
a.showToolsDialog = false
a.showModelDialog = false
// Show commands dialog
if len(a.commands) == 0 {
status.Warn("No commands available")
return a, nil
}
a.commandDialog.SetCommands(a.commands)
a.showCommandDialog = true
return a, nil
}
return a, nil
case key.Matches(msg, keys.Models):
if a.showModelDialog {
a.showModelDialog = false
return a, nil
}
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
// Close other dialogs
a.showToolsDialog = false
a.showThemeDialog = false
a.showFilepicker = false
// Load providers and show the dialog
providers, err := a.app.ListProviders(context.Background())
if err != nil {
status.Error(err.Error())
return a, nil
}
if len(providers) == 0 {
status.Warn("No providers available")
return a, nil
}
a.modelDialog.SetProviders(providers)
a.showModelDialog = true
return a, nil
}
return a, nil
case key.Matches(msg, keys.SwitchTheme):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
// Close other dialogs
a.showToolsDialog = false
a.showModelDialog = false
a.showFilepicker = false
a.showThemeDialog = true
return a, a.themeDialog.Init()
}
return a, nil
case key.Matches(msg, keys.Tools):
// Check if any other dialog is open
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions &&
!a.showSessionDialog && !a.showCommandDialog && !a.showThemeDialog &&
!a.showFilepicker && !a.showModelDialog && !a.showInitDialog &&
!a.showMultiArgumentsDialog {
// Toggle tools dialog
a.showToolsDialog = !a.showToolsDialog
if a.showToolsDialog {
// Get tool names dynamically
toolNames := getAvailableToolNames(a.app)
a.toolsDialog.SetTools(toolNames)
}
return a, nil
}
return a, nil
case key.Matches(msg, returnKey) || key.Matches(msg):
if !a.filepicker.IsCWDFocused() {
if a.showToolsDialog {
a.showToolsDialog = false
return a, nil
}
if a.showQuit {
a.showQuit = !a.showQuit
return a, nil
}
if a.showHelp {
a.showHelp = !a.showHelp
return a, nil
}
if a.showInitDialog {
a.showInitDialog = false
// TODO: should we not ask again?
// Mark the project as initialized without running the command
// if err := config.MarkProjectInitialized(); err != nil {
// status.Error(err.Error())
// return a, nil
// }
return a, nil
}
if a.showFilepicker {
a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker)
a.app.SetFilepickerOpen(a.showFilepicker)
return a, nil
}
}
case key.Matches(msg, keys.Help):
if a.showQuit {
return a, nil
}
a.showHelp = !a.showHelp
// Close other dialogs if opening help
if a.showHelp {
a.showToolsDialog = false
}
helpDialog := dialog.NewHelpDialog(
keys.Help,
keys.NewSession,
keys.SwitchSession,
keys.SwitchModel,
keys.SwitchTheme,
keys.Quit,
)
a.modal = helpDialog
return a, nil
case key.Matches(msg, helpEsc):
if a.app.IsBusy() {
if a.showQuit {
return a, nil
}
a.showHelp = !a.showHelp
return a, nil
}
case key.Matches(msg, keys.Filepicker):
// Toggle filepicker
a.showFilepicker = !a.showFilepicker
a.filepicker.ToggleFilepicker(a.showFilepicker)
a.app.SetFilepickerOpen(a.showFilepicker)
// Close other dialogs if opening filepicker
if a.showFilepicker {
a.showToolsDialog = false
a.showThemeDialog = false
a.showModelDialog = false
a.showCommandDialog = false
a.showSessionDialog = false
}
case key.Matches(msg, keys.NewSession):
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
return a, tea.Batch(
util.CmdHandler(state.SessionClearedMsg{}),
)
case key.Matches(msg, keys.SwitchModel):
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
return a, nil
case key.Matches(msg, keys.SwitchSession):
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
return a, nil
case key.Matches(msg, keys.SwitchTheme):
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
return a, nil
case key.Matches(msg, keys.Quit):
quitDialog := dialog.NewQuitDialog()
a.modal = quitDialog
return a, nil
}
default:
f, filepickerCmd := a.filepicker.Update(msg)
a.filepicker = f.(dialog.FilepickerComponent)
cmds = append(cmds, filepickerCmd)
}
if a.showFilepicker {
f, filepickerCmd := a.filepicker.Update(msg)
a.filepicker = f.(dialog.FilepickerComponent)
cmds = append(cmds, filepickerCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
if a.showQuit {
q, quitCmd := a.quit.Update(msg)
a.quit = q.(dialog.QuitDialog)
cmds = append(cmds, quitCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
if a.showPermissions {
d, permissionsCmd := a.permissions.Update(msg)
a.permissions = d.(dialog.PermissionDialogComponent)
cmds = append(cmds, permissionsCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
if a.showSessionDialog {
d, sessionCmd := a.sessionDialog.Update(msg)
a.sessionDialog = d.(dialog.SessionDialog)
cmds = append(cmds, sessionCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
if a.showCommandDialog {
d, commandCmd := a.commandDialog.Update(msg)
a.commandDialog = d.(dialog.CommandDialog)
cmds = append(cmds, commandCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
if a.showModelDialog {
d, modelCmd := a.modelDialog.Update(msg)
a.modelDialog = d.(dialog.ModelDialog)
cmds = append(cmds, modelCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
if a.showInitDialog {
d, initCmd := a.initDialog.Update(msg)
a.initDialog = d.(dialog.InitDialogCmp)
cmds = append(cmds, initCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
if a.showThemeDialog {
d, themeCmd := a.themeDialog.Update(msg)
a.themeDialog = d.(dialog.ThemeDialog)
cmds = append(cmds, themeCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
if a.showToolsDialog {
d, toolsCmd := a.toolsDialog.Update(msg)
a.toolsDialog = d.(dialog.ToolsDialog)
cmds = append(cmds, toolsCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
// f, filepickerCmd := a.filepicker.Update(msg)
// a.filepicker = f.(dialog.FilepickerComponent)
// cmds = append(cmds, filepickerCmd)
}
// update status bar
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
a.status = s.(core.StatusComponent)
// update current page
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
cmds = append(cmds, cmd)
@ -731,12 +349,6 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
a.commands = append(a.commands, cmd)
}
// getAvailableToolNames returns a list of all available tool names
func getAvailableToolNames(_ *app.App) []string {
// TODO: Tools not implemented in API yet
return []string{"Tools not available in API mode"}
}
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
var cmds []tea.Cmd
if _, ok := a.loadedPages[pageID]; !ok {
@ -759,170 +371,10 @@ func (a appModel) View() string {
a.pages[a.currentPage].View(),
}
components = append(components, a.status.View())
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
if a.showPermissions {
overlay := a.permissions.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if a.showFilepicker {
overlay := a.filepicker.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if a.showHelp {
bindings := layout.KeyMapToSlice(keys)
if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
bindings = append(bindings, p.BindingKeys()...)
}
if a.showPermissions {
bindings = append(bindings, a.permissions.BindingKeys()...)
}
if !a.app.IsBusy() {
bindings = append(bindings, helpEsc)
}
a.help.SetBindings(bindings)
overlay := a.help.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if a.showQuit {
overlay := a.quit.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if a.showSessionDialog {
appView = a.sessionDialog.Render(appView)
}
if a.showModelDialog {
overlay := a.modelDialog.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if a.showCommandDialog {
overlay := a.commandDialog.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if a.showInitDialog {
overlay := a.initDialog.View()
appView = layout.PlaceOverlay(
a.width/2-lipgloss.Width(overlay)/2,
a.height/2-lipgloss.Height(overlay)/2,
overlay,
appView,
true,
)
}
if a.showThemeDialog {
overlay := a.themeDialog.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if a.showMultiArgumentsDialog {
overlay := a.multiArgumentsDialog.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if a.showToolsDialog {
overlay := a.toolsDialog.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
if a.modal != nil {
appView = a.modal.Render(appView)
}
return appView
@ -931,24 +383,14 @@ func (a appModel) View() string {
func NewModel(app *app.App) tea.Model {
startPage := page.ChatPage
model := &appModel{
currentPage: startPage,
loadedPages: make(map[page.PageID]bool),
status: core.NewStatusCmp(app),
help: dialog.NewHelpCmp(),
quit: dialog.NewQuitCmp(),
sessionDialog: dialog.NewSessionDialogCmp(),
commandDialog: dialog.NewCommandDialogCmp(),
modelDialog: dialog.NewModelDialogCmp(app),
permissions: dialog.NewPermissionDialogCmp(),
initDialog: dialog.NewInitDialogCmp(),
themeDialog: dialog.NewThemeDialogCmp(),
toolsDialog: dialog.NewToolsDialogCmp(),
app: app,
commands: []dialog.Command{},
currentPage: startPage,
loadedPages: make(map[page.PageID]bool),
status: core.NewStatusCmp(app),
app: app,
commands: []dialog.Command{},
pages: map[page.PageID]layout.ModelWithView{
page.ChatPage: page.NewChatPage(app),
},
filepicker: dialog.NewFilepickerCmp(app),
}
model.RegisterCommand(dialog.Command{