diff options
| author | Fuwn <[email protected]> | 2026-01-30 07:32:54 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-30 07:32:54 +0000 |
| commit | 5f3eba126201e4d679539aa2517bf6a132f29cd0 (patch) | |
| tree | 961afe2ae1d6ca0f23bdbb30930e37bc88884146 /internal/app | |
| download | faustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.tar.xz faustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.zip | |
feat: Initial commit
Diffstat (limited to 'internal/app')
| -rw-r--r-- | internal/app/helpers.go | 135 | ||||
| -rw-r--r-- | internal/app/model.go | 104 | ||||
| -rw-r--r-- | internal/app/preview.go | 110 | ||||
| -rw-r--r-- | internal/app/search.go | 96 | ||||
| -rw-r--r-- | internal/app/state.go | 114 | ||||
| -rw-r--r-- | internal/app/update.go | 445 | ||||
| -rw-r--r-- | internal/app/view.go | 487 |
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() +} |