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 | |
| download | faustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.tar.xz faustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.zip | |
feat: Initial commit
Diffstat (limited to 'internal')
| -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 | ||||
| -rw-r--r-- | internal/claude/preview.go | 177 | ||||
| -rw-r--r-- | internal/claude/search.go | 180 | ||||
| -rw-r--r-- | internal/claude/session.go | 360 | ||||
| -rw-r--r-- | internal/ui/keys.go | 126 | ||||
| -rw-r--r-- | internal/ui/styles.go | 159 |
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) +) |