Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ docs/plans
docs/reviews
docs/superpowers
tmp/
.superpowers/
4 changes: 2 additions & 2 deletions cmd/entire/cli/search_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ branch:<name>, repo:<owner/name>, and repo:* to search all accessible repos.`,
model := newSearchModel(nil, "", 0, searchCfg, styles)
model.mode = modeSearch
model.input.Focus()
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("TUI error: %w", err)
}
Expand Down Expand Up @@ -174,7 +174,7 @@ branch:<name>, repo:<owner/name>, and repo:* to search all accessible repos.`,

// Interactive TUI
model := newSearchModel(resp.Results, query, resp.Total, searchCfg, styles)
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("TUI error: %w", err)
}
Expand Down
126 changes: 67 additions & 59 deletions cmd/entire/cli/search_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ type searchStyles struct {
selected lipgloss.Style // highlighted selected row
helpKey lipgloss.Style // colored key hints in footer
helpSep lipgloss.Style // dim separator dots in footer
detailTitle lipgloss.Style // colored title inside detail card
detailTitle lipgloss.Style // colored title and section headers (orange, bold)
detailBorder lipgloss.Style // border style for detail card
}

Expand Down Expand Up @@ -216,19 +216,6 @@ func (m searchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:ireturn
m = m.refreshBrowseContent()
return m, nil

case tea.MouseMsg:
if m.mode == modeBrowse {
var cmd tea.Cmd
m.browseVP, cmd = m.browseVP.Update(msg)
return m, cmd
}
if m.mode == modeDetail {
var cmd tea.Cmd
m.detailVP, cmd = m.detailVP.Update(msg)
return m, cmd
}
return m, nil

case tea.KeyMsg:
switch m.mode {
case modeSearch:
Expand Down Expand Up @@ -322,7 +309,7 @@ func (m searchModel) updateBrowseMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //n
case "enter":
if r := m.selectedResult(); r != nil {
m.mode = modeDetail
content := m.renderDetailContent(*r)
content := m.renderDetailContent(*r, m.width, true)
m.detailVP = viewport.New(m.width, max(m.height-2, 1))
m.detailVP.SetContent(content)
return m, nil
Expand Down Expand Up @@ -524,18 +511,18 @@ func (m searchModel) viewRow(r search.Result, cols columnLayout) string {
}

// renderDetailContent builds the text content for a checkpoint detail (no border/card chrome).
func (m searchModel) renderDetailContent(r search.Result) string {
func (m searchModel) renderDetailContent(r search.Result, contentWidth int, showSections bool) string {
const labelWidth = 12
// Available width for field values: total width minus border/padding chrome minus label.
valueWidth := m.width - 8 - labelWidth - 1 // 8 for border+padding, 1 for space after label
// Available width for field values: content width minus label minus space.
valueWidth := contentWidth - labelWidth - 1
if valueWidth < 20 {
valueWidth = 0 // disable wrapping on very narrow terminals
}

var content strings.Builder

content.WriteString(m.styles.render(m.styles.detailTitle, "Checkpoint Detail"))
content.WriteString("\n\n")
content.WriteString("\n")

formatLabel := func(label string) string {
return m.styles.render(m.styles.label, fmt.Sprintf("%-*s", labelWidth, label+":"))
Expand All @@ -560,56 +547,103 @@ func (m searchModel) renderDetailContent(r search.Result) string {
}
}

writeSection := func(title string) {
if showSections {
content.WriteString("\n" + m.styles.render(m.styles.detailTitle, title) + "\n")
} else {
content.WriteString("\n")
}
}

// ── OVERVIEW ──
writeSection("OVERVIEW")
writeField("ID", r.Data.ID)
writeWrappedField("Prompt", r.Data.Prompt)
writeWrappedField("Prompt", stringutil.CollapseWhitespace(r.Data.Prompt))
matchType := r.Meta.MatchType
if r.Meta.Score > 0 {
matchType += " " + m.styles.render(m.styles.dim, fmt.Sprintf("(score: %.3f)", r.Meta.Score))
}
writeField("Match", matchType)

// ── SOURCE ──
writeSection("SOURCE")
writeWrappedField("Commit", formatCommit(r.Data.CommitSHA, r.Data.CommitMessage))
writeField("Branch", r.Data.Branch)
writeField("Repo", r.Data.Org+"/"+r.Data.Repo)
writeField("Author", formatAuthor(r.Data.Author, r.Data.AuthorUsername))
writeField("Created", formatCreatedAt(r.Data.CreatedAt))
writeField("Match", formatMatch(r.Meta))
authorStr := r.Data.Author
if r.Data.AuthorUsername != nil && *r.Data.AuthorUsername != "" {
authorStr = *r.Data.AuthorUsername + " " + m.styles.render(m.styles.dim, "("+r.Data.Author+")")
}
writeField("Author", authorStr)
createdStr := formatDetailCreatedAt(r.Data.CreatedAt, m.styles)
writeField("Created", createdStr)

// ── SNIPPET ──
if r.Meta.Snippet != "" {
content.WriteString("\n")
content.WriteString(m.styles.render(m.styles.label, "Snippet:") + "\n")
writeSection("SNIPPET")
if valueWidth > 0 {
content.WriteString(wrapText(r.Meta.Snippet, m.width-8) + "\n")
content.WriteString(wrapText(r.Meta.Snippet, contentWidth) + "\n")
} else {
content.WriteString(r.Meta.Snippet + "\n")
}
}

// ── FILES ──
if len(r.Data.FilesTouched) > 0 {
content.WriteString("\n")
content.WriteString(m.styles.render(m.styles.label, "Files:") + "\n")
if showSections {
content.WriteString(m.styles.render(m.styles.detailTitle, "FILES") + "\n")
} else {
content.WriteString(m.styles.render(m.styles.label, "Files:") + "\n")
}
for _, f := range r.Data.FilesTouched {
content.WriteString(f + "\n")
content.WriteString(" " + f + "\n")
}
}

return strings.TrimRight(content.String(), "\n")
}

// formatDetailCreatedAt renders date (default) + relative time (dim) for the detail view.
func formatDetailCreatedAt(createdAt string, styles searchStyles) string {
t, err := time.Parse(time.RFC3339, createdAt)
if err != nil {
return createdAt
}
return t.Format("Jan 02, 2006") + " " + styles.render(styles.dim, "("+timeAgo(t)+")")
}

// maxCardContentLines is the maximum number of content lines shown in the
// inline detail card. Longer content is truncated with a "enter for more" hint.
// The full content is always available via the detail view (enter key).
const maxCardContentLines = 15

func (m searchModel) viewDetailCard(r search.Result) string {
innerWidth := m.width - 8 // border + padding eats ~6-8 chars
cardContent := m.renderDetailContent(r)
var contentWidth int
var borderWidth int
if m.styles.colorEnabled {
// lipgloss .Width(W) includes padding but excludes border:
// text wraps at W - padding(4), rendered = W + border(2), + indent(1) = W + 3
borderWidth = max(m.width-3, 0)
contentWidth = max(borderWidth-4, 0)
} else {
// No border/padding in NO_COLOR mode, only indent(1)
contentWidth = max(m.width-1, 0)
}
cardContent := m.renderDetailContent(r, contentWidth, false)

lines := strings.Split(cardContent, "\n")
if len(lines) > maxCardContentLines {
lines = lines[:maxCardContentLines]
hint := m.styles.render(m.styles.dim, "▼ enter for more")
lines = append(lines, "", strings.Repeat(" ", max(innerWidth-lipgloss.Width(hint), 0))+hint)
hintWidth := lipgloss.Width(hint)
lines = append(lines, "", strings.Repeat(" ", max(contentWidth-hintWidth, 0))+hint)
cardContent = strings.Join(lines, "\n")
}

card := cardContent
if m.styles.colorEnabled {
card = m.styles.detailBorder.Width(max(innerWidth, 40)).Render(cardContent)
card = m.styles.detailBorder.Width(borderWidth).Render(cardContent)
}

return indentLines(card, " ")
Expand Down Expand Up @@ -747,7 +781,7 @@ func computeColumns(width int) columnLayout {
}

branchWidth := max(remaining*18/100, 8)
repoWidth := max(remaining*31/100, repoMin)
repoWidth := max(remaining*18/100, repoMin)
promptWidth := remaining - branchWidth - repoWidth
if promptWidth < 12 {
reclaim := 12 - promptWidth
Expand Down Expand Up @@ -789,32 +823,6 @@ func formatCommit(sha, message *string) string {
return s
}

// formatAuthor renders username with display name, e.g. "dipree (Daniel Adams)".
func formatAuthor(author string, username *string) string {
if username != nil && *username != "" {
return *username + " (" + author + ")"
}
return author
}

// formatCreatedAt renders a timestamp with relative time.
func formatCreatedAt(createdAt string) string {
t, err := time.Parse(time.RFC3339, createdAt)
if err != nil {
return createdAt
}
return t.Format("Jan 02, 2006") + " (" + timeAgo(t) + ")"
}

// formatMatch renders match type and score.
func formatMatch(meta search.Meta) string {
s := meta.MatchType
if meta.Score > 0 {
s += fmt.Sprintf(" (score: %.3f)", meta.Score)
}
return s
}

// derefStr returns the dereferenced string pointer, or fallback if nil.
func derefStr(s *string, fallback string) string {
if s == nil {
Expand Down
67 changes: 58 additions & 9 deletions cmd/entire/cli/search_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,16 +406,65 @@ func TestFormatCommit(t *testing.T) {
}
}

func TestFormatAuthor(t *testing.T) {
func TestRenderDetailContent_Sections(t *testing.T) {
t.Parallel()
m := testModel()
r := testResults()[0]

withSections := m.renderDetailContent(r, 80, true)
if !strings.Contains(withSections, "OVERVIEW") {
t.Error("showSections=true should contain OVERVIEW header")
}
if !strings.Contains(withSections, "SOURCE") {
t.Error("showSections=true should contain SOURCE header")
}
if !strings.Contains(withSections, "FILES") {
t.Error("showSections=true should contain FILES header")
}

withoutSections := m.renderDetailContent(r, 80, false)
if strings.Contains(withoutSections, "OVERVIEW") {
t.Error("showSections=false should not contain OVERVIEW header")
}
if strings.Contains(withoutSections, "SOURCE") {
t.Error("showSections=false should not contain SOURCE header")
}
if !strings.Contains(withoutSections, "Files:") {
t.Error("showSections=false should contain Files: label")
}
}

username := "alicecodes"
if got := formatAuthor("alice", &username); got != "alicecodes (alice)" {
t.Errorf("formatAuthor = %q, want %q", got, "alicecodes (alice)")
func TestRenderDetailContent_AuthorEmptyUsername(t *testing.T) {
t.Parallel()
m := testModel()
r := testResults()[1] // bob, no AuthorUsername
content := m.renderDetailContent(r, 80, false)
if !strings.Contains(content, "bob") {
t.Error("author should show display name when username is nil")
}

if got := formatAuthor("bob", nil); got != "bob" {
t.Errorf("formatAuthor(nil username) = %q, want %q", got, "bob")
// Empty string username should fall back to display name
empty := ""
r.Data.AuthorUsername = &empty
content = m.renderDetailContent(r, 80, false)
if !strings.Contains(content, "bob") {
t.Error("author should show display name when username is empty string")
}
}

func TestRenderDetailContent_PromptWrapping(t *testing.T) {
t.Parallel()
m := testModel()
r := testResults()[0]
r.Data.Prompt = "line one\nline two\nline three"

content := m.renderDetailContent(r, 80, false)
// CollapseWhitespace should merge the newlines into spaces
if strings.Contains(content, "line one\n") {
t.Error("prompt should have newlines collapsed")
}
if !strings.Contains(content, "line one line two line three") {
t.Error("prompt should be collapsed to single line")
}
}

Expand All @@ -433,7 +482,7 @@ func TestRenderSearchStatic(t *testing.T) {
if !strings.Contains(output, "REPO") {
t.Error("static output missing repo header")
}
if !strings.Contains(output, "entirehq/entire.io") {
if !strings.Contains(output, "entire") {
t.Error("static output missing repo value")
}
if !strings.Contains(output, "a3b2c4d5e6") {
Expand Down Expand Up @@ -952,8 +1001,8 @@ func TestComputeColumns(t *testing.T) {
if cols.id != 12 {
t.Errorf("id width = %d, want 12", cols.id)
}
if cols.repo != 18 {
t.Errorf("repo width = %d, want 18", cols.repo)
if cols.repo < 10 {
t.Errorf("repo width = %d, want >= 10", cols.repo)
}
if cols.author != 14 {
t.Errorf("author width = %d, want 14", cols.author)
Expand Down
Loading