wip: refactoring tui

This commit is contained in:
adamdottv
2025-05-28 15:36:31 -05:00
parent 8863a499a9
commit 9d7c5efb9b
21 changed files with 136 additions and 1346 deletions

View File

@@ -13,9 +13,9 @@ import (
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/components/dialog"
"github.com/sst/opencode/internal/tui/image"
"github.com/sst/opencode/internal/tui/layout"
@@ -37,10 +37,10 @@ type editorCmp struct {
}
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
}
@@ -251,14 +251,14 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
@@ -267,14 +267,14 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number and total lines
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
@@ -403,10 +403,10 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil)
return &editorCmp{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
}
}

View File

@@ -12,11 +12,11 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/session"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/components/dialog"
"github.com/sst/opencode/internal/tui/state"
"github.com/sst/opencode/internal/tui/styles"

View File

@@ -8,8 +8,8 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/tui/app"
// "github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/pubsub"
@@ -216,17 +216,17 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
// TODO: History service not implemented in API yet
return
/*
// Get all latest files for this session
latestFiles, err := m.app.History.ListLatestSessionFiles(ctx, m.app.CurrentSession.ID)
if err != nil {
return
}
// Get all latest files for this session
latestFiles, err := m.app.History.ListLatestSessionFiles(ctx, m.app.CurrentSession.ID)
if err != nil {
return
}
// Get all files for this session (to find initial versions)
allFiles, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID)
if err != nil {
return
}
// Get all files for this session (to find initial versions)
allFiles, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID)
if err != nil {
return
}
*/
// Clear the existing map to rebuild it
@@ -236,50 +236,50 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
})
/*
// Process each latest file
for _, file := range latestFiles {
// Skip if this is the initial version (no changes to show)
if file.Version == history.InitialVersion {
continue
}
// Process each latest file
for _, file := range latestFiles {
// Skip if this is the initial version (no changes to show)
if file.Version == history.InitialVersion {
continue
}
// Find the initial version for this specific file
var initialVersion history.File
for _, v := range allFiles {
if v.Path == file.Path && v.Version == history.InitialVersion {
initialVersion = v
break
// Find the initial version for this specific file
var initialVersion history.File
for _, v := range allFiles {
if v.Path == file.Path && v.Version == history.InitialVersion {
initialVersion = v
break
}
}
// Skip if we can't find the initial version
if initialVersion.ID == "" {
continue
}
if initialVersion.Content == file.Content {
continue
}
// Calculate diff between initial and latest version
_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
// Only add to modified files if there are changes
if additions > 0 || removals > 0 {
// Remove working directory prefix from file path
displayPath := file.Path
workingDir := config.WorkingDirectory()
displayPath = strings.TrimPrefix(displayPath, workingDir)
displayPath = strings.TrimPrefix(displayPath, "/")
m.modFiles[displayPath] = struct {
additions int
removals int
}{
additions: additions,
removals: removals,
}
}
}
// Skip if we can't find the initial version
if initialVersion.ID == "" {
continue
}
if initialVersion.Content == file.Content {
continue
}
// Calculate diff between initial and latest version
_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
// Only add to modified files if there are changes
if additions > 0 || removals > 0 {
// Remove working directory prefix from file path
displayPath := file.Path
workingDir := config.WorkingDirectory()
displayPath = strings.TrimPrefix(displayPath, workingDir)
displayPath = strings.TrimPrefix(displayPath, "/")
m.modFiles[displayPath] = struct {
additions int
removals int
}{
additions: additions,
removals: removals,
}
}
}
*/
}
@@ -287,44 +287,44 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File)
// TODO: History service not implemented in API yet
return
/*
// Skip if this is the initial version (no changes to show)
if file.Version == history.InitialVersion {
return
}
// Find the initial version for this file
initialVersion, err := m.findInitialVersion(ctx, file.Path)
if err != nil || initialVersion.ID == "" {
return
}
// Skip if content hasn't changed
if initialVersion.Content == file.Content {
// If this file was previously modified but now matches the initial version,
// remove it from the modified files list
displayPath := getDisplayPath(file.Path)
delete(m.modFiles, displayPath)
return
}
// Calculate diff between initial and latest version
_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
// Only add to modified files if there are changes
if additions > 0 || removals > 0 {
displayPath := getDisplayPath(file.Path)
m.modFiles[displayPath] = struct {
additions int
removals int
}{
additions: additions,
removals: removals,
// Skip if this is the initial version (no changes to show)
if file.Version == history.InitialVersion {
return
}
// Find the initial version for this file
initialVersion, err := m.findInitialVersion(ctx, file.Path)
if err != nil || initialVersion.ID == "" {
return
}
// Skip if content hasn't changed
if initialVersion.Content == file.Content {
// If this file was previously modified but now matches the initial version,
// remove it from the modified files list
displayPath := getDisplayPath(file.Path)
delete(m.modFiles, displayPath)
return
}
// Calculate diff between initial and latest version
_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
// Only add to modified files if there are changes
if additions > 0 || removals > 0 {
displayPath := getDisplayPath(file.Path)
m.modFiles[displayPath] = struct {
additions int
removals int
}{
additions: additions,
removals: removals,
}
} else {
// If no changes, remove from modified files
displayPath := getDisplayPath(file.Path)
delete(m.modFiles, displayPath)
}
} else {
// If no changes, remove from modified files
displayPath := getDisplayPath(file.Path)
delete(m.modFiles, displayPath)
}
*/
}
@@ -333,22 +333,22 @@ func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (histo
// TODO: History service not implemented in API yet
return history.File{}, fmt.Errorf("history service not implemented")
/*
// Get all versions of this file for the session
fileVersions, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID)
if err != nil {
return history.File{}, err
}
// Get all versions of this file for the session
fileVersions, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID)
if err != nil {
return history.File{}, err
}
*/
/*
// Find the initial version
for _, v := range fileVersions {
if v.Path == path && v.Version == history.InitialVersion {
return v, nil
// Find the initial version
for _, v := range fileVersions {
if v.Path == path && v.Version == history.InitialVersion {
return v, nil
}
}
}
return history.File{}, fmt.Errorf("initial version not found")
return history.File{}, fmt.Errorf("initial version not found")
*/
}

