Detailed changes
@@ -63,13 +63,13 @@ func crushMdExists(dir string) (bool, error) {
if entry.IsDir() {
continue
}
-
+
name := strings.ToLower(entry.Name())
if name == "crush.md" {
return true, nil
}
}
-
+
return false, nil
}
@@ -360,7 +360,6 @@ func (c *editorCmp) Blur() tea.Cmd {
// Focus implements Container.
func (c *editorCmp) Focus() tea.Cmd {
- logging.Info("Focusing editor textarea")
return c.textarea.Focus()
}
@@ -78,39 +78,39 @@ func Status(ops StatusOpts, width int) string {
}
type ButtonOpts struct {
- Text string
- UnderlineIndex int // Index of character to underline (0-based)
- Selected bool // Whether this button is selected
+ Text string
+ UnderlineIndex int // Index of character to underline (0-based)
+ Selected bool // Whether this button is selected
}
// SelectableButton creates a button with an underlined character and selection state
func SelectableButton(opts ButtonOpts) string {
t := styles.CurrentTheme()
-
+
// Base style for the button
buttonStyle := t.S().Text
-
+
// Apply selection styling
if opts.Selected {
buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary)
} else {
buttonStyle = buttonStyle.Background(t.BgSubtle)
}
-
+
// Create the button text with underlined character
text := opts.Text
if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) {
before := text[:opts.UnderlineIndex]
underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1]
after := text[opts.UnderlineIndex+1:]
-
- message := buttonStyle.Render(before) +
- buttonStyle.Underline(true).Render(underlined) +
+
+ message := buttonStyle.Render(before) +
+ buttonStyle.Underline(true).Render(underlined) +
buttonStyle.Render(after)
-
+
return buttonStyle.Padding(0, 2).Render(message)
}
-
+
// Fallback if no underline index specified
return buttonStyle.Padding(0, 2).Render(text)
}
@@ -120,7 +120,7 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
if spacing == "" {
spacing = " "
}
-
+
var parts []string
for i, button := range buttons {
parts = append(parts, SelectableButton(button))
@@ -128,6 +128,6 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
parts = append(parts, spacing)
}
}
-
+
return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
}
@@ -263,4 +263,3 @@ func (c *compactDialogCmp) Position() (int, int) {
func (c *compactDialogCmp) ID() dialogs.DialogID {
return CompactDialogID
}
-
@@ -58,4 +58,4 @@ func (k KeyMap) ShortHelp() []key.Binding {
k.Select,
k.Close,
}
-}
+}
@@ -52,43 +52,55 @@ func (i *detailCmp) updateContent() {
var content strings.Builder
t := styles.CurrentTheme()
- // Format the header with timestamp and level
- timeStyle := t.S().Muted
+ if i.currentLog.ID == "" {
+ content.WriteString(t.S().Muted.Render("No log selected"))
+ i.viewport.SetContent(content.String())
+ return
+ }
+
+ // Level badge with background color
levelStyle := getLevelStyle(i.currentLog.Level)
+ levelBadge := levelStyle.Padding(0, 1).Render(strings.ToUpper(i.currentLog.Level))
+ // Timestamp with relative time
+ timeStr := i.currentLog.Time.Format("2006-01-05 15:04:05 UTC")
+ relativeTime := getRelativeTime(i.currentLog.Time)
+ timeStyle := t.S().Muted
+
+ // Header line
header := lipgloss.JoinHorizontal(
- lipgloss.Center,
- timeStyle.Render(i.currentLog.Time.Format(time.RFC3339)),
- " ",
- levelStyle.Render(i.currentLog.Level),
+ lipgloss.Left,
+ timeStr,
+ " ",
+ timeStyle.Render(relativeTime),
)
- content.WriteString(lipgloss.NewStyle().Bold(true).Render(header))
+ content.WriteString(levelBadge)
+ content.WriteString("\n\n")
+ content.WriteString(header)
content.WriteString("\n\n")
- // Message with styling
- messageStyle := t.S().Text.Bold(true)
- content.WriteString(messageStyle.Render("Message:"))
+ // Message section
+ messageHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true)
+ content.WriteString(messageHeaderStyle.Render("Message"))
content.WriteString("\n")
- content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
+ content.WriteString(i.currentLog.Message)
content.WriteString("\n\n")
// Attributes section
if len(i.currentLog.Attributes) > 0 {
- attrHeaderStyle := t.S().Text.Bold(true)
- content.WriteString(attrHeaderStyle.Render("Attributes:"))
+ attrHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true)
+ content.WriteString(attrHeaderStyle.Render("Attributes"))
content.WriteString("\n")
- // Create a table-like display for attributes
- keyStyle := t.S().Base.Foreground(t.Primary).Bold(true)
- valueStyle := t.S().Text
-
for _, attr := range i.currentLog.Attributes {
+ keyStyle := t.S().Base.Foreground(t.Accent)
+ valueStyle := t.S().Text
attrLine := fmt.Sprintf("%s: %s",
keyStyle.Render(attr.Key),
valueStyle.Render(attr.Value),
)
- content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine))
+ content.WriteString(attrLine)
content.WriteString("\n")
}
}
@@ -102,20 +114,48 @@ func getLevelStyle(level string) lipgloss.Style {
switch strings.ToLower(level) {
case "info":
- return style.Foreground(t.Info)
+ return style.Foreground(t.White).Background(t.Info)
case "warn", "warning":
- return style.Foreground(t.Warning)
+ return style.Foreground(t.White).Background(t.Warning)
case "error", "err":
- return style.Foreground(t.Error)
+ return style.Foreground(t.White).Background(t.Error)
case "debug":
- return style.Foreground(t.Success)
+ return style.Foreground(t.White).Background(t.Success)
+ case "fatal":
+ return style.Foreground(t.White).Background(t.Error)
default:
return style.Foreground(t.FgBase)
}
}
+func getRelativeTime(logTime time.Time) string {
+ now := time.Now()
+ diff := now.Sub(logTime)
+
+ if diff < time.Minute {
+ return fmt.Sprintf("%ds ago", int(diff.Seconds()))
+ } else if diff < time.Hour {
+ return fmt.Sprintf("%dm ago", int(diff.Minutes()))
+ } else if diff < 24*time.Hour {
+ return fmt.Sprintf("%dh ago", int(diff.Hours()))
+ } else if diff < 30*24*time.Hour {
+ return fmt.Sprintf("%dd ago", int(diff.Hours()/24))
+ } else if diff < 365*24*time.Hour {
+ return fmt.Sprintf("%dmo ago", int(diff.Hours()/(24*30)))
+ } else {
+ return fmt.Sprintf("%dy ago", int(diff.Hours()/(24*365)))
+ }
+}
+
func (i *detailCmp) View() tea.View {
- return tea.NewView(i.viewport.View())
+ t := styles.CurrentTheme()
+ style := t.S().Base.
+ BorderStyle(lipgloss.RoundedBorder()).
+ BorderForeground(t.BorderFocus).
+ Width(i.width - 2). // Adjust width for border
+ Height(i.height - 2). // Adjust height for border
+ Padding(1)
+ return tea.NewView(style.Render(i.viewport.View()))
}
func (i *detailCmp) GetSize() (int, int) {
@@ -123,10 +163,11 @@ func (i *detailCmp) GetSize() (int, int) {
}
func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
+ logging.Info("Setting size for detail component", "width", width, "height", height)
i.width = width
i.height = height
- i.viewport.SetWidth(i.width)
- i.viewport.SetHeight(i.height)
+ i.viewport.SetWidth(i.width - 4)
+ i.viewport.SetHeight(i.height - 4)
i.updateContent()
return nil
}
@@ -1,8 +1,9 @@
package logs
import (
- "encoding/json"
+ "fmt"
"slices"
+ "strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/table"
@@ -12,6 +13,7 @@ import (
"github.com/charmbracelet/crush/internal/tui/layout"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
+ "github.com/charmbracelet/lipgloss/v2"
)
type TableComponent interface {
@@ -22,48 +24,80 @@ type TableComponent interface {
type tableCmp struct {
table table.Model
+ logs []logging.LogMessage
}
type selectedLogMsg logging.LogMessage
func (i *tableCmp) Init() tea.Cmd {
+ i.logs = logging.List()
i.setRows()
return nil
}
func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
- switch msg.(type) {
+ switch msg := msg.(type) {
case pubsub.Event[logging.LogMessage]:
- i.setRows()
- return i, nil
+ return i, func() tea.Msg {
+ if msg.Type == pubsub.CreatedEvent {
+ rows := i.table.Rows()
+ for _, row := range rows {
+ if row[1] == msg.Payload.ID {
+ return nil // If the log already exists, do not add it again
+ }
+ }
+ i.logs = append(i.logs, msg.Payload)
+ i.table.SetRows(
+ append(
+ []table.Row{
+ logToRow(msg.Payload),
+ },
+ i.table.Rows()...,
+ ),
+ )
+ }
+ return selectedLogMsg(msg.Payload)
+ }
}
- prevSelectedRow := i.table.SelectedRow()
t, cmd := i.table.Update(msg)
cmds = append(cmds, cmd)
i.table = t
- selectedRow := i.table.SelectedRow()
- if selectedRow != nil {
- if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
- var log logging.LogMessage
- for _, row := range logging.List() {
- if row.ID == selectedRow[0] {
- log = row
- break
- }
- }
- if log.ID != "" {
- cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
+
+ cmds = append(cmds, func() tea.Msg {
+ for _, log := range logging.List() {
+ if log.ID == i.table.SelectedRow()[1] {
+ // If the selected row matches the log ID, return the selected log message
+ return selectedLogMsg(log)
}
}
- }
+ return nil
+ })
return i, tea.Batch(cmds...)
}
func (i *tableCmp) View() tea.View {
t := styles.CurrentTheme()
defaultStyles := table.DefaultStyles()
- defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary)
+
+ // Header styling
+ defaultStyles.Header = defaultStyles.Header.
+ Foreground(t.Primary).
+ Bold(true).
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderBottom(true).
+ BorderForeground(t.Border)
+
+ // Selected row styling
+ defaultStyles.Selected = defaultStyles.Selected.
+ Foreground(t.FgSelected).
+ Background(t.Primary).
+ Bold(false)
+
+ // Cell styling
+ defaultStyles.Cell = defaultStyles.Cell.
+ Foreground(t.FgBase)
+
i.table.SetStyles(defaultStyles)
return tea.NewView(i.table.View())
}
@@ -75,12 +109,30 @@ func (i *tableCmp) GetSize() (int, int) {
func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
i.table.SetWidth(width)
i.table.SetHeight(height)
- cloumns := i.table.Columns()
- for i, col := range cloumns {
- col.Width = (width / len(cloumns)) - 2
- cloumns[i] = col
- }
- i.table.SetColumns(cloumns)
+
+ columnWidth := (width - 10) / 4
+ i.table.SetColumns([]table.Column{
+ {
+ Title: "Level",
+ Width: 10,
+ },
+ {
+ Title: "ID",
+ Width: columnWidth,
+ },
+ {
+ Title: "Time",
+ Width: columnWidth,
+ },
+ {
+ Title: "Message",
+ Width: columnWidth,
+ },
+ {
+ Title: "Attributes",
+ Width: columnWidth,
+ },
+ })
return nil
}
@@ -91,39 +143,54 @@ func (i *tableCmp) BindingKeys() []key.Binding {
func (i *tableCmp) setRows() {
rows := []table.Row{}
- logs := logging.List()
- slices.SortFunc(logs, func(a, b logging.LogMessage) int {
+ slices.SortFunc(i.logs, func(a, b logging.LogMessage) int {
if a.Time.Before(b.Time) {
- return 1
+ return -1
}
if a.Time.After(b.Time) {
- return -1
+ return 1
}
return 0
})
- for _, log := range logs {
- bm, _ := json.Marshal(log.Attributes)
+ for _, log := range i.logs {
+ rows = append(rows, logToRow(log))
+ }
+ i.table.SetRows(rows)
+}
- row := table.Row{
- log.ID,
- log.Time.Format("15:04:05"),
- log.Level,
- log.Message,
- string(bm),
+func logToRow(log logging.LogMessage) table.Row {
+ // Format attributes as JSON string
+ var attrStr string
+ if len(log.Attributes) > 0 {
+ var parts []string
+ for _, attr := range log.Attributes {
+ parts = append(parts, fmt.Sprintf(`{"Key":"%s","Value":"%s"}`, attr.Key, attr.Value))
}
- rows = append(rows, row)
+ attrStr = "[" + strings.Join(parts, ",") + "]"
+ }
+
+ // Format time with relative time
+ timeStr := log.Time.Format("2006-01-05 15:04:05 UTC")
+ relativeTime := getRelativeTime(log.Time)
+ fullTimeStr := timeStr + " " + relativeTime
+
+ return table.Row{
+ strings.ToUpper(log.Level),
+ log.ID,
+ fullTimeStr,
+ log.Message,
+ attrStr,
}
- i.table.SetRows(rows)
}
func NewLogsTable() TableComponent {
columns := []table.Column{
- {Title: "ID", Width: 4},
- {Title: "Time", Width: 4},
- {Title: "Level", Width: 10},
- {Title: "Message", Width: 10},
- {Title: "Attributes", Width: 10},
+ {Title: "Level"},
+ {Title: "ID"},
+ {Title: "Time"},
+ {Title: "Message"},
+ {Title: "Attributes"},
}
tableModel := table.New(
@@ -8,7 +8,6 @@ import (
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/models"
- "github.com/charmbracelet/crush/internal/logging"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -99,7 +98,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
}
case key.Matches(msg, p.keyMap.Tab):
- logging.Info("Tab key pressed, toggling chat focus")
if p.session.ID == "" {
return p, nil
}
@@ -0,0 +1,37 @@
+package logs
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+ Back key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+ return KeyMap{
+ Back: key.NewBinding(
+ key.WithKeys("esc", "backspace"),
+ key.WithHelp("esc/backspace", "back to chat"),
+ ),
+ }
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+ m := [][]key.Binding{}
+ slice := layout.KeyMapToSlice(k)
+ for i := 0; i < len(slice); i += 4 {
+ end := min(i+4, len(slice))
+ m = append(m, slice[i:end])
+ }
+ return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{
+ k.Back,
+ }
+}
@@ -1,26 +1,30 @@
-package page
+package logs
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/components/logs"
+ "github.com/charmbracelet/crush/internal/tui/components/core"
+ logsComponents "github.com/charmbracelet/crush/internal/tui/components/logs"
"github.com/charmbracelet/crush/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/page"
+ "github.com/charmbracelet/crush/internal/tui/page/chat"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
)
-var LogsPage PageID = "logs"
+var LogsPage page.PageID = "logs"
type LogPage interface {
util.Model
layout.Sizeable
- layout.Bindings
}
+
type logsPage struct {
width, height int
- table layout.Container
- details layout.Container
+ table logsComponents.TableComponent
+ details logsComponents.DetailComponent
+ keyMap KeyMap
}
func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -30,34 +34,39 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.width = msg.Width
p.height = msg.Height
return p, p.SetSize(msg.Width, msg.Height)
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, p.keyMap.Back):
+ return p, util.CmdHandler(page.PageChangeMsg{ID: chat.ChatPage})
+ }
}
table, cmd := p.table.Update(msg)
cmds = append(cmds, cmd)
- p.table = table.(layout.Container)
+ p.table = table.(logsComponents.TableComponent)
details, cmd := p.details.Update(msg)
cmds = append(cmds, cmd)
- p.details = details.(layout.Container)
+ p.details = details.(logsComponents.DetailComponent)
return p, tea.Batch(cmds...)
}
func (p *logsPage) View() tea.View {
- style := styles.CurrentTheme().S().Base.Width(p.width).Height(p.height)
+ baseStyle := styles.CurrentTheme().S().Base
+ style := baseStyle.Width(p.width).Height(p.height).Padding(1)
+ title := core.Title("Logs", p.width-2)
+
return tea.NewView(
style.Render(
lipgloss.JoinVertical(lipgloss.Top,
- p.table.View().String(),
+ title,
p.details.View().String(),
+ p.table.View().String(),
),
),
)
}
-func (p *logsPage) BindingKeys() []key.Binding {
- return p.table.BindingKeys()
-}
-
// GetSize implements LogPage.
func (p *logsPage) GetSize() (int, int) {
return p.width, p.height
@@ -67,9 +76,11 @@ func (p *logsPage) GetSize() (int, int) {
func (p *logsPage) SetSize(width int, height int) tea.Cmd {
p.width = width
p.height = height
+ availableHeight := height - 2 // Padding for top and bottom
+ availableHeight -= 1 // title height
return tea.Batch(
- p.table.SetSize(width, height/2),
- p.details.SetSize(width, height/2),
+ p.table.SetSize(width-2, availableHeight/2),
+ p.details.SetSize(width-2, availableHeight/2),
)
}
@@ -82,7 +93,8 @@ func (p *logsPage) Init() tea.Cmd {
func NewLogsPage() LogPage {
return &logsPage{
- table: layout.NewContainer(logs.NewLogsTable(), layout.WithBorderAll()),
- details: layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderAll()),
+ details: logsComponents.NewLogsDetails(),
+ table: logsComponents.NewLogsTable(),
+ keyMap: DefaultKeyMap(),
}
}
@@ -27,6 +27,7 @@ import (
"github.com/charmbracelet/crush/internal/tui/layout"
"github.com/charmbracelet/crush/internal/tui/page"
"github.com/charmbracelet/crush/internal/tui/page/chat"
+ "github.com/charmbracelet/crush/internal/tui/page/logs"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
@@ -137,7 +138,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, statusCmd)
// If the current page is logs, update the logs view
- if a.currentPage == page.LogsPage {
+ if a.currentPage == logs.LogsPage {
updated, pageCmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(util.Model)
cmds = append(cmds, pageCmd)
@@ -328,7 +329,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
return tea.Sequence(cmds...)
// Page navigation
case key.Matches(msg, a.keyMap.Logs):
- return a.moveToPage(page.LogsPage)
+ return a.moveToPage(logs.LogsPage)
default:
if a.dialog.HasDialogs() {
@@ -379,7 +380,6 @@ func (a *appModel) View() tea.View {
lipgloss.NewLayer(appView),
}
if a.dialog.HasDialogs() {
- logging.Info("Rendering dialogs")
layers = append(
layers,
a.dialog.GetLayers()...,
@@ -424,7 +424,7 @@ func New(app *app.App) tea.Model {
pages: map[page.PageID]util.Model{
chat.ChatPage: chat.NewChatPage(app),
- page.LogsPage: page.NewLogsPage(),
+ logs.LogsPage: logs.NewLogsPage(),
},
dialog: dialogs.NewDialogCmp(),
@@ -11,9 +11,9 @@
- [x] Cleanup Commands
- [x] Sessions dialog
-- [ ] Models
-- [~] Move sessions and model dialog to the commands
-- [ ] Add sessions shortuct
+- [x] Models
+- [x] Move sessions and model dialog to the commands
+- [x] Add sessions shortuct
- [ ] Add all posible actions to the commands
## Investigate
@@ -24,3 +24,6 @@
## Messages
- [ ] Fix issue with numbers (padding)
+- [ ] Run tools in parallel and add the responses in parallel
+- [ ] Handle parallel permission calls
+- [ ] Weird behavior sometimes the message does not update