summaryrefslogtreecommitdiffhomepage
path: root/pkg/tui/components/logs
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/tui/components/logs')
-rw-r--r--pkg/tui/components/logs/details.go187
-rw-r--r--pkg/tui/components/logs/table.go208
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()
+}