View File

@@ -7,13 +7,13 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
)

View File

@@ -17,10 +17,10 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/image"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
@@ -42,7 +42,7 @@ type FilePrickerKeyMap struct {
OpenFilePicker key.Binding
Esc key.Binding
InsertCWD key.Binding
Paste key.Binding
Paste key.Binding
}
var filePickerKeyMap = FilePrickerKeyMap{

View File

@@ -1,187 +0,0 @@
package logs
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
)
type DetailComponent interface {
tea.Model
layout.Sizeable
layout.Bindings
}
type detailCmp struct {
width, height int
currentLog logging.Log
viewport viewport.Model
focused bool
}
func (i *detailCmp) Init() tea.Cmd {
return nil
}
func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case selectedLogMsg:
if msg.ID != i.currentLog.ID {
i.currentLog = logging.Log(msg)
// Defer content update to avoid blocking the UI
cmd = tea.Tick(time.Millisecond*1, func(time.Time) tea.Msg {
i.updateContent()
return nil
})
}
case tea.KeyMsg:
// Only process keyboard input when focused
if !i.focused {
return i, nil
}
// Handle keyboard input for scrolling
i.viewport, cmd = i.viewport.Update(msg)
return i, cmd
}
return i, cmd
}
func (i *detailCmp) updateContent() {
var content strings.Builder
t := theme.CurrentTheme()
// Format the header with timestamp and level
timeStyle := lipgloss.NewStyle().Foreground(t.TextMuted())
levelStyle := getLevelStyle(i.currentLog.Level)
// Format timestamp
timeStr := i.currentLog.Timestamp.Format(time.RFC3339)
header := lipgloss.JoinHorizontal(
lipgloss.Center,
timeStyle.Render(timeStr),
" ",
levelStyle.Render(i.currentLog.Level),
)
content.WriteString(lipgloss.NewStyle().Bold(true).Render(header))
content.WriteString("\n\n")
// Message with styling
messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
content.WriteString(messageStyle.Render("Message:"))
content.WriteString("\n")
content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(i.currentLog.Message))
content.WriteString("\n\n")
// Attributes section
if len(i.currentLog.Attributes) > 0 {
attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
content.WriteString(attrHeaderStyle.Render("Attributes:"))
content.WriteString("\n")
// Create a table-like display for attributes
keyStyle := lipgloss.NewStyle().Foreground(t.Primary()).Bold(true)
valueStyle := lipgloss.NewStyle().Foreground(t.Text())
for key, value := range i.currentLog.Attributes {
// if value is JSON, render it with indentation
if strings.HasPrefix(value, "{") {
var indented bytes.Buffer
if err := json.Indent(&indented, []byte(value), "", " "); err != nil {
indented.WriteString(value)
}
value = indented.String()
}
attrLine := fmt.Sprintf("%s: %s",
keyStyle.Render(key),
valueStyle.Render(value),
)
content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(attrLine))
content.WriteString("\n")
}
}
// Session ID if available
if i.currentLog.SessionID != "" {
sessionStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
content.WriteString("\n")
content.WriteString(sessionStyle.Render("Session:"))
content.WriteString("\n")
content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(i.currentLog.SessionID))
}
i.viewport.SetContent(content.String())
}
func getLevelStyle(level string) lipgloss.Style {
style := lipgloss.NewStyle().Bold(true)
t := theme.CurrentTheme()
switch strings.ToLower(level) {
case "info":
return style.Foreground(t.Info())
case "warn", "warning":
return style.Foreground(t.Warning())
case "error", "err":
return style.Foreground(t.Error())
case "debug":
return style.Foreground(t.Success())
default:
return style.Foreground(t.Text())
}
}
func (i *detailCmp) View() string {
t := theme.CurrentTheme()
return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), t.Background())
}
func (i *detailCmp) GetSize() (int, int) {
return i.width, i.height
}
func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
i.width = width
i.height = height
i.viewport.Width = i.width
i.viewport.Height = i.height
i.updateContent()
return nil
}
func (i *detailCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(i.viewport.KeyMap)
}
func NewLogsDetails() DetailComponent {
return &detailCmp{
viewport: viewport.New(0, 0),
}
}
// Focus implements the focusable interface
func (i *detailCmp) Focus() {
i.focused = true
i.viewport.SetYOffset(i.viewport.YOffset)
}
// Blur implements the blurable interface
func (i *detailCmp) Blur() {
i.focused = false
}

