diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0840f..40fb5e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,4 +13,5 @@ - Fix shared `tui` pane-specific header sorting, scope sorting, and stable detail metadata labels across crawl apps. - Render shared `tui` parent/member panes with gitcrawl-style table columns, row styling, pane-local header sorting, and a 24-line minimum layout. - Use a gitcrawl-style viewport for `tui` detail panes so long threads and document previews scroll cleanly inside the focused pane. +- Render `tui` detail content with gitcrawl-style sections, rules, markdown-ish wrapping, and pane-width-aware chat/document previews. - Rename the public package nouns to `config`, `store`, `snapshot`, `mirror`, `state`, `output`, `tui`, and `cache`. diff --git a/tui/tui.go b/tui/tui.go index 172e88b..7b90172 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "regexp" "sort" "strings" "time" @@ -21,6 +22,13 @@ import ( var ErrNotTerminal = errors.New("terminal UI requires an interactive terminal") +var ( + markdownHeadingRE = regexp.MustCompile(`^(#{1,6})\s+(.+)$`) + markdownLinkRE = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`) + markdownListRE = regexp.MustCompile(`^(\s*)([-*+]|\d+[.)])\s+(.+)$`) + terminalControlRE = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`) +) + const ( wheelScrollDelay = 16 * time.Millisecond wheelMaxBufferedDelta = 6 @@ -1093,7 +1101,7 @@ func (m model) renderDetailPane(rect rect) string { if !ok { return pane("Detail", "", []string{"No row selected."}, rect, focusDetail, m.focus, detailPaneAccent) } - lines := m.detailLines(item) + lines := m.detailLinesForWidth(item, paneContentWidth(rect.w)) return m.renderDetailViewport(rect, lines) } @@ -1107,7 +1115,8 @@ func (m *model) syncDetailViewport() { if !ok { return } - m.configureDetailViewport(m.layout().detail, m.detailLines(item)) + rect := m.layout().detail + m.configureDetailViewport(rect, m.detailLinesForWidth(item, paneContentWidth(rect.w))) } func (m *model) configureDetailViewport(rect rect, lines []string) { @@ -2238,7 +2247,7 @@ func (m model) memberTableRows(columns []tableColumn, members []int) []tableRow case "kind": row = append(row, rowKind(item)) case "time": - row = append(row, rowWhen(item)) + row = append(row, rowTimeForColumn(item, column.Width)) case "age": row = append(row, rowAge(item)) case "container": @@ -2300,17 +2309,17 @@ func memberColumns(width int, active sortMode) []tableColumn { whenW := 5 titleW := maxInt(1, width-whenW-1) return []tableColumn{ - {Key: "time", Title: activeTimeLabel("date", active), Width: whenW}, + {Key: "time", Title: activeTimeLabel("time", active), Width: whenW}, {Key: "title", Title: activeLabel("title", active == sortTitle), Width: titleW}, } } - if width < 68 { + if width < 54 { whenW := 5 ageW := 4 authorW := minInt(maxInt(5, width/6), 9) titleW := maxInt(1, width-whenW-ageW-authorW-3) return []tableColumn{ - {Key: "time", Title: activeTimeLabel("date", active), Width: whenW}, + {Key: "time", Title: activeTimeLabel("time", active), Width: whenW}, {Key: "age", Title: activeTimeLabel("age", active), Width: ageW}, {Key: "author", Title: activeLabel("who", active == sortAuthor), Width: authorW}, {Key: "title", Title: activeLabel("title", active == sortTitle), Width: titleW}, @@ -2442,29 +2451,38 @@ func contextLines(item Item, width int) []string { } func (m model) detailLines(item Item) []string { + return m.detailLinesForWidth(item, 1000) +} + +func (m model) detailLinesForWidth(item Item, width int) []string { + width = maxInt(20, width) switch m.layoutPreset { case LayoutChat: - return m.chatDetailLines(item) + return m.chatDetailLines(item, width) case LayoutDocument: - return documentDetailLines(item) + return documentDetailLinesForWidth(item, width) } - return genericDetailLines(item) + return genericDetailLinesForWidth(item, width) } func genericDetailLines(item Item) []string { + return genericDetailLinesForWidth(item, 1000) +} + +func genericDetailLinesForWidth(item Item, width int) []string { detail := strings.TrimSpace(item.Detail) var lines []string context := detailContextLines(item, true) if len(context) > 0 { - lines = append(lines, "Context") + lines = append(lines, bold("Context")) lines = append(lines, context...) } if detail == "" { detail = item.Subtitle } if detail != "" { - lines = append(lines, "", "Content") - lines = append(lines, wrapLines(detail, 1000)...) + lines = append(lines, "", dim(tuiRule(width)), bold("Content")) + lines = append(lines, markdownLines(detail, width)...) } if len(lines) == 0 { lines = append(lines, "", "No detail for this row.") @@ -2472,27 +2490,27 @@ func genericDetailLines(item Item) []string { return lines } -func (m model) chatDetailLines(item Item) []string { +func (m model) chatDetailLines(item Item, width int) []string { var lines []string if header := chatHeaderLine(item); header != "" { - lines = append(lines, header) + lines = append(lines, bold(header)) } if meta := chatMetaLine(item); meta != "" { lines = append(lines, dim(meta)) } - if thread := m.threadLines(item); len(thread) > 0 { - lines = append(lines, "", "Thread") + if thread := m.threadLines(item, width); len(thread) > 0 { + lines = append(lines, "", dim(tuiRule(width)), bold("Thread")) lines = append(lines, thread...) } else if message := strings.TrimSpace(firstNonEmpty(item.Text, item.Detail, item.Title)); message != "" { - lines = append(lines, "", "Message") - lines = append(lines, chatBubbleLines(item, message, true)...) + lines = append(lines, "", dim(tuiRule(width)), bold("Message")) + lines = append(lines, chatBubbleLines(item, message, true, width)...) } if properties := chatPropertyLines(item); len(properties) > 0 { - lines = append(lines, "", "Properties") + lines = append(lines, "", dim(tuiRule(width)), bold("Properties")) lines = append(lines, properties...) } if ids := chatIDLines(item); len(ids) > 0 { - lines = append(lines, "", "IDs") + lines = append(lines, "", dim(tuiRule(width)), bold("IDs")) lines = append(lines, ids...) } if len(lines) == 0 { @@ -2502,23 +2520,27 @@ func (m model) chatDetailLines(item Item) []string { } func documentDetailLines(item Item) []string { + return documentDetailLinesForWidth(item, 1000) +} + +func documentDetailLinesForWidth(item Item, width int) []string { var lines []string title := firstNonEmpty(item.Title, item.ID, "Untitled") - lines = append(lines, title) + lines = append(lines, bold(title)) if meta := documentMetaLine(item); meta != "" { lines = append(lines, dim(meta)) } if location := documentLocationLines(item); len(location) > 0 { - lines = append(lines, "", "Location") + lines = append(lines, "", dim(tuiRule(width)), bold("Location")) lines = append(lines, location...) } preview := documentPreview(item) if preview != "" { - lines = append(lines, "", "Preview") - lines = append(lines, wrapLines(preview, 1000)...) + lines = append(lines, "", dim(tuiRule(width)), bold("Preview")) + lines = append(lines, markdownLines(preview, width)...) } if metadata := documentPropertyLines(item); len(metadata) > 0 { - lines = append(lines, "", "Properties") + lines = append(lines, "", dim(tuiRule(width)), bold("Properties")) lines = append(lines, metadata...) } if len(lines) == 0 { @@ -2614,6 +2636,20 @@ func indentWrappedLines(value string, indent, width int) []string { return out } +func indentMarkdownLines(value string, indent, width int) []string { + prefix := strings.Repeat(" ", maxInt(0, indent)) + raw := markdownLines(value, maxInt(8, width-indent)) + out := make([]string, 0, len(raw)) + for _, line := range raw { + if line == "" { + out = append(out, "") + continue + } + out = append(out, prefix+line) + } + return out +} + func detailContextLines(item Item, includeTitle bool) []string { var lines []string fields := []string{ @@ -2654,7 +2690,7 @@ func detailContextLines(item Item, includeTitle bool) []string { return lines } -func (m model) threadLines(selected Item) []string { +func (m model) threadLines(selected Item, width int) []string { key := threadKey(selected) if key == "" { return nil @@ -2669,7 +2705,7 @@ func (m model) threadLines(selected Item) []string { continue } text := firstNonEmpty(item.Text, item.Detail, item.Title) - lines = append(lines, chatBubbleLines(item, text, item.ID == selected.ID)...) + lines = append(lines, chatBubbleLines(item, text, item.ID == selected.ID, width)...) } if len(lines) <= 1 { return nil @@ -2677,7 +2713,7 @@ func (m model) threadLines(selected Item) []string { return lines } -func chatBubbleLines(item Item, text string, selected bool) []string { +func chatBubbleLines(item Item, text string, selected bool, width int) []string { var lines []string prefix := " " if selected { @@ -2687,7 +2723,7 @@ func chatBubbleLines(item Item, text string, selected bool) []string { if header != "" { lines = append(lines, prefix+header) } - body := indentWrappedLines(text, lipgloss.Width(prefix)+2, 1000) + body := indentMarkdownLines(text, lipgloss.Width(prefix)+2, width) if len(body) == 0 { body = []string{strings.Repeat(" ", lipgloss.Width(prefix)+2) + "(empty)"} } @@ -2970,9 +3006,9 @@ func compactRowListHeader(width int, active sortMode) string { if width < 34 { whenW := 5 titleW := maxInt(1, width-whenW-1) - return padCells(truncateCells("DATE", whenW), whenW) + " " + truncateCells("TITLE", titleW) + return padCells(truncateCells("TIME", whenW), whenW) + " " + truncateCells("TITLE", titleW) } - timeLabel := "DATE" + timeLabel := "TIME" age := "AGE" author := "WHO" title := "TITLE" @@ -3068,6 +3104,13 @@ func rowWhen(item Item) string { return "" } +func rowTimeForColumn(item Item, width int) string { + if width <= 5 { + return compactDate(item) + } + return rowWhen(item) +} + func rowAge(item Item) string { if t, ok := itemSortTime(item); ok { return compactAge(time.Since(t)) @@ -3245,6 +3288,104 @@ func dim(value string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color(archiveMutedFG)).Render(value) } +func tuiRule(width int) string { + return strings.Repeat("-", minInt(72, maxInt(12, width))) +} + +func markdownLines(value string, width int) []string { + if strings.TrimSpace(value) == "" { + return nil + } + width = maxInt(20, width) + var lines []string + inFence := false + blankRun := 0 + for _, rawLine := range strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n") { + line := strings.TrimRight(stripTerminalControls(rawLine), " \t") + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") { + inFence = !inFence + lines = append(lines, dim("--- code ---")) + blankRun = 0 + continue + } + if inFence { + lines = append(lines, dim(truncateCells(line, width))) + blankRun = 0 + continue + } + if trimmed == "" { + blankRun++ + if blankRun <= 1 { + lines = append(lines, "") + } + continue + } + blankRun = 0 + if match := markdownHeadingRE.FindStringSubmatch(trimmed); match != nil { + lines = appendWrappedStyled(lines, "", renderInlineMarkdown(match[2]), width, bold) + continue + } + if strings.HasPrefix(trimmed, ">") { + quote := strings.TrimSpace(strings.TrimPrefix(trimmed, ">")) + lines = appendWrappedStyled(lines, "> ", renderInlineMarkdown(quote), width, dim) + continue + } + if match := markdownListRE.FindStringSubmatch(line); match != nil { + indent := match[1] + if lipgloss.Width(indent) > 4 { + indent = strings.Repeat(" ", 4) + } + lines = appendWrappedStyled(lines, indent+"- ", renderInlineMarkdown(match[3]), width, nil) + continue + } + lines = appendWrappedStyled(lines, "", renderInlineMarkdown(line), width, nil) + } + return trimTrailingBlankLines(lines) +} + +func appendWrappedStyled(lines []string, prefix, value string, width int, styler func(string) string) []string { + contentWidth := maxInt(8, width-lipgloss.Width(prefix)) + wrapped := wrapPlain(value, contentWidth) + if len(wrapped) == 0 { + return lines + } + continuation := strings.Repeat(" ", lipgloss.Width(prefix)) + for index, line := range wrapped { + prefixForLine := prefix + if index > 0 { + prefixForLine = continuation + } + if styler != nil { + line = styler(line) + } + lines = append(lines, prefixForLine+line) + } + return lines +} + +func renderInlineMarkdown(value string) string { + value = markdownLinkRE.ReplaceAllString(value, "$1 <$2>") + replacer := strings.NewReplacer( + "`", "", + "**", "", + "__", "", + "~~", "", + ) + return strings.TrimSpace(replacer.Replace(value)) +} + +func stripTerminalControls(value string) string { + return terminalControlRE.ReplaceAllString(value, "") +} + +func trimTrailingBlankLines(lines []string) []string { + for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + return lines +} + func mutedStyle(width int) lipgloss.Style { return lipgloss.NewStyle(). Foreground(lipgloss.Color(archiveMutedFG)). @@ -3372,6 +3513,40 @@ func wrap(value string, width int) string { return b.String() } +func wrapPlain(value string, width int) []string { + width = maxInt(20, width) + words := strings.Fields(value) + if len(words) == 0 { + return []string{""} + } + var lines []string + var line string + for _, word := range words { + if lipgloss.Width(word) > width { + if line != "" { + lines = append(lines, line) + line = "" + } + lines = append(lines, truncateCells(word, width)) + continue + } + if lipgloss.Width(line)+1+lipgloss.Width(word) > width && line != "" { + lines = append(lines, line) + line = word + continue + } + if line == "" { + line = word + } else { + line += " " + word + } + } + if line != "" { + lines = append(lines, line) + } + return lines +} + func wrapLines(value string, width int) []string { width = maxInt(width, 1) var out []string diff --git a/tui/tui_test.go b/tui/tui_test.go index 022601b..d382677 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -167,6 +167,32 @@ func TestViewUsesGitcrawlStylePaneTables(t *testing.T) { } } +func TestWideRenderFillsTerminalAndKeepsThreePaneColumns(t *testing.T) { + m := newModel(Options{ + Title: "discrawl archive", + Layout: LayoutChat, + Items: []Item{ + Row{Kind: "message", ID: "one", Scope: "guild", Container: "general", Author: "Amy", Title: "first update", CreatedAt: "2026-05-02T09:00:00Z"}.ItemForLayout(LayoutChat), + Row{Kind: "message", ID: "two", Scope: "guild", Container: "general", Author: "Zed", Title: "second update", CreatedAt: "2026-05-02T10:00:00Z"}.ItemForLayout(LayoutChat), + }, + }) + m.width = 220 + m.height = 34 + view := stripANSI(m.View()) + lines := strings.Split(view, "\n") + if len(lines) != 34 { + t.Fatalf("rendered height = %d, want 34:\n%s", len(lines), view) + } + if len(lines[0]) != 220 || len(lines[len(lines)-1]) != 220 { + t.Fatalf("view did not fill terminal width: first=%d last=%d\n%s", len(lines[0]), len(lines[len(lines)-1]), view) + } + for _, want := range []string{"Channels / People", "Messages", "Thread", "type", "count", "latest", "age", "scope", "group", "kind", "time", "where", "author", "title"} { + if !strings.Contains(view, want) { + t.Fatalf("wide render missing %q:\n%s", want, view) + } + } +} + func TestCompactWidthKeepsUsefulColumns(t *testing.T) { group := itemGroup{Kind: "channel", Count: 18, Latest: "2026-05-02T12:00:00Z", Title: "github-secure-session-4"} groupHeader := groupListHeader(40, sortDefault) @@ -183,7 +209,7 @@ func TestCompactWidthKeepsUsefulColumns(t *testing.T) { Author: "Vincent Koc", CreatedAt: "2026-05-02T12:00:00Z", }, 42) - for _, want := range []string{"DATE", "AGE", "WHO", "TITLE", "05-02", "Vinc", "Im working"} { + for _, want := range []string{"TIME", "AGE", "WHO", "TITLE", "05-02", "Vinc", "Im working"} { if !strings.Contains(rowHeader+rowLine, want) { t.Fatalf("compact row columns missing %q:\n%s\n%s", want, rowHeader, rowLine) } @@ -206,7 +232,7 @@ func TestVeryNarrowPanesStillShowCompactColumns(t *testing.T) { Author: "Vincent Koc", CreatedAt: "2026-05-02T12:00:00Z", }, 28) - for _, want := range []string{"DATE", "TITLE", "05-02", "Im working"} { + for _, want := range []string{"TIME", "TITLE", "05-02", "Im working"} { if !strings.Contains(rowHeader+rowLine, want) { t.Fatalf("narrow row columns missing %q:\n%s\n%s", want, rowHeader, rowLine) } @@ -273,6 +299,31 @@ func TestChatDetailUsesTranscriptShapeBeforeMetadata(t *testing.T) { } } +func TestChatDetailRendersMarkdownTranscriptLikeGitcrawl(t *testing.T) { + m := newModel(Options{ + Title: "discrawl archive", + Layout: LayoutChat, + Items: []Item{ + Row{Kind: "message", ID: "m1", Container: "general", Author: "alice", Title: "root", Text: "# Plan\n- ship columns\n- polish [preview](https://example.com)", CreatedAt: "2026-05-01T10:00:00Z"}.ItemForLayout(LayoutChat), + Row{Kind: "message", ID: "m2", ParentID: "m1", Container: "general", Author: "bob", Title: "reply", Text: "> agreed\n`done`", CreatedAt: "2026-05-01T10:01:00Z"}.ItemForLayout(LayoutChat), + }, + }) + m.selectItemIndex(1) + item, ok := m.selectedItem() + if !ok { + t.Fatal("missing selected item") + } + joined := stripANSI(strings.Join(m.detailLinesForWidth(item, 52), "\n")) + for _, want := range []string{"Plan", "- ship columns", "polish preview ", "> agreed", "done", "Properties", "IDs"} { + if !strings.Contains(joined, want) { + t.Fatalf("markdown chat detail missing %q:\n%s", want, joined) + } + } + if strings.Contains(joined, "# Plan") || strings.Contains(joined, "`done`") { + t.Fatalf("chat detail should render markdown-ish text, not raw markdown:\n%s", joined) + } +} + func TestChatMembersDefaultToChronologicalTranscriptOrder(t *testing.T) { m := newModel(Options{ Title: "slacrawl archive", @@ -807,6 +858,29 @@ func TestDocumentDetailUsesHeaderLocationPreviewProperties(t *testing.T) { } } +func TestDocumentDetailRendersMarkdownPreviewLikeGitcrawl(t *testing.T) { + item := Row{ + Source: "notion", + Kind: "page", + ID: "page1", + ParentID: "Launch docs", + Scope: "Workspace", + Container: "Roadmap DB", + Title: "Launch plan", + Text: "# Checklist\n- wire panes\n- review [spec](https://example.com/spec)\n> keep it readable", + UpdatedAt: "2026-05-01T12:00:00Z", + }.ItemForLayout(LayoutDocument) + joined := stripANSI(strings.Join(documentDetailLinesForWidth(item, 56), "\n")) + for _, want := range []string{"Launch plan", "Checklist", "- wire panes", "review spec ", "> keep it readable", "Properties"} { + if !strings.Contains(joined, want) { + t.Fatalf("document detail missing %q:\n%s", want, joined) + } + } + if strings.Contains(joined, "# Checklist") { + t.Fatalf("document detail should render markdown-ish headings:\n%s", joined) + } +} + func TestDocumentDetailSeparatesProviderAndSource(t *testing.T) { item := Row{ Source: "notion",