diff options
Diffstat (limited to 'pkg/tui/components/logs')
| -rw-r--r-- | pkg/tui/components/logs/details.go | 187 | ||||
| -rw-r--r-- | pkg/tui/components/logs/table.go | 208 |
2 files changed, 395 insertions, 0 deletions
diff --git a/pkg/tui/components/logs/details.go b/pkg/tui/components/logs/details.go new file mode 100644 index 000000000..bc59fdc6f --- /dev/null +++ b/pkg/tui/components/logs/details.go @@ -0,0 +1,187 @@ +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 +} diff --git a/pkg/tui/components/logs/table.go b/pkg/tui/components/logs/table.go new file mode 100644 index 000000000..e85009205 --- /dev/null +++ b/pkg/tui/components/logs/table.go @@ -0,0 +1,208 @@ +package logs + +import ( + "context" + "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 + if i.app.CurrentSession.ID == "" { + logs, err = i.app.Logs.ListAll(ctx, logLimit) + } else { + logs, err = i.app.Logs.ListBySession(ctx, i.app.CurrentSession.ID) + } + + 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() +} |