View File

@@ -1,207 +0,0 @@
package logs
import (
// "context"
"fmt"
"log/slog"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/state"
"github.com/sst/opencode/internal/tui/theme"
)
type TableComponent interface {
tea.Model
layout.Sizeable
layout.Bindings
}
type tableCmp struct {
app *app.App
table table.Model
focused bool
logs []logging.Log
selectedLogID string
}
type selectedLogMsg logging.Log
type LogsLoadedMsg struct {
logs []logging.Log
}
func (i *tableCmp) Init() tea.Cmd {
return i.fetchLogs()
}
func (i *tableCmp) fetchLogs() tea.Cmd {
return func() tea.Msg {
// ctx := context.Background()
var logs []logging.Log
var err error
// Limit the number of logs to improve performance
const logLimit = 100
// TODO: Logs service not implemented in API yet
logs = []logging.Log{}
err = fmt.Errorf("logs service not implemented")
if err != nil {
slog.Error("Failed to fetch logs", "error", err)
return nil
}
return LogsLoadedMsg{logs: logs}
}
}
func (i *tableCmp) updateRows() tea.Cmd {
return func() tea.Msg {
rows := make([]table.Row, 0, len(i.logs))
for _, log := range i.logs {
timeStr := log.Timestamp.Local().Format("15:04:05")
// Include ID as hidden first column for selection
row := table.Row{
log.ID,
timeStr,
log.Level,
log.Message,
}
rows = append(rows, row)
}
i.table.SetRows(rows)
return nil
}
}
func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case LogsLoadedMsg:
i.logs = msg.logs
return i, i.updateRows()
case state.SessionSelectedMsg:
return i, i.fetchLogs()
case pubsub.Event[logging.Log]:
if msg.Type == logging.EventLogCreated {
// Add the new log to our list
i.logs = append([]logging.Log{msg.Payload}, i.logs...)
// Keep the list at a reasonable size
if len(i.logs) > 100 {
i.logs = i.logs[:100]
}
return i, i.updateRows()
}
return i, nil
}
// Only process keyboard input when focused
if _, ok := msg.(tea.KeyMsg); ok && !i.focused {
return i, nil
}
t, cmd := i.table.Update(msg)
cmds = append(cmds, cmd)
i.table = t
selectedRow := i.table.SelectedRow()
if selectedRow != nil {
// Only send message if it's a new selection
if i.selectedLogID != selectedRow[0] {
cmds = append(cmds, func() tea.Msg {
for _, log := range i.logs {
if log.ID == selectedRow[0] {
return selectedLogMsg(log)
}
}
return nil
})
}
i.selectedLogID = selectedRow[0]
}
return i, tea.Batch(cmds...)
}
func (i *tableCmp) View() string {
t := theme.CurrentTheme()
defaultStyles := table.DefaultStyles()
defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
i.table.SetStyles(defaultStyles)
return i.table.View()
}
func (i *tableCmp) GetSize() (int, int) {
return i.table.Width(), i.table.Height()
}
func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
i.table.SetWidth(width)
i.table.SetHeight(height)
columns := i.table.Columns()
// Calculate widths for visible columns
timeWidth := 8 // Fixed width for Time column
levelWidth := 7 // Fixed width for Level column
// Message column gets the remaining space
messageWidth := width - timeWidth - levelWidth - 5 // 5 for padding and borders
// Set column widths
columns[0].Width = 0 // ID column (hidden)
columns[1].Width = timeWidth
columns[2].Width = levelWidth
columns[3].Width = messageWidth
i.table.SetColumns(columns)
return nil
}
func (i *tableCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(i.table.KeyMap)
}
func NewLogsTable(app *app.App) TableComponent {
columns := []table.Column{
{Title: "ID", Width: 0}, // ID column with zero width
{Title: "Time", Width: 8},
{Title: "Level", Width: 7},
{Title: "Message", Width: 30},
}
tableModel := table.New(
table.WithColumns(columns),
)
tableModel.Focus()
return &tableCmp{
app: app,
table: tableModel,
logs: []logging.Log{},
}
}
// Focus implements the focusable interface
func (i *tableCmp) Focus() {
i.focused = true
i.table.Focus()
}
// Blur implements the blurable interface
func (i *tableCmp) Blur() {
i.focused = false
i.table.Blur()
}