aboutsummaryrefslogtreecommitdiff
path: root/internal/app
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/app
downloadfaustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.tar.xz
faustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.zip
feat: Initial commit
Diffstat (limited to 'internal/app')
-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
7 files changed, 1491 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()
+}