aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-30 07:32:54 +0000
committerFuwn <[email protected]>2026-01-30 07:32:54 +0000
commit5f3eba126201e4d679539aa2517bf6a132f29cd0 (patch)
tree961afe2ae1d6ca0f23bdbb30930e37bc88884146 /internal
downloadfaustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.tar.xz
faustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.zip
feat: Initial commit
Diffstat (limited to 'internal')
-rw-r--r--internal/app/helpers.go135
-rw-r--r--internal/app/model.go104
-rw-r--r--internal/app/preview.go110
-rw-r--r--internal/app/search.go96
-rw-r--r--internal/app/state.go114
-rw-r--r--internal/app/update.go445
-rw-r--r--internal/app/view.go487
-rw-r--r--internal/claude/preview.go177
-rw-r--r--internal/claude/search.go180
-rw-r--r--internal/claude/session.go360
-rw-r--r--internal/ui/keys.go126
-rw-r--r--internal/ui/styles.go159
12 files changed, 2493 insertions, 0 deletions
diff --git a/internal/app/helpers.go b/internal/app/helpers.go
new file mode 100644
index 0000000..4d2cf65
--- /dev/null
+++ b/internal/app/helpers.go
@@ -0,0 +1,135 @@
+package app
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+func truncate(text string, maxLength int) string {
+ if maxLength <= 0 {
+ return ""
+ }
+
+ text = strings.ReplaceAll(text, "\n", " ")
+
+ if len(text) <= maxLength {
+ return text
+ }
+
+ if maxLength <= 2 {
+ return text[:maxLength]
+ }
+
+ return text[:maxLength-2] + " …"
+}
+
+func formatTime(timestamp time.Time) string {
+ now := time.Now()
+ difference := now.Sub(timestamp)
+
+ switch {
+ case difference < time.Minute:
+ return "just now"
+ case difference < time.Hour:
+ return fmt.Sprintf("%dm ago", int(difference.Minutes()))
+ case difference < 24*time.Hour:
+ return fmt.Sprintf("%dh ago", int(difference.Hours()))
+ case difference < 7*24*time.Hour:
+ return fmt.Sprintf("%dd ago", int(difference.Hours()/24))
+ default:
+ return timestamp.Format("Jan 2")
+ }
+}
+
+func max(first, second int) int {
+ if first > second {
+ return first
+ }
+
+ return second
+}
+
+func min(first, second int) int {
+ if first < second {
+ return first
+ }
+
+ return second
+}
+
+func highlightMatches(text, query string) string {
+ if query == "" {
+ return text
+ }
+
+ queryLower := strings.ToLower(query)
+ textLower := strings.ToLower(text)
+
+ var result strings.Builder
+
+ lastEnd := 0
+
+ for {
+ index := strings.Index(textLower[lastEnd:], queryLower)
+
+ if index == -1 {
+ result.WriteString(text[lastEnd:])
+
+ break
+ }
+
+ matchStart := lastEnd + index
+
+ result.WriteString(text[lastEnd:matchStart])
+
+ matchEnd := matchStart + len(query)
+
+ result.WriteString("\033[43;30m")
+ result.WriteString(text[matchStart:matchEnd])
+ result.WriteString("\033[0m")
+
+ lastEnd = matchEnd
+ }
+
+ return result.String()
+}
+
+func wrapText(text string, width int) string {
+ if width <= 0 {
+ return text
+ }
+
+ var result strings.Builder
+
+ words := strings.Fields(text)
+ lineLength := 0
+
+ for wordIndex, word := range words {
+ wordLength := len(word)
+
+ if lineLength+wordLength+1 > width && lineLength > 0 {
+ result.WriteString("\n")
+
+ lineLength = 0
+ }
+
+ if lineLength > 0 {
+ result.WriteString(" ")
+
+ lineLength += 1
+ }
+
+ result.WriteString(word)
+
+ lineLength += wordLength
+
+ if lineLength > width && wordIndex < len(words)-1 {
+ result.WriteString("\n")
+
+ lineLength = 0
+ }
+ }
+
+ return result.String()
+}
diff --git a/internal/app/model.go b/internal/app/model.go
new file mode 100644
index 0000000..90cfecf
--- /dev/null
+++ b/internal/app/model.go
@@ -0,0 +1,104 @@
+package app
+
+import (
+ "time"
+
+ "github.com/Fuwn/faustus/internal/claude"
+ "github.com/Fuwn/faustus/internal/ui"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type Tab int
+
+const (
+ TabSessions Tab = iota
+ TabTrash
+)
+
+type Mode int
+
+const (
+ ModeNormal Mode = iota
+ ModeSearch
+ ModeDeepSearch
+ ModeRename
+ ModeConfirm
+)
+
+type ConfirmAction int
+
+const (
+ ConfirmNone ConfirmAction = iota
+ ConfirmDelete
+ ConfirmRestore
+ ConfirmEmptyTrash
+ ConfirmPermanentDelete
+)
+
+type Model struct {
+ sessions []claude.Session
+ filtered []claude.Session
+ cursor int
+ offset int
+ width int
+ height int
+ tab Tab
+ mode Mode
+ confirmAction ConfirmAction
+ searchInput textinput.Model
+ renameInput textinput.Model
+ keys ui.KeyMap
+ showHelp bool
+ message string
+ messageTime time.Time
+ showPreview bool
+ previewFocus bool
+ previewScroll int
+ previewCache *claude.PreviewContent
+ previewFor string
+ deepSearchInput textinput.Model
+ deepSearchResults []claude.SearchResult
+ deepSearchIndex int
+ deepSearchQuery string
+ previewSearchQuery string
+ previewSearchMatches []int
+ previewSearchIndex int
+}
+
+func NewModel(sessions []claude.Session) Model {
+ searchInput := textinput.New()
+
+ searchInput.Placeholder = "Filter sessions"
+ searchInput.CharLimit = 100
+ searchInput.Width = 40
+
+ renameInput := textinput.New()
+
+ renameInput.Placeholder = "Enter new name"
+ renameInput.CharLimit = 200
+ renameInput.Width = 60
+
+ deepSearchInput := textinput.New()
+
+ deepSearchInput.Placeholder = "Search all sessions"
+ deepSearchInput.CharLimit = 100
+ deepSearchInput.Width = 50
+
+ model := Model{
+ sessions: sessions,
+ keys: ui.DefaultKeyMap(),
+ searchInput: searchInput,
+ renameInput: renameInput,
+ deepSearchInput: deepSearchInput,
+ showPreview: false,
+ }
+
+ model.updateFiltered()
+
+ return model
+}
+
+func (m Model) Init() tea.Cmd {
+ return textinput.Blink
+}
diff --git a/internal/app/preview.go b/internal/app/preview.go
new file mode 100644
index 0000000..8578808
--- /dev/null
+++ b/internal/app/preview.go
@@ -0,0 +1,110 @@
+package app
+
+import (
+ "strings"
+
+ "github.com/Fuwn/faustus/internal/claude"
+)
+
+func (m *Model) invalidatePreviewCache() {
+ m.previewCache = nil
+ m.previewFor = ""
+ m.previewScroll = 0
+}
+
+func (m *Model) preview() *claude.PreviewContent {
+ if len(m.filtered) == 0 {
+ return nil
+ }
+
+ session := &m.filtered[m.cursor]
+
+ if m.previewCache != nil && m.previewFor == session.SessionID {
+ return m.previewCache
+ }
+
+ previewContent := claude.LoadSessionPreview(session, 50)
+ m.previewCache = &previewContent
+ m.previewFor = session.SessionID
+
+ return m.previewCache
+}
+
+type previewMetrics struct {
+ totalLines int
+ messageLines []int
+}
+
+func (m *Model) calculatePreviewMetrics() previewMetrics {
+ preview := m.preview()
+
+ if preview == nil || preview.Error != "" {
+ return previewMetrics{}
+ }
+
+ width := m.previewWidth() - 8
+
+ if width <= 0 {
+ width = 40
+ }
+
+ var metrics previewMetrics
+
+ lineCount := 0
+
+ if m.cursor < len(m.filtered) {
+ lineCount += 1
+ lineCount += 1
+ lineCount += 1
+ lineCount += 1
+ }
+
+ for _, previewMessage := range preview.Messages {
+ metrics.messageLines = append(metrics.messageLines, lineCount)
+
+ lineCount += 1
+
+ wrapped := wrapText(previewMessage.Content, width)
+ contentLines := strings.Count(wrapped, "\n") + 1
+
+ lineCount += contentLines
+ lineCount += 1
+ }
+
+ metrics.totalLines = lineCount
+
+ return metrics
+}
+
+func (m *Model) clampPreviewScroll() {
+ height := m.listHeight() - 2
+ metrics := m.calculatePreviewMetrics()
+ maxScroll := max(0, metrics.totalLines-height+1)
+
+ if m.previewScroll < 0 {
+ m.previewScroll = 0
+ }
+
+ if m.previewScroll > maxScroll {
+ m.previewScroll = maxScroll
+ }
+}
+
+func (m *Model) scrollToPreviewMatch() {
+ if len(m.previewSearchMatches) == 0 {
+ return
+ }
+
+ matchMessageIndex := m.previewSearchMatches[m.previewSearchIndex]
+ metrics := m.calculatePreviewMetrics()
+
+ if matchMessageIndex >= len(metrics.messageLines) {
+ return
+ }
+
+ lineNumber := metrics.messageLines[matchMessageIndex]
+ height := m.listHeight() - 2
+ m.previewScroll = max(0, lineNumber-height/3)
+
+ m.clampPreviewScroll()
+}
diff --git a/internal/app/search.go b/internal/app/search.go
new file mode 100644
index 0000000..b4d3833
--- /dev/null
+++ b/internal/app/search.go
@@ -0,0 +1,96 @@
+package app
+
+import (
+ "strings"
+
+ "github.com/Fuwn/faustus/internal/claude"
+)
+
+func (m *Model) jumpToSearchResult() {
+ if len(m.deepSearchResults) == 0 {
+ return
+ }
+
+ result := m.deepSearchResults[m.deepSearchIndex]
+ setupPreview := func() {
+ m.invalidatePreviewCache()
+
+ m.showPreview = true
+ m.previewFocus = true
+ m.previewSearchQuery = m.deepSearchQuery
+
+ if preview := m.preview(); preview != nil {
+ m.previewSearchMatches = claude.SearchPreview(preview, m.deepSearchQuery)
+ m.previewSearchIndex = 0
+
+ if len(m.previewSearchMatches) > 0 && result.Content != "" {
+ bestMatch := 0
+
+ for matchIndex, messageIndex := range m.previewSearchMatches {
+ if messageIndex < len(preview.Messages) {
+ if strings.Contains(strings.ToLower(preview.Messages[messageIndex].Content),
+ strings.ToLower(extractSearchSnippet(result.Content))) {
+ bestMatch = matchIndex
+
+ break
+ }
+ }
+ }
+
+ m.previewSearchIndex = bestMatch
+ }
+
+ m.scrollToPreviewMatch()
+ }
+ }
+
+ for index, session := range m.filtered {
+ if session.SessionID == result.Session.SessionID {
+ m.cursor = index
+
+ m.ensureVisible()
+ setupPreview()
+
+ return
+ }
+ }
+
+ for _, session := range m.sessions {
+ if session.SessionID == result.Session.SessionID {
+ if session.InTrash && m.tab != TabTrash {
+ m.tab = TabTrash
+
+ m.updateFiltered()
+ } else if !session.InTrash && m.tab != TabSessions {
+ m.tab = TabSessions
+
+ m.updateFiltered()
+ }
+
+ for filteredIndex, filteredSession := range m.filtered {
+ if filteredSession.SessionID == session.SessionID {
+ m.cursor = filteredIndex
+
+ m.ensureVisible()
+ setupPreview()
+
+ break
+ }
+ }
+
+ break
+ }
+ }
+}
+
+func extractSearchSnippet(content string) string {
+ content = strings.TrimPrefix(content, "… ")
+ content = strings.TrimSuffix(content, " …")
+ content = strings.TrimSpace(content)
+
+ if len(content) > 30 {
+ content = content[:30]
+ }
+
+ return content
+}
diff --git a/internal/app/state.go b/internal/app/state.go
new file mode 100644
index 0000000..82e186d
--- /dev/null
+++ b/internal/app/state.go
@@ -0,0 +1,114 @@
+package app
+
+import (
+ "strings"
+ "time"
+
+ "github.com/Fuwn/faustus/internal/claude"
+)
+
+func (m *Model) updateFiltered() {
+ query := strings.ToLower(m.searchInput.Value())
+ m.filtered = nil
+
+ for _, session := range m.sessions {
+ if m.tab == TabTrash && !session.InTrash {
+ continue
+ }
+
+ if m.tab == TabSessions && session.InTrash {
+ continue
+ }
+
+ if query != "" {
+ searchable := strings.ToLower(session.Summary + " " + session.FirstPrompt + " " + session.ProjectName + " " + session.GitBranch)
+
+ if !strings.Contains(searchable, query) {
+ continue
+ }
+ }
+
+ m.filtered = append(m.filtered, session)
+ }
+
+ if m.cursor >= len(m.filtered) {
+ m.cursor = max(0, len(m.filtered)-1)
+ }
+}
+
+func (m *Model) setMessage(statusMessage string) {
+ m.message = statusMessage
+ m.messageTime = time.Now()
+}
+
+func (m *Model) selectedSession() *claude.Session {
+ if m.cursor >= 0 && m.cursor < len(m.filtered) {
+ sessionID := m.filtered[m.cursor].SessionID
+
+ for index := range m.sessions {
+ if m.sessions[index].SessionID == sessionID {
+ return &m.sessions[index]
+ }
+ }
+ }
+
+ return nil
+}
+
+func (m *Model) updateFilteredFromOriginal() {
+ if m.cursor >= 0 && m.cursor < len(m.filtered) {
+ sessionID := m.filtered[m.cursor].SessionID
+
+ for _, session := range m.sessions {
+ if session.SessionID == sessionID {
+ m.filtered[m.cursor] = session
+
+ break
+ }
+ }
+ }
+}
+
+func (m *Model) reloadSessions() {
+ sessions, loadError := claude.LoadAllSessions()
+
+ if loadError == nil {
+ m.sessions = sessions
+
+ m.updateFiltered()
+ }
+}
+
+func (m *Model) ensureVisible() {
+ visibleHeight := m.listHeight()
+
+ if m.cursor < m.offset {
+ m.offset = m.cursor
+ }
+
+ if m.cursor >= m.offset+visibleHeight {
+ m.offset = m.cursor - visibleHeight + 1
+ }
+}
+
+func (m Model) listWidth() int {
+ if m.showPreview {
+ return m.width / 2
+ }
+
+ return m.width
+}
+
+func (m Model) previewWidth() int {
+ return m.width - m.listWidth() - 3
+}
+
+func (m Model) listHeight() int {
+ reserved := 8
+
+ if m.showHelp {
+ reserved += 12
+ }
+
+ return max(1, m.height-reserved)
+}
diff --git a/internal/app/update.go b/internal/app/update.go
new file mode 100644
index 0000000..b50fe82
--- /dev/null
+++ b/internal/app/update.go
@@ -0,0 +1,445 @@
+package app
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/Fuwn/faustus/internal/claude"
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
+ switch typedMessage := message.(type) {
+ case tea.WindowSizeMsg:
+ m.width = typedMessage.Width
+ m.height = typedMessage.Height
+
+ return m, nil
+
+ case tea.KeyMsg:
+ if time.Since(m.messageTime) > 3*time.Second {
+ m.message = ""
+ }
+
+ switch m.mode {
+ case ModeSearch:
+ return m.handleSearchMode(typedMessage)
+ case ModeDeepSearch:
+ return m.handleDeepSearchMode(typedMessage)
+ case ModeRename:
+ return m.handleRenameMode(typedMessage)
+ case ModeConfirm:
+ return m.handleConfirmMode(typedMessage)
+ default:
+ return m.handleNormalMode(typedMessage)
+ }
+ }
+
+ return m, nil
+}
+
+func (m Model) handleNormalMode(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch {
+ case key.Matches(keyMessage, m.keys.Quit):
+ return m, tea.Quit
+
+ case key.Matches(keyMessage, m.keys.Help):
+ m.showHelp = !m.showHelp
+
+ case key.Matches(keyMessage, m.keys.Preview):
+ m.showPreview = !m.showPreview
+ m.previewFocus = false
+ m.previewScroll = 0
+
+ m.invalidatePreviewCache()
+
+ case key.Matches(keyMessage, m.keys.Up):
+ if m.showPreview && m.previewFocus {
+ m.previewScroll -= 1
+
+ m.clampPreviewScroll()
+ } else if m.cursor > 0 {
+ m.cursor -= 1
+
+ m.ensureVisible()
+ m.invalidatePreviewCache()
+ }
+
+ case key.Matches(keyMessage, m.keys.Down):
+ if m.showPreview && m.previewFocus {
+ m.previewScroll += 1
+
+ m.clampPreviewScroll()
+ } else if m.cursor < len(m.filtered)-1 {
+ m.cursor += 1
+
+ m.ensureVisible()
+ m.invalidatePreviewCache()
+ }
+
+ case key.Matches(keyMessage, m.keys.HalfUp):
+ if m.showPreview && m.previewFocus {
+ m.previewScroll -= 10
+
+ m.clampPreviewScroll()
+ } else {
+ m.cursor = max(0, m.cursor-10)
+
+ m.ensureVisible()
+ m.invalidatePreviewCache()
+ }
+
+ case key.Matches(keyMessage, m.keys.HalfDown):
+ if m.showPreview && m.previewFocus {
+ m.previewScroll += 10
+
+ m.clampPreviewScroll()
+ } else {
+ m.cursor = min(len(m.filtered)-1, m.cursor+10)
+
+ m.ensureVisible()
+ m.invalidatePreviewCache()
+ }
+
+ case key.Matches(keyMessage, m.keys.Top):
+ if m.showPreview && m.previewFocus {
+ m.previewScroll = 0
+ } else {
+ m.cursor = 0
+
+ m.ensureVisible()
+ m.invalidatePreviewCache()
+ }
+
+ case key.Matches(keyMessage, m.keys.Bottom):
+ if m.showPreview && m.previewFocus {
+ m.previewScroll = 99999
+
+ m.clampPreviewScroll()
+ } else {
+ m.cursor = max(0, len(m.filtered)-1)
+
+ m.ensureVisible()
+ m.invalidatePreviewCache()
+ }
+
+ case key.Matches(keyMessage, m.keys.Tab):
+ if m.showPreview {
+ m.previewFocus = !m.previewFocus
+ } else {
+ if m.tab == TabSessions {
+ m.tab = TabTrash
+ } else {
+ m.tab = TabSessions
+ }
+
+ m.cursor = 0
+ m.offset = 0
+
+ m.updateFiltered()
+ m.invalidatePreviewCache()
+ }
+
+ case key.Matches(keyMessage, m.keys.Left):
+ if m.tab != TabSessions {
+ m.tab = TabSessions
+ m.cursor = 0
+ m.offset = 0
+
+ m.updateFiltered()
+ m.invalidatePreviewCache()
+ }
+
+ case key.Matches(keyMessage, m.keys.Right):
+ if m.tab != TabTrash {
+ m.tab = TabTrash
+ m.cursor = 0
+ m.offset = 0
+
+ m.updateFiltered()
+ m.invalidatePreviewCache()
+ }
+
+ case key.Matches(keyMessage, m.keys.Search):
+ m.mode = ModeSearch
+
+ m.searchInput.Focus()
+
+ return m, textinput.Blink
+
+ case key.Matches(keyMessage, m.keys.Delete):
+ if len(m.filtered) > 0 {
+ if m.tab == TabTrash {
+ m.confirmAction = ConfirmPermanentDelete
+ } else {
+ m.confirmAction = ConfirmDelete
+ }
+
+ m.mode = ModeConfirm
+ }
+
+ case key.Matches(keyMessage, m.keys.Restore):
+ if len(m.filtered) > 0 && m.tab == TabTrash {
+ m.confirmAction = ConfirmRestore
+ m.mode = ModeConfirm
+ }
+
+ case key.Matches(keyMessage, m.keys.Rename):
+ if len(m.filtered) > 0 {
+ session := &m.filtered[m.cursor]
+
+ m.renameInput.SetValue(session.Summary)
+ m.renameInput.Focus()
+
+ m.mode = ModeRename
+
+ return m, textinput.Blink
+ }
+
+ case key.Matches(keyMessage, m.keys.Clear):
+ if m.tab == TabTrash {
+ m.confirmAction = ConfirmEmptyTrash
+ m.mode = ModeConfirm
+ }
+
+ case key.Matches(keyMessage, m.keys.DeepSearch):
+ m.mode = ModeDeepSearch
+
+ m.deepSearchInput.SetValue(m.deepSearchQuery)
+ m.deepSearchInput.Focus()
+
+ return m, textinput.Blink
+
+ case key.Matches(keyMessage, m.keys.NextMatch):
+ if m.showPreview && m.previewFocus && len(m.previewSearchMatches) > 0 {
+ m.previewSearchIndex = (m.previewSearchIndex + 1) % len(m.previewSearchMatches)
+
+ m.scrollToPreviewMatch()
+ } else if len(m.deepSearchResults) > 0 {
+ m.deepSearchIndex = (m.deepSearchIndex + 1) % len(m.deepSearchResults)
+
+ m.jumpToSearchResult()
+ }
+
+ case key.Matches(keyMessage, m.keys.PrevMatch):
+ if m.showPreview && m.previewFocus && len(m.previewSearchMatches) > 0 {
+ m.previewSearchIndex -= 1
+
+ if m.previewSearchIndex < 0 {
+ m.previewSearchIndex = len(m.previewSearchMatches) - 1
+ }
+
+ m.scrollToPreviewMatch()
+ } else if len(m.deepSearchResults) > 0 {
+ m.deepSearchIndex -= 1
+
+ if m.deepSearchIndex < 0 {
+ m.deepSearchIndex = len(m.deepSearchResults) - 1
+ }
+
+ m.jumpToSearchResult()
+ }
+ }
+
+ return m, nil
+}
+
+func (m Model) handleSearchMode(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch {
+ case key.Matches(keyMessage, m.keys.Escape):
+ m.mode = ModeNormal
+
+ m.searchInput.Blur()
+ m.searchInput.SetValue("")
+ m.updateFiltered()
+
+ m.previewSearchQuery = ""
+ m.previewSearchMatches = nil
+
+ return m, nil
+
+ case key.Matches(keyMessage, m.keys.Enter):
+ m.mode = ModeNormal
+
+ m.searchInput.Blur()
+
+ if m.showPreview && m.previewFocus {
+ query := m.searchInput.Value()
+ m.previewSearchQuery = query
+
+ if preview := m.preview(); preview != nil {
+ m.previewSearchMatches = claude.SearchPreview(preview, query)
+ m.previewSearchIndex = 0
+
+ if len(m.previewSearchMatches) > 0 {
+ m.scrollToPreviewMatch()
+ m.setMessage(fmt.Sprintf("%d matches", len(m.previewSearchMatches)))
+ } else if query != "" {
+ m.setMessage("No matches")
+ }
+ }
+
+ m.searchInput.SetValue("")
+ } else {
+ m.updateFiltered()
+ }
+
+ return m, nil
+ }
+
+ var command tea.Cmd
+
+ m.searchInput, command = m.searchInput.Update(keyMessage)
+
+ if !m.showPreview || !m.previewFocus {
+ m.updateFiltered()
+ }
+
+ return m, command
+}
+
+func (m Model) handleDeepSearchMode(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch {
+ case key.Matches(keyMessage, m.keys.Escape):
+ m.mode = ModeNormal
+
+ m.deepSearchInput.Blur()
+
+ return m, nil
+
+ case key.Matches(keyMessage, m.keys.Enter):
+ query := m.deepSearchInput.Value()
+
+ if query != "" {
+ m.deepSearchQuery = query
+ m.deepSearchResults = claude.SearchAllSessions(m.sessions, query)
+ m.deepSearchIndex = 0
+
+ if len(m.deepSearchResults) > 0 {
+ m.jumpToSearchResult()
+ m.setMessage(fmt.Sprintf("%d matches across all sessions", len(m.deepSearchResults)))
+ } else {
+ m.setMessage("No matches")
+ }
+ }
+
+ m.mode = ModeNormal
+
+ m.deepSearchInput.Blur()
+
+ return m, nil
+ }
+
+ var command tea.Cmd
+
+ m.deepSearchInput, command = m.deepSearchInput.Update(keyMessage)
+
+ return m, command
+}
+
+func (m Model) handleRenameMode(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch {
+ case key.Matches(keyMessage, m.keys.Escape):
+ m.mode = ModeNormal
+
+ m.renameInput.Blur()
+
+ return m, nil
+
+ case key.Matches(keyMessage, m.keys.Enter):
+ if len(m.filtered) > 0 {
+ newName := m.renameInput.Value()
+
+ if newName != "" {
+ session := m.selectedSession()
+
+ if session != nil {
+ if renameError := claude.RenameSession(session, newName); renameError != nil {
+ m.setMessage(fmt.Sprintf("Error: %v", renameError))
+ } else {
+ session.Summary = newName
+
+ m.updateFilteredFromOriginal()
+ m.setMessage("Renamed")
+ }
+ }
+ }
+ }
+
+ m.mode = ModeNormal
+
+ m.renameInput.Blur()
+
+ return m, nil
+ }
+
+ var command tea.Cmd
+
+ m.renameInput, command = m.renameInput.Update(keyMessage)
+
+ return m, command
+}
+
+func (m Model) handleConfirmMode(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch {
+ case key.Matches(keyMessage, m.keys.Escape), keyMessage.String() == "n", keyMessage.String() == "N":
+ m.mode = ModeNormal
+ m.confirmAction = ConfirmNone
+
+ return m, nil
+
+ case key.Matches(keyMessage, m.keys.Confirm):
+ return m.executeConfirmedAction()
+ }
+
+ return m, nil
+}
+
+func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
+ switch m.confirmAction {
+ case ConfirmDelete:
+ if session := m.selectedSession(); session != nil {
+ if deleteError := claude.MoveToTrash(session); deleteError != nil {
+ m.setMessage(fmt.Sprintf("Error: %v", deleteError))
+ } else {
+ m.setMessage("Moved to Bin")
+ m.reloadSessions()
+ }
+ }
+
+ case ConfirmRestore:
+ if session := m.selectedSession(); session != nil {
+ if restoreError := claude.RestoreFromTrash(session); restoreError != nil {
+ m.setMessage(fmt.Sprintf("Error: %v", restoreError))
+ } else {
+ m.setMessage("Restored")
+ m.reloadSessions()
+ }
+ }
+
+ case ConfirmPermanentDelete:
+ if session := m.selectedSession(); session != nil {
+ if deleteError := claude.PermanentlyDelete(session); deleteError != nil {
+ m.setMessage(fmt.Sprintf("Error: %v", deleteError))
+ } else {
+ m.setMessage("Permanently deleted")
+ m.reloadSessions()
+ }
+ }
+
+ case ConfirmEmptyTrash:
+ if emptyError := claude.EmptyTrash(); emptyError != nil {
+ m.setMessage(fmt.Sprintf("Error: %v", emptyError))
+ } else {
+ m.setMessage("Bin emptied")
+ m.reloadSessions()
+ }
+ }
+
+ m.mode = ModeNormal
+ m.confirmAction = ConfirmNone
+
+ return m, nil
+}
diff --git a/internal/app/view.go b/internal/app/view.go
new file mode 100644
index 0000000..48239d2
--- /dev/null
+++ b/internal/app/view.go
@@ -0,0 +1,487 @@
+package app
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/Fuwn/faustus/internal/claude"
+ "github.com/Fuwn/faustus/internal/ui"
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m Model) View() string {
+ if m.width == 0 {
+ return "Loading …"
+ }
+
+ var builder strings.Builder
+
+ builder.WriteString(m.renderHeader())
+ builder.WriteString("\n")
+ builder.WriteString(m.renderTabs())
+ builder.WriteString("\n\n")
+
+ if m.mode == ModeSearch {
+ builder.WriteString(m.renderSearch())
+ builder.WriteString("\n")
+ } else if m.mode == ModeDeepSearch {
+ builder.WriteString(m.renderDeepSearch())
+ builder.WriteString("\n")
+ } else if m.searchInput.Value() != "" {
+ builder.WriteString(ui.SearchStyle.Render("/ " + m.searchInput.Value()))
+ builder.WriteString("\n")
+ }
+
+ if m.deepSearchQuery != "" && len(m.deepSearchResults) > 0 {
+ status := fmt.Sprintf("Search: \"%s\" • %d of %d • n / N to navigate",
+ m.deepSearchQuery, m.deepSearchIndex+1, len(m.deepSearchResults))
+
+ builder.WriteString(ui.SearchMatchStyle.Render(status))
+ builder.WriteString("\n")
+ }
+
+ if m.mode == ModeRename {
+ builder.WriteString(m.renderRename())
+ builder.WriteString("\n")
+ }
+
+ if m.mode == ModeConfirm {
+ builder.WriteString(m.renderConfirm())
+ builder.WriteString("\n\n")
+ }
+
+ if m.showPreview {
+ builder.WriteString(m.renderSplitView())
+ } else {
+ builder.WriteString(m.renderList())
+ }
+
+ if m.message != "" && time.Since(m.messageTime) < 3*time.Second {
+ builder.WriteString("\n")
+ builder.WriteString(ui.StatusBarStyle.Render(m.message))
+ }
+
+ if m.showHelp {
+ builder.WriteString("\n")
+ builder.WriteString(m.renderHelp())
+ } else {
+ builder.WriteString("\n")
+
+ previewHint := ""
+
+ if m.showPreview {
+ if m.previewFocus {
+ previewHint = " • Preview focused"
+ } else {
+ previewHint = " • Tab to focus preview"
+ }
+ }
+
+ builder.WriteString(ui.HelpStyle.Render("? Help • j/k Navigate • h/l Tabs • / Filter • p Preview" + previewHint))
+ }
+
+ return builder.String()
+}
+
+func (m Model) renderSplitView() string {
+ listWidth := m.listWidth()
+ previewWidth := m.previewWidth()
+ listHeight := m.listHeight()
+ listContent := m.renderListCompact(listWidth-2, listHeight-2)
+ previewContent := m.renderPreview(previewWidth-2, listHeight-2)
+
+ var listStyle, previewStyle lipgloss.Style
+
+ if m.previewFocus {
+ listStyle = ui.ListBoxStyle
+ previewStyle = ui.PreviewFocusedStyle
+ } else {
+ listStyle = ui.ListBoxFocusedStyle
+ previewStyle = ui.PreviewStyle
+ }
+
+ listBox := listStyle.
+ Width(listWidth).
+ Height(listHeight).
+ Render(listContent)
+ previewBox := previewStyle.
+ Width(previewWidth).
+ Height(listHeight).
+ Render(previewContent)
+
+ return lipgloss.JoinHorizontal(lipgloss.Top, listBox, " ", previewBox)
+}
+
+func (m Model) renderListCompact(width, height int) string {
+ if len(m.filtered) == 0 {
+ if m.tab == TabTrash {
+ return ui.MetaStyle.Render(" Bin is empty")
+ }
+
+ if m.searchInput.Value() != "" {
+ return ui.MetaStyle.Render(" No matching sessions")
+ }
+
+ return ui.MetaStyle.Render(" No sessions")
+ }
+
+ var builder strings.Builder
+
+ for index := m.offset; index < min(m.offset+height, len(m.filtered)); index++ {
+ session := m.filtered[index]
+ isSelected := index == m.cursor
+
+ builder.WriteString(m.renderSessionCompact(&session, isSelected, width))
+ builder.WriteString("\n")
+ }
+
+ if len(m.filtered) > height {
+ indicator := fmt.Sprintf("[%d/%d]", m.cursor+1, len(m.filtered))
+
+ builder.WriteString(ui.MetaStyle.Render(indicator))
+ }
+
+ return builder.String()
+}
+
+func (m Model) renderSessionCompact(session *claude.Session, isSelected bool, maxWidth int) string {
+ cursor := " "
+
+ if isSelected {
+ cursor = ui.CursorStyle.Render("▸ ")
+ }
+
+ summary := session.Summary
+
+ if summary == "" {
+ summary = truncate(session.FirstPrompt, 40)
+ }
+
+ if summary == "" {
+ summary = "(No summary)"
+ }
+
+ maxSummary := maxWidth - 4
+
+ summary = truncate(summary, maxSummary)
+
+ if isSelected {
+ return cursor + ui.SelectedItemStyle.Render(summary)
+ }
+
+ return cursor + ui.TitleStyle.Render(summary)
+}
+
+func (m Model) renderPreview(width, height int) string {
+ preview := m.preview()
+
+ if preview == nil {
+ return ui.MetaStyle.Render("No session selected")
+ }
+
+ if preview.Error != "" {
+ return ui.MetaStyle.Render(preview.Error)
+ }
+
+ var lines []string
+
+ if m.cursor < len(m.filtered) {
+ session := &m.filtered[m.cursor]
+ header := ui.PreviewHeaderStyle.Render(truncate(session.Summary, width-4))
+
+ lines = append(lines, header)
+
+ meta := ui.MetaStyle.Render(fmt.Sprintf("%s • %s • %d messages",
+ session.ProjectName, formatTime(session.Modified), len(preview.Messages)))
+
+ lines = append(lines, meta)
+ lines = append(lines, ui.PreviewDividerStyle.Render(strings.Repeat("─", width-4)))
+ lines = append(lines, "")
+ }
+
+ for messageIndex, previewMessage := range preview.Messages {
+ var roleStyle, contentStyle lipgloss.Style
+ var prefix string
+
+ isMatch := false
+ isCurrentMatch := false
+
+ for matchNumber, matchMessageIndex := range m.previewSearchMatches {
+ if matchMessageIndex == messageIndex {
+ isMatch = true
+
+ if matchNumber == m.previewSearchIndex {
+ isCurrentMatch = true
+ }
+
+ break
+ }
+ }
+
+ switch previewMessage.Role {
+ case "user":
+ roleStyle = ui.UserRoleStyle
+ contentStyle = ui.UserContentStyle
+ prefix = "You"
+ case "assistant":
+ roleStyle = ui.AssistantRoleStyle
+ contentStyle = ui.AssistantContentStyle
+ prefix = "Claude"
+ case "tool":
+ roleStyle = ui.ToolRoleStyle
+ contentStyle = ui.ToolContentStyle
+ prefix = "Tool"
+ case "thinking":
+ roleStyle = ui.ThinkingRoleStyle
+ contentStyle = ui.ThinkingContentStyle
+ prefix = "Thinking"
+ }
+
+ matchIndicator := ""
+
+ if isCurrentMatch {
+ matchIndicator = ui.HighlightStyle.Render(" ◀ ")
+ } else if isMatch {
+ matchIndicator = ui.SearchMatchStyle.Render(" ● ")
+ }
+
+ lines = append(lines, roleStyle.Render(prefix+":")+matchIndicator)
+
+ content := previewMessage.Content
+
+ if m.previewSearchQuery != "" && isMatch {
+ content = highlightMatches(content, m.previewSearchQuery)
+ }
+
+ wrapped := wrapText(content, width-6)
+
+ for _, line := range strings.Split(wrapped, "\n") {
+ lines = append(lines, " "+contentStyle.Render(line))
+ }
+
+ lines = append(lines, "")
+ }
+
+ maxScroll := max(0, len(lines)-height+1)
+ scroll := m.previewScroll
+
+ if scroll < 0 {
+ scroll = 0
+ }
+
+ if scroll > maxScroll {
+ scroll = maxScroll
+ }
+
+ if scroll > 0 && scroll < len(lines) {
+ lines = lines[scroll:]
+ }
+
+ if len(lines) > height {
+ lines = lines[:height]
+ }
+
+ if maxScroll > 0 {
+ indicator := fmt.Sprintf("─── %d/%d ───", scroll+1, maxScroll+1)
+
+ if len(lines) > 0 {
+ lines[len(lines)-1] = ui.MetaStyle.Render(indicator)
+ }
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func (m Model) renderHeader() string {
+ logo := ui.LogoStyle.Render("🛎️ Faustus")
+ subtitle := ui.MetaStyle.Render(" • Session Manager for Claude Code")
+ count := ui.CountStyle.Render(fmt.Sprintf("%d sessions", len(m.filtered)))
+ gap := m.width - lipgloss.Width(logo) - lipgloss.Width(subtitle) - lipgloss.Width(count) - 4
+
+ if gap < 0 {
+ gap = 0
+ }
+
+ return logo + subtitle + strings.Repeat(" ", gap) + count
+}
+
+func (m Model) renderTabs() string {
+ var sessionsCount, trashCount int
+
+ for _, session := range m.sessions {
+ if session.InTrash {
+ trashCount += 1
+ } else {
+ sessionsCount += 1
+ }
+ }
+
+ sessionsTab := fmt.Sprintf("Sessions (%d)", sessionsCount)
+ binTab := fmt.Sprintf("Bin (%d)", trashCount)
+
+ if m.tab == TabSessions {
+ return ui.ActiveTabStyle.Render("● "+sessionsTab) + " " + ui.TabStyle.Render(binTab)
+ }
+
+ return ui.TabStyle.Render(sessionsTab) + " " + ui.ActiveTabStyle.Render("● "+binTab)
+}
+
+func (m Model) renderSearch() string {
+ label := "/"
+
+ if m.showPreview && m.previewFocus {
+ label = "/ (preview)"
+ }
+
+ return ui.SearchInputStyle.Render(label + " " + m.searchInput.View())
+}
+
+func (m Model) renderDeepSearch() string {
+ return ui.SearchInputStyle.Render("s: " + m.deepSearchInput.View())
+}
+
+func (m Model) renderRename() string {
+ return ui.SearchInputStyle.Render("✏️ " + m.renameInput.View())
+}
+
+func (m Model) renderConfirm() string {
+ var confirmMessage string
+
+ switch m.confirmAction {
+ case ConfirmDelete:
+ confirmMessage = "Move this session to the Bin?"
+ case ConfirmRestore:
+ confirmMessage = "Restore this session from the Bin?"
+ case ConfirmPermanentDelete:
+ confirmMessage = "Delete this session permanently? This cannot be undone."
+ case ConfirmEmptyTrash:
+ confirmMessage = "Empty the Bin? All sessions will be permanently deleted."
+ }
+
+ return ui.ModalStyle.Render(
+ ui.ConfirmStyle.Render(confirmMessage) + "\n\n" +
+ ui.HelpKeyStyle.Render("y") + ui.HelpStyle.Render(" confirm ") +
+ ui.HelpKeyStyle.Render("n/esc") + ui.HelpStyle.Render(" cancel"),
+ )
+}
+
+func (m Model) renderList() string {
+ if len(m.filtered) == 0 {
+ if m.tab == TabTrash {
+ return ui.MetaStyle.Render(" Bin is empty")
+ }
+
+ if m.searchInput.Value() != "" {
+ return ui.MetaStyle.Render(" No matching sessions")
+ }
+
+ return ui.MetaStyle.Render(" No sessions")
+ }
+
+ var builder strings.Builder
+
+ listHeight := m.listHeight()
+
+ for index := m.offset; index < min(m.offset+listHeight, len(m.filtered)); index++ {
+ session := m.filtered[index]
+ isSelected := index == m.cursor
+
+ builder.WriteString(m.renderSession(&session, isSelected))
+ builder.WriteString("\n")
+ }
+
+ if len(m.filtered) > listHeight {
+ position := float64(m.offset) / float64(len(m.filtered)-listHeight)
+ indicator := fmt.Sprintf(" [%d-%d of %d]", m.offset+1, min(m.offset+listHeight, len(m.filtered)), len(m.filtered))
+
+ builder.WriteString(ui.MetaStyle.Render(indicator))
+
+ scrollPosition := int(position * 10)
+ scrollBar := strings.Repeat("─", scrollPosition) + "●" + strings.Repeat("─", 10-scrollPosition)
+
+ builder.WriteString(" " + ui.MetaStyle.Render(scrollBar))
+ }
+
+ return builder.String()
+}
+
+func (m Model) renderSession(session *claude.Session, isSelected bool) string {
+ var builder strings.Builder
+
+ cursor := " "
+
+ if isSelected {
+ cursor = ui.CursorStyle.Render("▸ ")
+ }
+
+ builder.WriteString(cursor)
+
+ summary := session.Summary
+
+ if summary == "" {
+ summary = truncate(session.FirstPrompt, 60)
+ }
+
+ if summary == "" {
+ summary = "(No summary)"
+ }
+
+ if isSelected {
+ builder.WriteString(ui.SelectedItemStyle.Render(truncate(summary, m.width-20)))
+ } else {
+ builder.WriteString(ui.TitleStyle.Render(truncate(summary, m.width-20)))
+ }
+
+ builder.WriteString("\n")
+
+ meta := fmt.Sprintf(" %s", ui.ProjectStyle.Render(session.ProjectName))
+
+ if session.GitBranch != "" {
+ meta += ui.MetaStyle.Render(" @ " + session.GitBranch)
+ }
+
+ meta += ui.MetaStyle.Render(fmt.Sprintf(" • %d messages • %s", session.MessageCount, formatTime(session.Modified)))
+
+ if session.InTrash {
+ meta += " " + ui.TrashStyle.Render("In Bin")
+ }
+
+ builder.WriteString(meta)
+
+ return builder.String()
+}
+
+func (m Model) renderHelp() string {
+ var builder strings.Builder
+
+ builder.WriteString(ui.HeaderStyle.Render("Keyboard Shortcuts"))
+ builder.WriteString("\n\n")
+
+ helpItems := []struct{ key, description string }{
+ {"j / k", "Navigate up and down"},
+ {"h / l", "Switch between tabs"},
+ {"g g / G", "Jump to top or bottom"},
+ {"ctrl+u / ctrl+d", "Page up or down"},
+ {"/", "Filter sessions"},
+ {"s", "Search all sessions"},
+ {"n / N", "Next or previous match"},
+ {"p", "Toggle preview pane"},
+ {"tab", "Switch focus"},
+ {"d", "Move to Bin"},
+ {"u", "Restore from Bin"},
+ {"c", "Rename session"},
+ {"D", "Empty Bin"},
+ {"?", "Show help"},
+ {"q", "Quit"},
+ }
+
+ for _, item := range helpItems {
+ builder.WriteString(" ")
+ builder.WriteString(ui.HelpKeyStyle.Render(fmt.Sprintf("%-16s", item.key)))
+ builder.WriteString(ui.HelpStyle.Render(item.description))
+ builder.WriteString("\n")
+ }
+
+ return builder.String()
+}
diff --git a/internal/claude/preview.go b/internal/claude/preview.go
new file mode 100644
index 0000000..1ea08c1
--- /dev/null
+++ b/internal/claude/preview.go
@@ -0,0 +1,177 @@
+package claude
+
+import (
+ "bufio"
+ "encoding/json"
+ "os"
+ "strings"
+)
+
+type RawMessage struct {
+ Type string `json:"type"`
+ Message json.RawMessage `json:"message"`
+}
+
+type UserMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type AssistantMessage struct {
+ Role string `json:"role"`
+ Content []ContentBlock `json:"content"`
+}
+
+type ContentBlock struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ Thinking string `json:"thinking,omitempty"`
+ Name string `json:"name,omitempty"`
+ Input any `json:"input,omitempty"`
+}
+
+type PreviewContent struct {
+ Messages []PreviewMessage
+ Error string
+}
+
+type PreviewMessage struct {
+ Role string
+ Content string
+}
+
+func LoadSessionPreview(session *Session, maxMessages int) PreviewContent {
+ if session == nil || session.FullPath == "" {
+ return PreviewContent{Error: "No session selected"}
+ }
+
+ file, openError := os.Open(session.FullPath)
+
+ if openError != nil {
+ return PreviewContent{Error: "Could not open session file"}
+ }
+
+ defer func() { _ = file.Close() }()
+
+ var messages []PreviewMessage
+
+ scanner := bufio.NewScanner(file)
+ scanBuffer := make([]byte, 0, 64*1024)
+
+ scanner.Buffer(scanBuffer, 10*1024*1024)
+
+ for scanner.Scan() {
+ line := scanner.Text()
+
+ if line == "" {
+ continue
+ }
+
+ var rawMessage RawMessage
+
+ if unmarshalError := json.Unmarshal([]byte(line), &rawMessage); unmarshalError != nil {
+ continue
+ }
+
+ parsedMessages := parseRawMessage(rawMessage)
+
+ messages = append(messages, parsedMessages...)
+ }
+
+ if len(messages) > maxMessages {
+ messages = messages[len(messages)-maxMessages:]
+ }
+
+ if len(messages) == 0 {
+ return PreviewContent{Error: "No messages in session"}
+ }
+
+ return PreviewContent{Messages: messages}
+}
+
+func parseRawMessage(rawMessage RawMessage) []PreviewMessage {
+ var result []PreviewMessage
+
+ switch rawMessage.Type {
+ case "user":
+ var userMessage UserMessage
+
+ if unmarshalError := json.Unmarshal(rawMessage.Message, &userMessage); unmarshalError != nil {
+ return nil
+ }
+
+ content := userMessage.Content
+
+ if len(content) > 500 {
+ content = content[:500] + " …"
+ }
+
+ if content != "" {
+ result = append(result, PreviewMessage{Role: "user", Content: content})
+ }
+
+ case "assistant":
+ var assistantMessage AssistantMessage
+
+ if unmarshalError := json.Unmarshal(rawMessage.Message, &assistantMessage); unmarshalError != nil {
+ return nil
+ }
+
+ for _, contentBlock := range assistantMessage.Content {
+ switch contentBlock.Type {
+ case "text":
+ text := contentBlock.Text
+
+ if len(text) > 500 {
+ text = text[:500] + " …"
+ }
+
+ if text != "" {
+ result = append(result, PreviewMessage{Role: "assistant", Content: text})
+ }
+
+ case "tool_use":
+ toolInfo := contentBlock.Name
+
+ if toolInfo != "" {
+ if inputMap, isMap := contentBlock.Input.(map[string]interface{}); isMap {
+ if command, exists := inputMap["command"]; exists {
+ if commandString, isString := command.(string); isString {
+ if len(commandString) > 60 {
+ commandString = commandString[:60] + " …"
+ }
+
+ toolInfo += ": " + commandString
+ }
+ } else if pattern, exists := inputMap["pattern"]; exists {
+ if patternString, isString := pattern.(string); isString {
+ toolInfo += ": " + patternString
+ }
+ } else if filePath, exists := inputMap["file_path"]; exists {
+ if filePathString, isString := filePath.(string); isString {
+ pathParts := strings.Split(filePathString, "/")
+
+ toolInfo += ": " + pathParts[len(pathParts)-1]
+ }
+ }
+ }
+
+ result = append(result, PreviewMessage{Role: "tool", Content: toolInfo})
+ }
+
+ case "thinking":
+ thinking := contentBlock.Thinking
+
+ if len(thinking) > 200 {
+ thinking = thinking[:200] + " …"
+ }
+
+ if thinking != "" {
+ result = append(result, PreviewMessage{Role: "thinking", Content: thinking})
+ }
+ }
+ }
+ }
+
+ return result
+}
diff --git a/internal/claude/search.go b/internal/claude/search.go
new file mode 100644
index 0000000..c79f871
--- /dev/null
+++ b/internal/claude/search.go
@@ -0,0 +1,180 @@
+package claude
+
+import (
+ "bufio"
+ "encoding/json"
+ "os"
+ "strings"
+)
+
+type SearchResult struct {
+ Session *Session
+ MessageIndex int
+ Role string
+ Content string
+ MatchPosition int
+}
+
+func SearchAllSessions(sessions []Session, query string) []SearchResult {
+ if query == "" {
+ return nil
+ }
+
+ query = strings.ToLower(query)
+
+ var results []SearchResult
+
+ for sessionIndex := range sessions {
+ session := &sessions[sessionIndex]
+ matches := searchSession(session, query)
+
+ results = append(results, matches...)
+ }
+
+ return results
+}
+
+func searchSession(session *Session, query string) []SearchResult {
+ file, openError := os.Open(session.FullPath)
+
+ if openError != nil {
+ return nil
+ }
+
+ defer func() { _ = file.Close() }()
+
+ var results []SearchResult
+
+ scanner := bufio.NewScanner(file)
+ scanBuffer := make([]byte, 0, 64*1024)
+
+ scanner.Buffer(scanBuffer, 10*1024*1024)
+
+ messageIndex := 0
+
+ for scanner.Scan() {
+ line := scanner.Text()
+
+ if line == "" {
+ continue
+ }
+
+ var rawMessage RawMessage
+
+ if unmarshalError := json.Unmarshal([]byte(line), &rawMessage); unmarshalError != nil {
+ continue
+ }
+
+ matches := searchRawMessage(session, &rawMessage, query, messageIndex)
+
+ results = append(results, matches...)
+
+ if rawMessage.Type == "user" || rawMessage.Type == "assistant" {
+ messageIndex += 1
+ }
+ }
+
+ return results
+}
+
+func searchRawMessage(session *Session, rawMessage *RawMessage, query string, messageIndex int) []SearchResult {
+ var results []SearchResult
+
+ switch rawMessage.Type {
+ case "user":
+ var userMessage UserMessage
+
+ if unmarshalError := json.Unmarshal(rawMessage.Message, &userMessage); unmarshalError != nil {
+ return nil
+ }
+
+ contentLowercase := strings.ToLower(userMessage.Content)
+
+ if matchPosition := strings.Index(contentLowercase, query); matchPosition != -1 {
+ content := matchContext(userMessage.Content, matchPosition, len(query))
+
+ results = append(results, SearchResult{
+ Session: session,
+ MessageIndex: messageIndex,
+ Role: "user",
+ Content: content,
+ MatchPosition: matchPosition,
+ })
+ }
+
+ case "assistant":
+ var assistantMessage AssistantMessage
+
+ if unmarshalError := json.Unmarshal(rawMessage.Message, &assistantMessage); unmarshalError != nil {
+ return nil
+ }
+
+ for _, contentBlock := range assistantMessage.Content {
+ if contentBlock.Type == "text" {
+ textLowercase := strings.ToLower(contentBlock.Text)
+
+ if matchPosition := strings.Index(textLowercase, query); matchPosition != -1 {
+ content := matchContext(contentBlock.Text, matchPosition, len(query))
+
+ results = append(results, SearchResult{
+ Session: session,
+ MessageIndex: messageIndex,
+ Role: "assistant",
+ Content: content,
+ MatchPosition: matchPosition,
+ })
+ }
+ }
+ }
+ }
+
+ return results
+}
+
+func matchContext(text string, matchPosition, matchLength int) string {
+ const contextLength = 100
+
+ start := matchPosition - contextLength/2
+
+ if start < 0 {
+ start = 0
+ }
+
+ end := matchPosition + matchLength + contextLength/2
+
+ if end > len(text) {
+ end = len(text)
+ }
+
+ result := text[start:end]
+ result = strings.ReplaceAll(result, "\n", " ")
+ result = strings.Join(strings.Fields(result), " ")
+
+ if start > 0 {
+ result = "… " + result
+ }
+
+ if end < len(text) {
+ result = result + " …"
+ }
+
+ return result
+}
+
+func SearchPreview(preview *PreviewContent, query string) []int {
+ if preview == nil || query == "" {
+ return nil
+ }
+
+ query = strings.ToLower(query)
+
+ var matches []int
+
+ for messageIndex, previewMessage := range preview.Messages {
+ if strings.Contains(strings.ToLower(previewMessage.Content), query) {
+ matches = append(matches, messageIndex)
+ }
+ }
+
+ return matches
+}
diff --git a/internal/claude/session.go b/internal/claude/session.go
new file mode 100644
index 0000000..7e7b311
--- /dev/null
+++ b/internal/claude/session.go
@@ -0,0 +1,360 @@
+package claude
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+type Session struct {
+ SessionID string `json:"sessionId"`
+ FullPath string `json:"fullPath"`
+ FirstPrompt string `json:"firstPrompt"`
+ Summary string `json:"summary"`
+ MessageCount int `json:"messageCount"`
+ Created time.Time `json:"created"`
+ Modified time.Time `json:"modified"`
+ GitBranch string `json:"gitBranch"`
+ ProjectPath string `json:"projectPath"`
+ IsSidechain bool `json:"isSidechain"`
+
+ ProjectName string `json:"-"`
+ InTrash bool `json:"-"`
+}
+
+type SessionIndex struct {
+ Version int `json:"version"`
+ Entries []Session `json:"entries"`
+ OriginalPath string `json:"originalPath"`
+}
+
+func ClaudeDir() string {
+ homeDirectory, _ := os.UserHomeDir()
+
+ return filepath.Join(homeDirectory, ".claude")
+}
+
+func ProjectsDir() string {
+ return filepath.Join(ClaudeDir(), "projects")
+}
+
+func TrashDir() string {
+ return filepath.Join(ClaudeDir(), "faustus-trash")
+}
+
+func EnsureTrashDir() error {
+ return os.MkdirAll(TrashDir(), 0o755)
+}
+
+func LoadAllSessions() ([]Session, error) {
+ var allSessions []Session
+
+ projectsDirectory := ProjectsDir()
+ directoryEntries, readError := os.ReadDir(projectsDirectory)
+
+ if readError != nil {
+ return nil, readError
+ }
+
+ for _, directoryEntry := range directoryEntries {
+ if !directoryEntry.IsDir() {
+ continue
+ }
+
+ projectDirectory := filepath.Join(projectsDirectory, directoryEntry.Name())
+ indexPath := filepath.Join(projectDirectory, "sessions-index.json")
+ sessions, loadError := loadSessionsFromIndex(indexPath, directoryEntry.Name(), false)
+
+ if loadError != nil {
+ continue
+ }
+
+ allSessions = append(allSessions, sessions...)
+ }
+
+ trashDirectory := TrashDir()
+
+ if _, statError := os.Stat(trashDirectory); statError == nil {
+ trashEntries, readError := os.ReadDir(trashDirectory)
+
+ if readError == nil {
+ for _, directoryEntry := range trashEntries {
+ if !directoryEntry.IsDir() {
+ continue
+ }
+
+ projectDirectory := filepath.Join(trashDirectory, directoryEntry.Name())
+ indexPath := filepath.Join(projectDirectory, "sessions-index.json")
+ sessions, loadError := loadSessionsFromIndex(indexPath, directoryEntry.Name(), true)
+
+ if loadError != nil {
+ continue
+ }
+
+ allSessions = append(allSessions, sessions...)
+ }
+ }
+ }
+
+ sort.Slice(allSessions, func(first, second int) bool {
+ return allSessions[first].Modified.After(allSessions[second].Modified)
+ })
+
+ return allSessions, nil
+}
+
+func loadSessionsFromIndex(indexPath, projectDirectoryName string, inTrash bool) ([]Session, error) {
+ fileData, readError := os.ReadFile(indexPath)
+
+ if readError != nil {
+ return nil, readError
+ }
+
+ var sessionIndex SessionIndex
+
+ if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil {
+ return nil, unmarshalError
+ }
+
+ projectName := deriveProjectName(projectDirectoryName)
+
+ for entryIndex := range sessionIndex.Entries {
+ sessionIndex.Entries[entryIndex].ProjectName = projectName
+ sessionIndex.Entries[entryIndex].InTrash = inTrash
+
+ if inTrash {
+ sessionIndex.Entries[entryIndex].FullPath = filepath.Join(TrashDir(), projectDirectoryName,
+ sessionIndex.Entries[entryIndex].SessionID+".jsonl")
+ }
+ }
+
+ return sessionIndex.Entries, nil
+}
+
+func deriveProjectName(directoryName string) string {
+ parts := strings.Split(directoryName, "-")
+
+ if len(parts) > 0 {
+ for partIndex := len(parts) - 1; partIndex >= 0; partIndex-- {
+ if parts[partIndex] != "" {
+ if partIndex > 0 && len(parts[partIndex-1]) > 0 {
+ return parts[partIndex-1] + "/" + parts[partIndex]
+ }
+
+ return parts[partIndex]
+ }
+ }
+ }
+
+ return directoryName
+}
+
+func ProjectDir(session *Session) string {
+ return filepath.Dir(session.FullPath)
+}
+
+func MoveToTrash(session *Session) error {
+ if session.InTrash {
+ return nil
+ }
+
+ if ensureError := EnsureTrashDir(); ensureError != nil {
+ return ensureError
+ }
+
+ sourceProjectDirectory := ProjectDir(session)
+ projectDirectoryName := filepath.Base(sourceProjectDirectory)
+ destinationProjectDirectory := filepath.Join(TrashDir(), projectDirectoryName)
+
+ if mkdirError := os.MkdirAll(destinationProjectDirectory, 0o755); mkdirError != nil {
+ return mkdirError
+ }
+
+ sourceFile := session.FullPath
+ destinationFile := filepath.Join(destinationProjectDirectory, session.SessionID+".jsonl")
+
+ if renameError := os.Rename(sourceFile, destinationFile); renameError != nil {
+ return renameError
+ }
+
+ sourceAssociatedDirectory := filepath.Join(sourceProjectDirectory, session.SessionID)
+
+ if _, statError := os.Stat(sourceAssociatedDirectory); statError == nil {
+ destinationAssociatedDirectory := filepath.Join(destinationProjectDirectory, session.SessionID)
+
+ _ = os.Rename(sourceAssociatedDirectory, destinationAssociatedDirectory)
+ }
+
+ if removeError := removeFromIndex(sourceProjectDirectory, session.SessionID); removeError != nil {
+ return removeError
+ }
+
+ session.InTrash = true
+ session.FullPath = destinationFile
+
+ return addToIndex(destinationProjectDirectory, session)
+}
+
+func RestoreFromTrash(session *Session) error {
+ if !session.InTrash {
+ return nil
+ }
+
+ sourceProjectDirectory := ProjectDir(session)
+ projectDirectoryName := filepath.Base(sourceProjectDirectory)
+ destinationProjectDirectory := filepath.Join(ProjectsDir(), projectDirectoryName)
+ sourceFile := session.FullPath
+ destinationFile := filepath.Join(destinationProjectDirectory, session.SessionID+".jsonl")
+
+ if renameError := os.Rename(sourceFile, destinationFile); renameError != nil {
+ return renameError
+ }
+
+ sourceAssociatedDirectory := filepath.Join(sourceProjectDirectory, session.SessionID)
+
+ if _, statError := os.Stat(sourceAssociatedDirectory); statError == nil {
+ destinationAssociatedDirectory := filepath.Join(destinationProjectDirectory, session.SessionID)
+
+ _ = os.Rename(sourceAssociatedDirectory, destinationAssociatedDirectory)
+ }
+
+ if removeError := removeFromIndex(sourceProjectDirectory, session.SessionID); removeError != nil {
+ return removeError
+ }
+
+ session.InTrash = false
+ session.FullPath = destinationFile
+
+ return addToIndex(destinationProjectDirectory, session)
+}
+
+func PermanentlyDelete(session *Session) error {
+ projectDirectory := ProjectDir(session)
+
+ if removeError := os.Remove(session.FullPath); removeError != nil && !os.IsNotExist(removeError) {
+ return removeError
+ }
+
+ associatedDirectory := filepath.Join(projectDirectory, session.SessionID)
+
+ _ = os.RemoveAll(associatedDirectory)
+
+ return removeFromIndex(projectDirectory, session.SessionID)
+}
+
+func EmptyTrash() error {
+ trashDirectory := TrashDir()
+
+ if _, statError := os.Stat(trashDirectory); os.IsNotExist(statError) {
+ return nil
+ }
+
+ return os.RemoveAll(trashDirectory)
+}
+
+func RenameSession(session *Session, newSummary string) error {
+ projectDirectory := ProjectDir(session)
+ indexPath := filepath.Join(projectDirectory, "sessions-index.json")
+ fileData, readError := os.ReadFile(indexPath)
+
+ if readError != nil {
+ return readError
+ }
+
+ var sessionIndex SessionIndex
+
+ if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil {
+ return unmarshalError
+ }
+
+ for entryIndex := range sessionIndex.Entries {
+ if sessionIndex.Entries[entryIndex].SessionID == session.SessionID {
+ sessionIndex.Entries[entryIndex].Summary = newSummary
+
+ break
+ }
+ }
+
+ return writeIndex(indexPath, &sessionIndex)
+}
+
+func removeFromIndex(projectDirectory, sessionID string) error {
+ indexPath := filepath.Join(projectDirectory, "sessions-index.json")
+ fileData, readError := os.ReadFile(indexPath)
+
+ if readError != nil {
+ return readError
+ }
+
+ var sessionIndex SessionIndex
+
+ if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil {
+ return unmarshalError
+ }
+
+ filteredEntries := make([]Session, 0, len(sessionIndex.Entries)-1)
+
+ for _, entry := range sessionIndex.Entries {
+ if entry.SessionID != sessionID {
+ filteredEntries = append(filteredEntries, entry)
+ }
+ }
+
+ sessionIndex.Entries = filteredEntries
+
+ return writeIndex(indexPath, &sessionIndex)
+}
+
+func addToIndex(projectDirectory string, session *Session) error {
+ indexPath := filepath.Join(projectDirectory, "sessions-index.json")
+
+ var sessionIndex SessionIndex
+
+ fileData, readError := os.ReadFile(indexPath)
+
+ if readError != nil {
+ if os.IsNotExist(readError) {
+ sessionIndex = SessionIndex{
+ Version: 1,
+ Entries: []Session{},
+ OriginalPath: session.ProjectPath,
+ }
+ } else {
+ return readError
+ }
+ } else {
+ if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil {
+ return unmarshalError
+ }
+ }
+
+ found := false
+
+ for entryIndex := range sessionIndex.Entries {
+ if sessionIndex.Entries[entryIndex].SessionID == session.SessionID {
+ sessionIndex.Entries[entryIndex] = *session
+ found = true
+
+ break
+ }
+ }
+
+ if !found {
+ sessionIndex.Entries = append(sessionIndex.Entries, *session)
+ }
+
+ return writeIndex(indexPath, &sessionIndex)
+}
+
+func writeIndex(indexPath string, sessionIndex *SessionIndex) error {
+ jsonData, marshalError := json.MarshalIndent(sessionIndex, "", " ")
+
+ if marshalError != nil {
+ return marshalError
+ }
+
+ return os.WriteFile(indexPath, jsonData, 0o644)
+}
diff --git a/internal/ui/keys.go b/internal/ui/keys.go
new file mode 100644
index 0000000..81692ca
--- /dev/null
+++ b/internal/ui/keys.go
@@ -0,0 +1,126 @@
+package ui
+
+import "github.com/charmbracelet/bubbles/key"
+
+type KeyMap struct {
+ Up key.Binding
+ Down key.Binding
+ Left key.Binding
+ Right key.Binding
+ Enter key.Binding
+ Delete key.Binding
+ Restore key.Binding
+ Rename key.Binding
+ Search key.Binding
+ DeepSearch key.Binding
+ NextMatch key.Binding
+ PrevMatch key.Binding
+ Tab key.Binding
+ Clear key.Binding
+ Quit key.Binding
+ Help key.Binding
+ Escape key.Binding
+ Confirm key.Binding
+ HalfUp key.Binding
+ HalfDown key.Binding
+ Top key.Binding
+ Bottom key.Binding
+ Preview key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+ return KeyMap{
+ Up: key.NewBinding(
+ key.WithKeys("k", "up"),
+ key.WithHelp("k", "move up"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("j", "down"),
+ key.WithHelp("j", "move down"),
+ ),
+ Left: key.NewBinding(
+ key.WithKeys("h", "left"),
+ key.WithHelp("h", "previous tab"),
+ ),
+ Right: key.NewBinding(
+ key.WithKeys("l", "right"),
+ key.WithHelp("l", "next tab"),
+ ),
+ HalfUp: key.NewBinding(
+ key.WithKeys("ctrl+u"),
+ key.WithHelp("ctrl+u", "page up"),
+ ),
+ HalfDown: key.NewBinding(
+ key.WithKeys("ctrl+d"),
+ key.WithHelp("ctrl+d", "page down"),
+ ),
+ Top: key.NewBinding(
+ key.WithKeys("g", "home"),
+ key.WithHelp("gg", "jump to top"),
+ ),
+ Bottom: key.NewBinding(
+ key.WithKeys("G", "end"),
+ key.WithHelp("G", "jump to bottom"),
+ ),
+ Enter: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("return", "select"),
+ ),
+ Delete: key.NewBinding(
+ key.WithKeys("d", "x"),
+ key.WithHelp("d", "move to bin"),
+ ),
+ Restore: key.NewBinding(
+ key.WithKeys("u"),
+ key.WithHelp("u", "restore"),
+ ),
+ Rename: key.NewBinding(
+ key.WithKeys("c"),
+ key.WithHelp("c", "rename"),
+ ),
+ Search: key.NewBinding(
+ key.WithKeys("/"),
+ key.WithHelp("/", "filter"),
+ ),
+ DeepSearch: key.NewBinding(
+ key.WithKeys("s"),
+ key.WithHelp("s", "search"),
+ ),
+ NextMatch: key.NewBinding(
+ key.WithKeys("n"),
+ key.WithHelp("n", "next match"),
+ ),
+ PrevMatch: key.NewBinding(
+ key.WithKeys("N"),
+ key.WithHelp("N", "previous match"),
+ ),
+ Tab: key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "switch focus"),
+ ),
+ Clear: key.NewBinding(
+ key.WithKeys("D"),
+ key.WithHelp("D", "empty bin"),
+ ),
+ Quit: key.NewBinding(
+ key.WithKeys("q", "ctrl+c"),
+ key.WithHelp("q", "quit"),
+ ),
+ Help: key.NewBinding(
+ key.WithKeys("?"),
+ key.WithHelp("?", "help"),
+ ),
+ Escape: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
+ Confirm: key.NewBinding(
+ key.WithKeys("y", "Y"),
+ key.WithHelp("y", "confirm"),
+ ),
+ Preview: key.NewBinding(
+ key.WithKeys("p"),
+ key.WithHelp("p", "toggle preview"),
+ ),
+ }
+}
diff --git a/internal/ui/styles.go b/internal/ui/styles.go
new file mode 100644
index 0000000..e615f6d
--- /dev/null
+++ b/internal/ui/styles.go
@@ -0,0 +1,159 @@
+package ui
+
+import "github.com/charmbracelet/lipgloss"
+
+var (
+ Primary = lipgloss.Color("#6B50FF")
+ Secondary = lipgloss.Color("#FF60FF")
+ Tertiary = lipgloss.Color("#68FFD6")
+ Accent = lipgloss.Color("#E8FE96")
+ BgBase = lipgloss.Color("#201F26")
+ BgLighter = lipgloss.Color("#2D2C35")
+ BgSubtle = lipgloss.Color("#3A3943")
+ BgOverlay = lipgloss.Color("#4D4C57")
+ FgBase = lipgloss.Color("#DFDBDD")
+ FgMuted = lipgloss.Color("#858392")
+ FgHalfMute = lipgloss.Color("#BFBCC8")
+ FgSubtle = lipgloss.Color("#605F6B")
+ FgBright = lipgloss.Color("#F1EFEF")
+ Success = lipgloss.Color("#00FFB2")
+ Error = lipgloss.Color("#EB4268")
+ Warning = lipgloss.Color("#E8FE96")
+ Info = lipgloss.Color("#00A4FF")
+ Blue = lipgloss.Color("#00A4FF")
+ Green = lipgloss.Color("#00FFB2")
+ GreenDark = lipgloss.Color("#12C78F")
+ Red = lipgloss.Color("#FF577D")
+ RedDark = lipgloss.Color("#EB4268")
+ Yellow = lipgloss.Color("#E8FE96")
+ Orange = lipgloss.Color("#FF985A")
+ Purple = lipgloss.Color("#8B75FF")
+ Cyan = lipgloss.Color("#0ADCD9")
+ Pink = lipgloss.Color("#FF60FF")
+ BaseStyle = lipgloss.NewStyle().
+ Foreground(FgBase)
+ MutedStyle = lipgloss.NewStyle().
+ Foreground(FgMuted)
+ SubtleStyle = lipgloss.NewStyle().
+ Foreground(FgSubtle)
+ HeaderStyle = lipgloss.NewStyle().
+ Foreground(Primary).
+ Bold(true)
+ LogoStyle = lipgloss.NewStyle().
+ Foreground(Secondary).
+ Bold(true)
+ TabStyle = lipgloss.NewStyle().
+ Foreground(FgMuted).
+ Padding(0, 2)
+ ActiveTabStyle = lipgloss.NewStyle().
+ Foreground(Primary).
+ Bold(true).
+ Padding(0, 2)
+ ItemStyle = lipgloss.NewStyle().
+ Foreground(FgBase).
+ PaddingLeft(2)
+ SelectedItemStyle = lipgloss.NewStyle().
+ Foreground(FgBright).
+ Background(BgSubtle).
+ Bold(true).
+ PaddingLeft(1).
+ PaddingRight(1)
+ CursorStyle = lipgloss.NewStyle().
+ Foreground(Tertiary).
+ Bold(true)
+ TitleStyle = lipgloss.NewStyle().
+ Foreground(FgBase).
+ Bold(true)
+ SummaryStyle = lipgloss.NewStyle().
+ Foreground(FgHalfMute).
+ Italic(true)
+ MetaStyle = lipgloss.NewStyle().
+ Foreground(FgSubtle)
+ ProjectStyle = lipgloss.NewStyle().
+ Foreground(Tertiary)
+ TrashStyle = lipgloss.NewStyle().
+ Foreground(Error).
+ Bold(true)
+ ActiveStyle = lipgloss.NewStyle().
+ Foreground(Success)
+ SearchStyle = lipgloss.NewStyle().
+ Foreground(Accent).
+ Bold(true)
+ SearchInputStyle = lipgloss.NewStyle().
+ BorderStyle(lipgloss.RoundedBorder()).
+ BorderForeground(Primary).
+ Padding(0, 1)
+ HelpStyle = lipgloss.NewStyle().
+ Foreground(FgSubtle)
+ HelpKeyStyle = lipgloss.NewStyle().
+ Foreground(FgMuted)
+ ModalStyle = lipgloss.NewStyle().
+ BorderStyle(lipgloss.RoundedBorder()).
+ BorderForeground(Primary).
+ Background(BgLighter).
+ Padding(1, 2)
+ ConfirmStyle = lipgloss.NewStyle().
+ Foreground(Warning).
+ Bold(true)
+ StatusBarStyle = lipgloss.NewStyle().
+ Foreground(FgBase).
+ Background(BgSubtle).
+ Padding(0, 1)
+ CountStyle = lipgloss.NewStyle().
+ Foreground(Tertiary).
+ Bold(true)
+ PreviewStyle = lipgloss.NewStyle().
+ BorderStyle(lipgloss.RoundedBorder()).
+ BorderForeground(FgSubtle).
+ Padding(0, 1)
+ PreviewFocusedStyle = lipgloss.NewStyle().
+ BorderStyle(lipgloss.RoundedBorder()).
+ BorderForeground(Primary).
+ Padding(0, 1)
+ ListBoxStyle = lipgloss.NewStyle().
+ BorderStyle(lipgloss.RoundedBorder()).
+ BorderForeground(FgSubtle).
+ Padding(0, 1)
+ ListBoxFocusedStyle = lipgloss.NewStyle().
+ BorderStyle(lipgloss.RoundedBorder()).
+ BorderForeground(Primary).
+ Padding(0, 1)
+ PreviewHeaderStyle = lipgloss.NewStyle().
+ Foreground(Primary).
+ Bold(true)
+ PreviewDividerStyle = lipgloss.NewStyle().
+ Foreground(FgSubtle)
+ UserRoleStyle = lipgloss.NewStyle().
+ Foreground(Blue).
+ Bold(true)
+ UserContentStyle = lipgloss.NewStyle().
+ Foreground(FgBase)
+ AssistantRoleStyle = lipgloss.NewStyle().
+ Foreground(GreenDark).
+ Bold(true)
+ AssistantContentStyle = lipgloss.NewStyle().
+ Foreground(FgHalfMute)
+ ToolRoleStyle = lipgloss.NewStyle().
+ Foreground(Orange).
+ Bold(true)
+ ToolContentStyle = lipgloss.NewStyle().
+ Foreground(FgMuted).
+ Italic(true)
+ ThinkingRoleStyle = lipgloss.NewStyle().
+ Foreground(Purple).
+ Bold(true)
+ ThinkingContentStyle = lipgloss.NewStyle().
+ Foreground(FgSubtle).
+ Italic(true)
+ HighlightStyle = lipgloss.NewStyle().
+ Background(Accent).
+ Foreground(BgBase).
+ Bold(true)
+ SearchResultStyle = lipgloss.NewStyle().
+ Foreground(FgBase)
+ SearchMatchStyle = lipgloss.NewStyle().
+ Foreground(Accent).
+ Bold(true)
+ SearchContextStyle = lipgloss.NewStyle().
+ Foreground(FgHalfMute)
+)