diff --git a/go.mod b/go.mod index 25515cd..ed63b48 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,8 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.6 - github.com/keyolk/tuikit v0.0.0-20261021024723-c19aa4f0fc4c + github.com/keyolk/tuikit v0.0.0-20261021080153-b0beaa45b719 github.com/mattn/go-runewidth v0.0.19 - github.com/muesli/termenv v0.16.0 golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.57.0 @@ -33,6 +32,7 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index 11e8956..f362d6f 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/keyolk/tuikit v0.0.0-20261021024723-c19aa4f0fc4c h1:5J4T0r8xWkt3JiN8BquRQAoIEK+6OzbNwBiqecahHzY= -github.com/keyolk/tuikit v0.0.0-20261021024723-c19aa4f0fc4c/go.mod h1:f1m38AtKzIx8QT7vR3N0ghjtCcn8bhcoeYtwveRIcxw= +github.com/keyolk/tuikit v0.0.0-20261021080153-b0beaa45b719 h1:Edpqtu56QDZVThm2iqiEP4UOq4uH1pWmG0c5xZbmvmQ= +github.com/keyolk/tuikit v0.0.0-20261021080153-b0beaa45b719/go.mod h1:f1m38AtKzIx8QT7vR3N0ghjtCcn8bhcoeYtwveRIcxw= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= diff --git a/internal/cli/picker.go b/internal/cli/picker.go index 03c632b..cfaf73b 100644 --- a/internal/cli/picker.go +++ b/internal/cli/picker.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "github.com/keyolk/tuikit/link" "os" "os/exec" "path/filepath" @@ -289,8 +290,11 @@ func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, kittyPaneCmd(kitty.PaneCmd(), false) case tea.MouseMsg: return m.handleMouse(msg) + case selection.AutoScrollMsg: + cmd, _ := m.handleSelectionMouse(msg) + return m, cmd case selection.CopiedMsg: - m.notice = copiedNotice(msg) + m.notice = msg.Note() return m, nil case tea.KeyMsg: // A key between two clicks breaks the pair: click, move away and back @@ -881,7 +885,9 @@ func (m pickerModel) renderListRow(item PickerItem, cursored bool, check string, labelStyle = sel prefixStyled = sel.Render(">") } - row := prefixStyled + check + cat.Render(badgePlain) + " " + labelStyle.Render(label) + + // The label is a PR key, a title or a cut URL: link it to the full URL. + // File rows carry a path, which link.Wrap leaves as plain text. + row := prefixStyled + check + cat.Render(badgePlain) + " " + link.Wrap(item.Item.URL, labelStyle.Render(label)) + dim.Render(refBadgePlain) + dim.Render(sourcePlain) if statusPlain != "" { row += " " + dim.Render(statusPlain) @@ -945,6 +951,14 @@ func (m pickerModel) listFirstVisible(contentH int) int { return 0 } +// pickerPreviewFrame is the preview box's border and padding. View draws the +// box with it and the drag selection insets the box by it, so the two cannot +// disagree on where the preview's text starts. +var pickerPreviewFrame = lipgloss.NewStyle(). + BorderLeft(true). + BorderStyle(lipgloss.NormalBorder()). + PaddingLeft(1) + // screen draws the picker without the drag-selection highlight; image is the // Kitty layer, kept apart because it is cursor-positioned escape output that // the highlight must not cut and selection must not read as text. @@ -1001,12 +1015,9 @@ func (m pickerModel) screen() (frame, image string) { if m.previewFocused { borderColor = lipgloss.Color("#38BDF8") } - previewBox := lipgloss.NewStyle(). + previewBox := pickerPreviewFrame. Width(pw).Height(contentH). - BorderLeft(true). - BorderStyle(lipgloss.NormalBorder()). BorderForeground(borderColor). - PaddingLeft(1). MaxWidth(pw + 1). // +1 for the border column MaxHeight(contentH). Render(m.preview.View()) @@ -1334,10 +1345,24 @@ func (m pickerModel) refStatusPlain(url string, budget int) string { func (m pickerModel) openItems(urls []string) { for _, u := range urls { - _ = opener.Open(m.opener, u) + // URL lists come from transcripts: only http(s) URLs reach the + // opener. Other kinds hold local paths, which open as files. + if m.listsURLs() && !link.Valid(u) { + continue + } + _ = pickerOpen(m.opener, u) } } +// pickerOpen launches a URL or path; tests replace it so no real opener runs. +var pickerOpen = opener.Open + +// listsURLs reports whether the picker's items are web URLs (urls, refs) +// rather than local paths (files, changes, images). +func (m pickerModel) listsURLs() bool { + return m.kind == "urls" || m.kind == "refs" +} + func (m pickerModel) editItems(paths []string) { editor := os.Getenv("EDITOR") if editor == "" { diff --git a/internal/cli/picker_links_test.go b/internal/cli/picker_links_test.go new file mode 100644 index 0000000..1e2e556 --- /dev/null +++ b/internal/cli/picker_links_test.go @@ -0,0 +1,88 @@ +package cli + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/keyolk/tuikit/tuitest" + + "github.com/sendbird/ccx/internal/extract" + "github.com/sendbird/ccx/internal/opener" +) + +// stubPickerOpen records what the picker hands its opener instead of running +// one. +func stubPickerOpen(t *testing.T) *[]string { + t.Helper() + var got []string + prev := pickerOpen + pickerOpen = func(_ opener.Config, u string) error { got = append(got, u); return nil } + t.Cleanup(func() { pickerOpen = prev }) + return &got +} + +func pressO(m pickerModel) pickerModel { + return feed(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) +} + +// A URL row's label is an OSC 8 link to the full URL, and the row still fits +// the list column. +func TestPickerURLRowLabelIsLinked(t *testing.T) { + m, _ := mousePicker(t, "urls", linkItems(3), 100, 24) + frame := m.View() + var found bool + for _, l := range tuitest.Links(frame) { + if l.URL == "https://example.com/1" { + found = true + if l.Text != "link-01" { + t.Fatalf("link covers %q; want exactly the label link-01", l.Text) + } + } + } + if !found { + t.Fatalf("no link to https://example.com/1:\n%s", ansi.Strip(frame)) + } + if !strings.Contains(frame, "\x1b]8;;https://example.com/1\x1b\\") { + t.Fatal("the label is not wrapped in an OSC 8 escape") + } + for i, row := range strings.Split(frame, "\n") { + if w := ansi.StringWidth(row); w > m.width { + t.Fatalf("row %d is %d cells, wider than %d", i, w, m.width) + } + } +} + +// The picker's existing `o` keeps opening the row, and refuses a non-http URL +// a transcript put in the list. +func TestPickerOpenSkipsNonHTTPURL(t *testing.T) { + items := linkItems(2) + items[1].Item = extract.Item{URL: "javascript:alert(1)", Label: "evil", Category: "other"} + m, _ := mousePicker(t, "urls", items, 100, 24) + opened := stubPickerOpen(t) + m = pressO(m) + m.cursor = 1 + pressO(m) + if len(*opened) != 1 || (*opened)[0] != "https://example.com/0" { + t.Fatalf("opened %q; want only https://example.com/0", *opened) + } + if links := tuitest.Links(m.View()); len(links) != 1 { + t.Fatalf("links %+v; the javascript: row must not be linked", links) + } +} + +// File pickers open local paths: the URL filter must not apply to them. +func TestPickerFilesStillOpenPaths(t *testing.T) { + items := linkItems(1) + items[0].Item = extract.Item{URL: "/tmp/repo/app.go", Label: "app.go", Category: "file"} + m, _ := mousePicker(t, "files", items, 100, 24) + opened := stubPickerOpen(t) + pressO(m) + if len(*opened) != 1 || (*opened)[0] != "/tmp/repo/app.go" { + t.Fatalf("opened %q; want the path", *opened) + } + if links := tuitest.Links(m.View()); len(links) != 0 { + t.Fatalf("a path was linked: %+v", links) + } +} diff --git a/internal/cli/picker_mouse.go b/internal/cli/picker_mouse.go index 5885216..28dbd49 100644 --- a/internal/cli/picker_mouse.go +++ b/internal/cli/picker_mouse.go @@ -63,6 +63,13 @@ func (m pickerModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if consumed { return m, selCmd } + mm, cmd := m.handlePickerMouse(msg) + return mm, tea.Batch(selCmd, cmd) +} + +// handlePickerMouse is the picker's own click and wheel handling, for events +// the drag selection did not consume. +func (m pickerModel) handlePickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if m.width == 0 || m.height == 0 || m.searching { return m, nil } diff --git a/internal/cli/picker_selection.go b/internal/cli/picker_selection.go index e35660e..144040c 100644 --- a/internal/cli/picker_selection.go +++ b/internal/cli/picker_selection.go @@ -1,46 +1,29 @@ package cli import ( - "fmt" - "io" "time" - "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/keyolk/tuikit/mouse" "github.com/keyolk/tuikit/selection" - "github.com/muesli/termenv" ) -// pickerSelectionStyle is reverse video on a renderer pinned to the ANSI -// profile: the default renderer drops every SGR under NO_COLOR, reverse -// included, and the selection would be invisible. -var pickerSelectionStyle = func() lipgloss.Style { - // SetColorProfile, not termenv.WithProfile: the renderer re-detects the - // profile from the environment unless it is set explicitly. - r := lipgloss.NewRenderer(io.Discard) - r.SetColorProfile(termenv.ANSI) - return r.NewStyle().Reverse(true) -}() - // View is the picker frame with the drag-selection highlight drawn last, then // the Kitty image layer. func (m pickerModel) View() string { frame, image := m.screen() if m.sel.Active() { - frame = m.sel.Render(frame, m.selectionPanes(), pickerSelectionStyle) + frame = m.sel.Render(frame, m.selectionPanes(), selection.DefaultStyle()) } return frame + image } // previewTextRect is the preview's text cells: previewRect minus the border -// column and the one-cell left padding View draws the preview box with. +// column and padding the preview box is drawn with, capped at the viewport's +// height. func (m pickerModel) previewTextRect() mouse.Rect { - r := m.previewRect() - r.X += 2 - r.W = max(r.W-2, 0) + r := m.previewRect().InsetStyle(pickerPreviewFrame) r.H = min(r.H, m.preview.Height) return r } @@ -76,39 +59,22 @@ func viewportLines(vp viewport.Model) []string { return full.GotoBottom() } -// handleSelectionMouse feeds the drag selection before the picker's click -// handling. done means the selection owns the event; a press that does not -// move stays the picker's click. -func (m *pickerModel) handleSelectionMouse(msg tea.MouseMsg) (cmd tea.Cmd, done bool) { - if !mouse.IsLeftPress(msg) && !m.sel.Dragging() { - return nil, false - } - if mouse.IsLeftPress(msg) { +// handleSelectionMouse feeds the drag selection, or the auto-scroll tick of a +// drag resting past the preview's edge, before the picker's click handling. +// done means the selection owns the event; a press that does not move stays +// the picker's click. +func (m *pickerModel) handleSelectionMouse(msg tea.Msg) (cmd tea.Cmd, done bool) { + if mm, ok := msg.(tea.MouseMsg); ok && mouse.IsLeftPress(mm) { m.notice = "" } now := time.Now if m.clicks.now != nil { now = m.clicks.now } - frame, _ := m.screen() - r := m.sel.Mouse(msg, m.selectionPanes(), frame, now()) + frame := func() string { f, _ := m.screen(); return f } + r, cmd := m.sel.Update(msg, m.selectionPanes, frame, now()) if r.Scroll != 0 && r.ScrollPane == "preview" { - // The same viewport scroll the wheel uses over the preview. - if r.Scroll < 0 { - m.preview.LineUp(-r.Scroll) - } else { - m.preview.LineDown(r.Scroll) - } - } - if r.Copy != "" { - cmd = selection.Copy(r.Copy) + m.preview.SetYOffset(r.Offset) } return cmd, r.Consumed } - -func copiedNotice(msg selection.CopiedMsg) string { - if msg.Err != nil { - return "copy failed: " + msg.Err.Error() - } - return fmt.Sprintf("copied %d chars", utf8.RuneCountInString(msg.Text)) -} diff --git a/internal/cli/picker_selection_test.go b/internal/cli/picker_selection_test.go index cfbf777..b2584b5 100644 --- a/internal/cli/picker_selection_test.go +++ b/internal/cli/picker_selection_test.go @@ -2,16 +2,14 @@ package cli import ( "fmt" - "io" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/keyolk/tuikit/selection" - "github.com/muesli/termenv" + "github.com/keyolk/tuikit/tuitest" ) func drag(x, y int) tea.MouseMsg { @@ -47,18 +45,6 @@ func (m pickerModel) selText() string { return m.sel.Text(m.selectionPanes(), frame) } -// markPickerSelection swaps the highlight for a background the test can find -// (the default test renderer drops styling). -func markPickerSelection(t *testing.T) string { - t.Helper() - r := lipgloss.NewRenderer(io.Discard) - r.SetColorProfile(termenv.ANSI256) - prev := pickerSelectionStyle - pickerSelectionStyle = r.NewStyle().Background(lipgloss.Color("201")) - t.Cleanup(func() { pickerSelectionStyle = prev }) - return "\x1b[48;5;201m" -} - func previewPicker(t *testing.T) (pickerModel, *pickerClock) { t.Helper() items := linkItems(3) @@ -72,7 +58,6 @@ func previewPicker(t *testing.T) (pickerModel, *pickerClock) { func TestPickerDragFromListStopsAtPreview(t *testing.T) { m, _ := previewPicker(t) - mark := markPickerSelection(t) x, y := whereCell(t, m, "link-01") px, py := whereCell(t, m, "ctx-03") m = feed(m, click(x, y)) @@ -85,13 +70,13 @@ func TestPickerDragFromListStopsAtPreview(t *testing.T) { if strings.Contains(got, "ctx-") || strings.Contains(got, "│") || !strings.Contains(got, "link-01") { t.Fatalf("list drag copied %q; want list text only", got) } - for _, row := range strings.Split(m.View(), "\n") { - if i := strings.Index(row, mark); i >= 0 { - rest := row[i+len(mark):] - cells := rest[:strings.Index(rest, "\x1b[")] - if ansi.StringWidth(ansi.Strip(row[:i]))+ansi.StringWidth(cells) > m.listWidth() || strings.Contains(cells, "│") { - t.Fatalf("highlight %q crosses the list edge %d", cells, m.listWidth()) - } + runs := tuitest.Reversed(m.View()) + if len(runs) == 0 { + t.Fatal("no highlight drawn") + } + for _, run := range runs { + if strings.Contains(run, "│") || strings.Contains(run, "ctx-") { + t.Fatalf("highlight %q crosses the list edge into the preview", run) } } } @@ -226,3 +211,26 @@ func TestPickerHighlightSurvivesNoColor(t *testing.T) { t.Fatal("the picker selection is not drawn in reverse video without color") } } + +// The picker routes the selection's auto-scroll tick back to it, so a drag +// resting below the preview keeps scrolling without more motion. +func TestPickerDragRestingPastPreviewBottomAutoScrolls(t *testing.T) { + m, _ := previewPicker(t) + px, py := whereCell(t, m, "ctx-04") + m = feed(m, click(px, py)) + m, cmd := feedCmd(m, drag(px+2, m.height-1)) + if cmd == nil { + t.Fatal("a drag past the bottom scheduled no auto-scroll tick") + } + start := m.preview.YOffset + for range 4 { + tick, ok := m.sel.NextTick() + if !ok { + t.Fatal("no auto-scroll tick pending") + } + m = feed(m, tick) + } + if got := m.preview.YOffset; got != start+4 { + t.Fatalf("offset after 4 ticks = %d, want %d", got, start+4) + } +} diff --git a/internal/cli/run_pick.go b/internal/cli/run_pick.go index c80f4a2..3f99b53 100644 --- a/internal/cli/run_pick.go +++ b/internal/cli/run_pick.go @@ -53,6 +53,7 @@ func RunPickSessionTUI(claudeDir, search string, multi bool) PickSessionExitCode configPath := filepath.Join(os.Getenv("HOME"), ".config", "ccx", "config.yaml") km, _, _, _, cc, oc, _ := tui.LoadCCXConfig(configPath) + tui.UseOpenerForLinks(oc) app := tui.NewApp(sessions, tui.Config{ ClaudeDir: claudeDir, diff --git a/internal/tui/app.go b/internal/tui/app.go index c1cc698..6323afa 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -17,6 +17,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tk "github.com/keyolk/tuikit/kitty" + "github.com/keyolk/tuikit/link" "github.com/keyolk/tuikit/mouse" "github.com/keyolk/tuikit/prefs" "github.com/keyolk/tuikit/selection" @@ -1693,10 +1694,18 @@ func (a *App) update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMsg: return a.handleMouse(msg) + case selection.AutoScrollMsg: + cmd, _ := a.handleSelectionMouse(msg) + return a, cmd + case selection.CopiedMsg: a.reportCopied(msg) return a, nil + case link.OpenedMsg: + a.reportOpened(msg) + return a, nil + case tea.KeyMsg: // A key between two clicks breaks the pair: click, move away and back // by keyboard, click again must not count as a double click. @@ -3103,6 +3112,11 @@ func (a *App) copySelectedRefs() (tea.Model, tea.Cmd, bool) { // falling back to the OS default). a.openURL overrides it so tests can intercept // the call instead of spawning a real process. func (a *App) openInBrowser(url string) error { + // URLs come from transcripts; only http(s) ones are handed to an opener + // (a file: or custom-scheme URL is not something a key should launch). + if !link.Valid(url) { + return link.ErrInvalid + } if a.openURL != nil { return a.openURL(url) } @@ -4149,6 +4163,8 @@ func (a *App) handleActionsMenu(key string) (tea.Model, tea.Cmd) { return a, ti.Cursor.BlinkCmd() case akm.URLs: return a.openURLMenuFromItems(extract.SessionURLs(sess.FilePath), "session") + case akm.OpenLink: + return a, openSessionLink(sess) case akm.Files: return a.openURLMenuFromItems(extract.SessionFilePaths(sess.FilePath), "session files") case akm.Changes: @@ -6950,7 +6966,8 @@ func refLine(r session.SessionRef, width int, selected, checked bool) string { if checked { mark = lipgloss.NewStyle().Foreground(colorBorderFocused).Bold(true).Render("* ") } - line := cursor + mark + dot + " " + label + // OSC 8 on the label only: the PR or issue key hides its URL. + line := cursor + mark + dot + " " + link.Wrap(r.URL, label) var detail string switch r.Kind { @@ -7866,7 +7883,7 @@ func (a *App) renderActionsHintBox() string { } else { lines = append(lines, a.actionLine("delete", "move", "resume", "copy", "copy_path")) } - second := []string{"edit", "worktree", "urls", "files", "changes", "tags"} + second := []string{"edit", "worktree", "urls", "open_link", "files", "changes", "tags"} if sess.HasMemory { second = append(second, "remove_mem") } diff --git a/internal/tui/bindings.go b/internal/tui/bindings.go index 3cfbd6b..848d171 100644 --- a/internal/tui/bindings.go +++ b/internal/tui/bindings.go @@ -64,6 +64,7 @@ var bindingTable = []bindingSpec{ {"actions", "input", "input", "Send input to the live pane"}, {"actions", "jump", "jump", "Jump to the live tmux pane"}, {"actions", "urls", "urls", "URLs"}, + {"actions", "open_link", "open-link", "Open the item's link in the browser"}, {"actions", "files", "files", "Files"}, {"actions", "changes", "changes", "Changes"}, {"actions", "copy", "copy", "Copy"}, @@ -357,6 +358,9 @@ func (a *App) sessionsListHelpRows() []helpRow { r := a.helpRowFor("session", "actions") r.desc += " (" + displayKey(km.Actions.Delete) + "/" + displayKey(km.Actions.Resume) + "/" + displayKey(km.Actions.CopyPath) + "/…)" rows = append(rows, r) + if km.Actions.OpenLink != "" { + rows = append(rows, helpRow{displayKey(km.Session.Actions) + " " + displayKey(km.Actions.OpenLink), "Open the session's PR / Jira link"}) + } continue case "resize_grow": continue // shown with resize_shrink diff --git a/internal/tui/daypane.go b/internal/tui/daypane.go index 0a7f324..6b91364 100644 --- a/internal/tui/daypane.go +++ b/internal/tui/daypane.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "github.com/keyolk/tuikit/link" "sort" "strings" "time" @@ -589,7 +590,8 @@ func dayOutputLine(r dayOutputRow, day time.Time, width int, selected bool) stri titleStyle = titleStyle.Foreground(colorBorderFocused) } stamp := dimStyle.Render(dayOutputTime(r.when, day, r.whenApprox)) + " " - head := cursor + stamp + outputGlyph(r.out) + " " + titleStyle.Render(r.out.Title) + // A PR/Jira/artifact title hides its URL: link the title, not the cursor. + head := cursor + stamp + outputGlyph(r.out) + " " + link.Wrap(r.out.URL, titleStyle.Render(r.out.Title)) anchor := dimStyle.Render(" " + r.shortID) if r.sessions > 1 { diff --git a/internal/tui/drag_selection.go b/internal/tui/drag_selection.go index 66075ae..94a842c 100644 --- a/internal/tui/drag_selection.go +++ b/internal/tui/drag_selection.go @@ -2,31 +2,15 @@ package tui import ( "fmt" - "io" "time" - "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/keyolk/tuikit/mouse" "github.com/keyolk/tuikit/selection" - "github.com/muesli/termenv" "github.com/sendbird/ccx/internal/kitty" ) -// selectionStyle is reverse video rather than a theme color, on a renderer -// pinned to the ANSI profile: the default renderer picks the Ascii profile -// under NO_COLOR and drops every SGR, reverse included, which would make the -// selection invisible. -var selectionStyle = func() lipgloss.Style { - // SetColorProfile, not termenv.WithProfile: the renderer re-detects the - // profile from the environment unless it is set explicitly. - r := lipgloss.NewRenderer(io.Discard) - r.SetColorProfile(termenv.ANSI) - return r.NewStyle().Reverse(true) -}() - // View is the frame plus the drag-selection highlight and the Kitty image // layer. The highlight is drawn last over the text so it lands on exactly the // cells the selection covers; the image layer is cursor-positioned escape @@ -34,7 +18,7 @@ var selectionStyle = func() lipgloss.Style { func (a *App) View() string { screen := a.renderScreen() if a.sel.Active() { - screen = a.sel.Render(screen, a.selectionPanes(), selectionStyle) + screen = a.sel.Render(screen, a.selectionPanes(), selection.DefaultStyle()) } if kitty.Supported() { screen += a.kittyImageLayer() @@ -153,29 +137,21 @@ func (a *App) selectionPanes() []selection.Pane { return nil } -// handleSelectionMouse feeds a mouse event to the drag selection before the -// app's own mouse handling. done is true when the selection owns the event and -// the app must not also treat it as a click; a press that does not move stays -// the app's click. -func (a *App) handleSelectionMouse(msg tea.MouseMsg) (cmd tea.Cmd, done bool) { - // Only a left press, or motion/release during a drag, can change the - // selection; skip building the frame for wheel and stray motion. - if !mouse.IsLeftPress(msg) && !a.sel.Dragging() { - return nil, false - } +// handleSelectionMouse feeds a mouse event, or the auto-scroll tick of a drag +// resting past a pane's edge, to the drag selection before the app's own mouse +// handling. done is true when the selection owns the event and the app must +// not also treat it as a click; a press that does not move stays the app's +// click. cmd carries the copy and the next auto-scroll tick. +func (a *App) handleSelectionMouse(msg tea.Msg) (cmd tea.Cmd, done bool) { // A press in the divider's grab zone (which reaches one cell into each // pane) starts a resize after this returns; the motion that follows // belongs to the resize, never to a selection. if a.dragResizing { return nil, false } - panes := a.selectionPanes() - r := a.sel.Mouse(msg, panes, a.renderScreen(), a.selectionNow()) + r, cmd := a.sel.Update(msg, a.selectionPanes, a.renderScreen, a.selectionNow()) if r.Scroll != 0 { - a.scrollSelectionPane(r.ScrollPane, r.Scroll) - } - if r.Copy != "" { - cmd = selection.Copy(r.Copy) + a.setSelectionPaneOffset(r.ScrollPane, r.Offset) } return cmd, r.Consumed } @@ -189,9 +165,11 @@ func (a *App) selectionNow() time.Time { return time.Now() } -// scrollSelectionPane scrolls the pane a drag went past by n lines, through -// the same viewport scroll the wheel uses for plain previews. -func (a *App) scrollSelectionPane(id string, n int) { +// setSelectionPaneOffset moves the pane a drag went past to offset, the +// selection's own Pane.Offset+Scroll. Setting it, rather than scrolling by +// Scroll, keeps an offset the app stored past the last page from swallowing +// the first lines of an upward drag. +func (a *App) setSelectionPaneOffset(id string, offset int) { var vp *viewport.Model switch id { case "stats": @@ -214,11 +192,7 @@ func (a *App) scrollSelectionPane(id string, n int) { if vp == nil { return } - if n < 0 { - vp.ScrollUp(-n) - } else { - vp.ScrollDown(n) - } + vp.SetYOffset(offset) if id == "sessions/preview" { a.sessPreviewPinned = !a.sessPreviewAtBottom() } @@ -236,11 +210,8 @@ func (a *App) selectionPrefix() string { return fmt.Sprintf("v%d", a.state) } -// reportCopied shows the result of a selection copy in the footer toast. +// reportCopied shows the result of a selection copy in the footer toast, in +// ccx's capitalized toast wording. func (a *App) reportCopied(msg selection.CopiedMsg) { - if msg.Err != nil { - a.flash("Copy failed: " + msg.Err.Error()) - return - } - a.flash(fmt.Sprintf("Copied %d chars", utf8.RuneCountInString(msg.Text))) + a.flash(msg.NoteWith("Copied %d chars", "Copy failed: %s")) } diff --git a/internal/tui/drag_selection_test.go b/internal/tui/drag_selection_test.go index 4a32924..271da0f 100644 --- a/internal/tui/drag_selection_test.go +++ b/internal/tui/drag_selection_test.go @@ -2,16 +2,14 @@ package tui import ( "fmt" - "io" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/keyolk/tuikit/selection" - "github.com/muesli/termenv" + "github.com/keyolk/tuikit/tuitest" "github.com/sendbird/ccx/internal/session" ) @@ -30,47 +28,6 @@ func sendCmd(app *App, msg tea.Msg) (*App, tea.Cmd) { return m.(*App), cmd } -// markSelection swaps the highlight for a style the test can find in the -// frame: a 256-color background no other ccx style uses, on an ANSI renderer -// (the default renderer in tests drops all styling). -func markSelection(t *testing.T) string { - t.Helper() - r := lipgloss.NewRenderer(io.Discard) - r.SetColorProfile(termenv.ANSI256) - prev := selectionStyle - selectionStyle = r.NewStyle().Background(lipgloss.Color("201")) - t.Cleanup(func() { selectionStyle = prev }) - return "\x1b[48;5;201m" -} - -// highlighted returns, per screen row, the cells drawn with the selection -// mark and the column the first one starts at. -func highlighted(frame, mark string) map[int]struct { - x int - text string -} { - out := map[int]struct { - x int - text string - }{} - for y, row := range strings.Split(frame, "\n") { - i := strings.Index(row, mark) - if i < 0 { - continue - } - rest := row[i+len(mark):] - end := strings.Index(rest, "\x1b[") - if end < 0 { - end = len(rest) - } - out[y] = struct { - x int - text string - }{ansi.StringWidth(ansi.Strip(row[:i])), rest[:end]} - } - return out -} - // newSelectionApp is the sessions view with the preview open on a long plain // document, cached under the selected session so clicks do not reload it. func newSelectionApp(t *testing.T) (*App, *testClock) { @@ -108,7 +65,6 @@ func (a *App) selectedText() string { return a.sel.Text(a.selectionPanes(), a.re func TestDragFromListTowardPreviewStaysInList(t *testing.T) { app, _ := newSelectionApp(t) - mark := markSelection(t) x, y := findCell(t, app, "s00 just now") // the list row, not the title px, py := findCell(t, app, "doc-002") app = send(app, press(x, y)) @@ -124,22 +80,19 @@ func TestDragFromListTowardPreviewStaysInList(t *testing.T) { if !strings.Contains(got, "s00 just now") { t.Fatalf("copy %q lost the list text under the press", got) } - listW := app.sessSplit.ListWidth(app.width, app.splitRatio) - frame := app.View() - rows := highlighted(frame, mark) - if len(rows) == 0 { + runs := tuitest.Reversed(app.View()) + if len(runs) == 0 { t.Fatal("no highlight drawn") } - for y, h := range rows { - if h.x+ansi.StringWidth(h.text) > listW || strings.Contains(h.text, "│") { - t.Fatalf("row %d highlight %q at %d crosses the list edge %d", y, h.text, h.x, listW) + for _, run := range runs { + if strings.Contains(run, "│") || strings.Contains(run, "doc-") { + t.Fatalf("highlight %q crosses the list edge into the preview", run) } } } func TestDragFromPreviewTowardListStaysInPreview(t *testing.T) { app, _ := newSelectionApp(t) - mark := markSelection(t) px, py := findCell(t, app, "doc-001") lx, _ := findCell(t, app, "s00 just now") // px is the preview's first column, inside the divider's resize grab @@ -151,10 +104,11 @@ func TestDragFromPreviewTowardListStaysInPreview(t *testing.T) { if got != "alpha ~/src/beta\nd" { t.Fatalf("copy = %q, want the preview columns only", got) } - for y, h := range highlighted(app.View(), mark) { - if h.x < px || strings.Contains(h.text, "│") || strings.Contains(h.text, "proj") { - t.Fatalf("row %d highlight %q at %d leaves the preview (starts at %d)", y, h.text, h.x, px) - } + // The first row is highlighted to the preview's right edge (the line's + // padding included); it must not reach the list on the left. + runs := tuitest.Reversed(app.View()) + if len(runs) != 2 || strings.TrimRight(runs[0], " ") != "alpha ~/src/beta" || runs[1] != "d" { + t.Fatalf("highlight %q; want the copied preview cells only", runs) } } @@ -232,16 +186,15 @@ func TestPreviewDoubleClickSelectsWordNotEnter(t *testing.T) { func TestKeyClearsSelection(t *testing.T) { app, _ := newSelectionApp(t) - mark := markSelection(t) px, py := findCell(t, app, "alpha") app = send(app, press(px, py)) app = send(app, motionAt(px+6, py)) app = send(app, releaseAt(px+6, py)) - if !app.sel.Active() || len(highlighted(app.View(), mark)) == 0 { + if !app.sel.Active() || len(tuitest.Reversed(app.View())) == 0 { t.Fatal("drag left no highlight") } app = send(app, tea.KeyMsg{Type: tea.KeyF12}) - if app.sel.Active() || len(highlighted(app.View(), mark)) != 0 { + if app.sel.Active() || len(tuitest.Reversed(app.View())) != 0 { t.Fatal("a key did not clear the selection") } } @@ -377,3 +330,50 @@ func TestDragInScrolledPreviewCopiesWhatIsShown(t *testing.T) { t.Fatalf("copy in a scrolled preview = %q", got) } } + +// A drag above the top of a scrolled preview scrolls it up one line per +// motion, to exactly the offset the selection reports: the copy keeps the +// lines that scrolled in. +func TestDragPastPreviewTopScrollsToSelectionOffset(t *testing.T) { + app, _ := newSelectionApp(t) + app.sessSplit.Preview.SetYOffset(30) + px, py := findCell(t, app, "doc-035") + app = send(app, press(px+4, py)) + for range 5 { + app = send(app, motionAt(px+4, 0)) // the title row, above the preview + } + if got := app.sessSplit.Preview.YOffset; got != 25 { + t.Fatalf("preview offset after 5 motions above the top = %d, want 25", got) + } + app = send(app, releaseAt(px+4, 0)) + if got := app.selectedText(); !strings.HasPrefix(got, "025 alpha") || !strings.Contains(got, "doc-034") { + t.Fatalf("copy %q; want line 025 (column of the drag) through the press line", got) + } +} + +// A pointer resting below the preview sends no more motion; the selection's +// auto-scroll tick keeps scrolling, and ccx must route the tick back to it. +func TestDragRestingPastPreviewBottomAutoScrolls(t *testing.T) { + app, _ := newSelectionApp(t) + px, py := findCell(t, app, "doc-005") + app = send(app, press(px+4, py)) + app, cmd := sendCmd(app, motionAt(px+4, app.height-1)) + if cmd == nil { + t.Fatal("a drag past the bottom scheduled no auto-scroll tick") + } + start := app.sessSplit.Preview.YOffset + for range 3 { + tick, ok := app.sel.NextTick() + if !ok { + t.Fatal("no auto-scroll tick pending while the pointer rests past the edge") + } + app = send(app, tick) + } + if got := app.sessSplit.Preview.YOffset; got != start+3 { + t.Fatalf("offset after 3 ticks = %d, want %d", got, start+3) + } + app = send(app, releaseAt(px+4, app.height-1)) + if _, ok := app.sel.NextTick(); ok { + t.Fatal("the release left an auto-scroll tick pending") + } +} diff --git a/internal/tui/interactions.go b/internal/tui/interactions.go index cc33d20..1f41082 100644 --- a/internal/tui/interactions.go +++ b/internal/tui/interactions.go @@ -10,10 +10,11 @@ import ( type interactionActionID string const ( - interactionActionURLs interactionActionID = "urls" - interactionActionFiles interactionActionID = "files" - interactionActionChanges interactionActionID = "changes" - interactionActionCopy interactionActionID = "copy" + interactionActionURLs interactionActionID = "urls" + interactionActionOpenLink interactionActionID = "open_link" + interactionActionFiles interactionActionID = "files" + interactionActionChanges interactionActionID = "changes" + interactionActionCopy interactionActionID = "copy" ) type interactionAction struct { @@ -279,6 +280,7 @@ func (a *App) conversationPreviewHiddenHelpActions() []interactionAction { func (a *App) conversationActionMenuActions() []interactionAction { return []interactionAction{ bindAction(interactionActionURLs, a.keymap.Actions.URLs, "urls"), + bindAction(interactionActionOpenLink, a.keymap.Actions.OpenLink, "open-link"), bindAction(interactionActionFiles, a.keymap.Actions.Files, "files"), bindAction(interactionActionChanges, a.keymap.Actions.Changes, "changes"), bindAction(interactionActionCopy, a.keymap.Actions.Copy, "copy"), diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 6afa842..e04070a 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -51,6 +51,7 @@ type ActionsKeymap struct { Input string `yaml:"input"` Jump string `yaml:"jump"` URLs string `yaml:"urls"` + OpenLink string `yaml:"open_link"` Files string `yaml:"files"` Changes string `yaml:"changes"` Copy string `yaml:"copy"` @@ -251,6 +252,10 @@ func DefaultKeymap() Keymap { Input: "i", Jump: "j", URLs: "u", + // "o", the fleet-wide open-link key (tuikit link.Binding); it is + // free in the actions menu, unlike the session list (fold group) + // and the conversation (jump to origin turn). + OpenLink: "o", Files: "f", Changes: "g", Copy: "c", @@ -467,6 +472,9 @@ func mergeKeymap(dst *Keymap, src Keymap) { if src.Actions.URLs != "" { dst.Actions.URLs = src.Actions.URLs } + if src.Actions.OpenLink != "" { + dst.Actions.OpenLink = src.Actions.OpenLink + } if src.Actions.Files != "" { dst.Actions.Files = src.Actions.Files } diff --git a/internal/tui/links.go b/internal/tui/links.go new file mode 100644 index 0000000..d94d9fa --- /dev/null +++ b/internal/tui/links.go @@ -0,0 +1,88 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/keyolk/tuikit/link" + "github.com/sendbird/ccx/internal/opener" + "github.com/sendbird/ccx/internal/session" +) + +// UseOpenerForLinks makes the open-link key (link.Open) honor the user's +// open.command_template, as every other ccx open path does. Without a +// template the kit's own opener stays: it is the same OS default ccx uses, +// and it also refuses to open a browser on the remote host over SSH. +// +// link.Launcher is process-wide, so this is called once at startup from the +// config ccx runs with, never from NewApp: tests stub link.Launcher and must +// not have it replaced by whatever config the machine has. +func UseOpenerForLinks(cfg opener.Config) { + if cfg.CommandTemplate == "" { + return + } + link.Launcher = func(u string) error { return opener.Open(cfg, u) } +} + +// sessionLinkURLs is the session's PR, Jira and artifact URLs, best first +// (session.sortRefs order). Refs already on the session are used as they are; +// otherwise they are extracted from the transcript, offline (no status +// lookup), so this is safe to run inside a command. +func sessionLinkURLs(sess session.Session) []string { + refs := sess.Refs + if len(refs) == 0 && sess.FilePath != "" { + refs = session.ExtractSessionRefsFromFile(sess.FilePath) + } + urls := make([]string, 0, len(refs)) + for _, r := range refs { + urls = append(urls, r.URL) + } + return urls +} + +// openSessionLink opens the first link of sess (a session usually has one PR +// worth opening; OpenFirst says when there were more). The transcript scan +// runs in the command, off the update loop. +func openSessionLink(sess session.Session) tea.Cmd { + return func() tea.Msg { return link.OpenFirst(sessionLinkURLs(sess))() } +} + +// conversationLinkText is the text the open-link key searches for URLs in the +// conversation: the focused block of the preview when one is picked, else the +// selected message. It mirrors what the copy action copies, so "copy" and +// "open link" act on the same thing. +func (a *App) conversationLinkText() string { + if fs := a.conv.split.Folds; fs != nil && fs.BlockCursor >= 0 && fs.BlockCursor < len(fs.Entry.Content) { + if text := blockPlainText(fs.Entry.Content[fs.BlockCursor]); text != "" { + return text + } + } + item, ok := a.selectedConversationItem() + if !ok { + return "" + } + switch item.kind { + case convMsg: + return entryFullText(item.merged.entry) + case convAgent: + return entryFullText(buildAgentPreviewEntry(item.agent)) + } + return "" +} + +// openConversationLink opens the first URL in the selected message or block. +func (a *App) openConversationLink() tea.Cmd { + return link.OpenFirst(link.Find(a.conversationLinkText())) +} + +// reportOpened shows the result of the open-link key in the footer toast. +func (a *App) reportOpened(msg link.OpenedMsg) { + a.flash(msg.NoteWith(ccxLinkNotes)) +} + +// ccxLinkNotes is the kit's toast text in ccx's capitalized toast wording +// ("Opened …", "Copied …"). +var ccxLinkNotes = link.Notes{ + Opened: "Opened %s", + OpenedFirst: "Opened %s (the first of %d links)", + Failed: "Open failed: %s", + None: "No link here", +} diff --git a/internal/tui/links_test.go b/internal/tui/links_test.go new file mode 100644 index 0000000..13a08e8 --- /dev/null +++ b/internal/tui/links_test.go @@ -0,0 +1,374 @@ +package tui + +import ( + "strings" + "sync" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/keyolk/tuikit/link" + "github.com/keyolk/tuikit/tuitest" + + "github.com/sendbird/ccx/internal/extract" + "github.com/sendbird/ccx/internal/session" +) + +const ( + prURL = "https://github.com/sendbird/ccx/pull/5" + jiraURL = "https://sendbird.atlassian.net/browse/CPLAT-12912" +) + +// stubLauncher replaces link.Launcher for the test and records every URL it +// is handed. The launcher runs inside the command, off the test goroutine, +// so it records and never fails the test itself. +func stubLauncher(t *testing.T) func() []string { + t.Helper() + var mu sync.Mutex + var got []string + prev := link.Launcher + link.Launcher = func(u string) error { + mu.Lock() + defer mu.Unlock() + got = append(got, u) + return nil + } + t.Cleanup(func() { link.Launcher = prev }) + return func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), got...) + } +} + +// pressOpenKey sends one rune key and runs the command it returns, feeding every +// link.OpenedMsg back as Bubble Tea would. Toast ticks are skipped by Drain. +func pressOpenKey(t *testing.T, app *App, key rune) (*App, []link.OpenedMsg) { + t.Helper() + app, cmd := sendCmd(app, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{key}}) + var opened []link.OpenedMsg + for _, msg := range tuitest.Drain(cmd, 500*time.Millisecond) { + if om, ok := msg.(link.OpenedMsg); ok { + opened = append(opened, om) + app = send(app, om) + } + } + return app, opened +} + +// linkTo returns the frame's OSC 8 link to u, or false. +func linkTo(frame, u string) (tuitest.Link, bool) { + for _, l := range tuitest.Links(frame) { + if l.URL == u { + return l, true + } + } + return tuitest.Link{}, false +} + +// assertRowsFit fails when a wrapped label widened any row past the screen. +func assertRowsFit(t *testing.T, app *App) { + t.Helper() + for i, row := range strings.Split(app.View(), "\n") { + if w := ansi.StringWidth(row); w > app.width { + t.Fatalf("row %d is %d cells, wider than the %d-cell screen: %q", i, w, app.width, ansi.Strip(row)) + } + } +} + +func toastText(t *testing.T, app *App) string { + t.Helper() + tt, ok := app.statusToast() + if !ok { + t.Fatal("no toast shown") + } + return tt.Text +} + +// The Outputs digest shows a PR by its key and a Jira issue by its key; the +// URL is not on screen, so the title is an OSC 8 link to it. A javascript: +// URL from a transcript is not a link. +func TestOutputsDigestLinksTitlesThatHideTheirURL(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + sessions := []session.Session{{ + ID: "maker", ShortID: "maker", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0), + RefsResolved: true, + Refs: []session.SessionRef{ + {Kind: session.RefPR, Label: "sendbird/ccx#5", URL: prURL, Resolved: true}, + {Kind: session.RefJira, Label: "CPLAT-12912", URL: jiraURL, Resolved: true}, + {Kind: session.RefArtifact, Label: "evil-page", URL: "javascript:alert(1)"}, + }, + }} + app := newTestApp(sessions) + app.sessionList.Select(0) + app.sessPreviewMode = sessPreviewOutputs + app.sessSplit.Show = true + app.sessSplit.Focus = true + _ = app.updateSessionPreview() + frame := app.View() + for u, label := range map[string]string{prURL: "sendbird/ccx#5", jiraURL: "CPLAT-12912"} { + l, ok := linkTo(frame, u) + if !ok { + t.Fatalf("no OSC 8 link to %s in the digest:\n%s", u, ansi.Strip(frame)) + } + if l.Text != label { + t.Fatalf("link to %s covers %q; want exactly the title %q", u, l.Text, label) + } + } + if !strings.Contains(frame, "\x1b]8;;"+prURL+"\x1b\\") { + t.Fatal("the PR link is not an OSC 8 escape around the title") + } + if !strings.Contains(ansi.Strip(frame), "evil-page") { + t.Fatalf("the javascript: row is not drawn:\n%s", ansi.Strip(frame)) + } + for _, l := range tuitest.Links(frame) { + if l.URL != prURL && l.URL != jiraURL { + t.Fatalf("unexpected link %+v: only http(s) URLs may be linked", l) + } + } + assertRowsFit(t, app) +} + +// The References preview links each ref's label (key plus state) to its URL. +func TestRefsPreviewLinksLabel(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + sessions := fakeSessions() + sessions[0].Refs = []session.SessionRef{ + {Kind: session.RefPR, URL: prURL, Label: "sendbird/ccx#5", State: session.RefStateOpen, Resolved: true}, + {Kind: session.RefArtifact, URL: "file:///etc/passwd", Label: "local", Title: "local file"}, + } + a := newTestApp(sessions) + a.sessionList.Select(0) + a.sessPreviewMode = sessPreviewRefs + a.sessSplit.Show = true + a.sessSplit.Focus = true + _ = a.updateSessionPreview() + frame := a.View() + l, ok := linkTo(frame, prURL) + if !ok || l.Text != "sendbird/ccx#5 OPEN" { + t.Fatalf("refs preview link = %+v (found %t); want the label \"sendbird/ccx#5 OPEN\"", l, ok) + } + if !strings.Contains(ansi.Strip(frame), "local file") { + t.Fatalf("the file: ref is not drawn:\n%s", ansi.Strip(frame)) + } + if _, ok := linkTo(frame, "file:///etc/passwd"); ok { + t.Fatal("a file: URL from a transcript was linked") + } + assertRowsFit(t, a) +} + +// The existing `o` on a refs row keeps opening it, but never hands a +// non-http URL to the opener. +func TestRefsPreviewOpenRefusesNonHTTPURL(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + for _, bad := range []string{"javascript:alert(1)", "file:///etc/passwd"} { + a := newTestApp(fakeSessions()) + a.sessionList.Select(0) + a.sessPreviewMode = sessPreviewRefs + a.sessSplit.Show = true + a.sessSplit.Focus = true + a.sessPreviewRefs = []session.SessionRef{{Kind: session.RefArtifact, URL: bad, Label: "x"}} + var opened []string + a.openURL = func(u string) error { opened = append(opened, u); return nil } + a = send(a, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + if len(opened) != 0 { + t.Fatalf("o handed %q to the opener", opened) + } + } +} + +// The day pane links its PR rows too. +func TestDayPaneLinksPRTitle(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + app := dayPaneApp(t, []session.Session{{ + ID: "maker", ShortID: "maker", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", + ModTime: dayOf(0), + Refs: []session.SessionRef{{Kind: session.RefPR, Label: "sendbird/ccx#5", URL: prURL, Resolved: true}}, + }}) + l, ok := linkTo(app.View(), prURL) + if !ok || l.Text != "sendbird/ccx#5" { + t.Fatalf("day pane link = %+v (found %t); want the title sendbird/ccx#5", l, ok) + } + assertRowsFit(t, app) +} + +// sessionActionsApp is the session list with the actions menu open on sess. +func sessionActionsApp(t *testing.T, sess session.Session) *App { + t.Helper() + t.Setenv("HOME", t.TempDir()) + app := newTestApp([]session.Session{sess}) + app.actionsMenu = true + app.actionsSess = sess + return app +} + +// x o on a session opens its first PR/Jira link with link.Open, and the +// toast says which one and that there were more. +func TestSessionActionsOpenLinkOpensFirstRef(t *testing.T) { + launched := stubLauncher(t) + app := sessionActionsApp(t, session.Session{ + ID: "s1", ShortID: "s1", ProjectPath: "/tmp/p", ProjectName: "p", ModTime: time.Now(), + Refs: []session.SessionRef{ + {Kind: session.RefPR, URL: prURL, Label: "sendbird/ccx#5"}, + {Kind: session.RefJira, URL: jiraURL, Label: "CPLAT-12912"}, + }, + }) + app, opened := pressOpenKey(t, app, 'o') + if got := launched(); len(got) != 1 || got[0] != prURL { + t.Fatalf("launcher got %q; want exactly [%s]", got, prURL) + } + if len(opened) != 1 || opened[0].URL != prURL || opened[0].Err != nil { + t.Fatalf("OpenedMsg = %+v", opened) + } + if got := toastText(t, app); got != "Opened "+prURL+" (the first of 2 links)" { + t.Fatalf("toast = %q", got) + } +} + +// A session with no ref launches nothing and says so. +func TestSessionActionsOpenLinkWithoutLinkSaysSo(t *testing.T) { + launched := stubLauncher(t) + app := sessionActionsApp(t, session.Session{ + ID: "s1", ShortID: "s1", ProjectPath: "/tmp/p", ProjectName: "p", ModTime: time.Now(), + Refs: []session.SessionRef{{Kind: session.RefArtifact, URL: "javascript:alert(1)", Label: "x"}}, + }) + app, _ = pressOpenKey(t, app, 'o') + if got := launched(); len(got) != 0 { + t.Fatalf("launcher got %q; want nothing", got) + } + if got := toastText(t, app); got != "No link here" { + t.Fatalf("toast = %q, want No link here", got) + } +} + +// Refs not loaded yet are read from the transcript, offline. +func TestSessionActionsOpenLinkReadsTranscriptRefs(t *testing.T) { + launched := stubLauncher(t) + path := writeTranscript(t, + `{"type":"assistant","uuid":"u2","timestamp":"2026-08-13T01:01:00Z","message":{"role":"assistant","content":[{"type":"text","text":"opened `+prURL+`"}]}}`, + ) + app := sessionActionsApp(t, session.Session{ + ID: "s1", ShortID: "s1", FilePath: path, ProjectPath: "/tmp/p", ProjectName: "p", ModTime: time.Now(), + }) + pressOpenKey(t, app, 'o') + if got := launched(); len(got) != 1 || got[0] != prURL { + t.Fatalf("launcher got %q; want [%s]", got, prURL) + } +} + +// The actions hint box and the help overlay both show the new binding. +func TestOpenLinkBindingShowsInHelp(t *testing.T) { + app := sessionActionsApp(t, session.Session{ID: "s1", ShortID: "s1", ProjectPath: "/tmp/p", ModTime: time.Now()}) + if box := ansi.Strip(app.renderActionsHintBox()); !strings.Contains(box, "o:open-link") { + t.Fatalf("actions hint box lacks o open-link:\n%s", box) + } + found := false + for _, r := range app.sessionsListHelpRows() { + if r.key == "x o" && strings.Contains(r.desc, "link") { + found = true + } + } + if !found { + t.Fatalf("the ? overlay has no x o row: %+v", app.sessionsListHelpRows()) + } + if conflicts := app.keymap.Conflicts(); len(conflicts) != 0 { + t.Fatalf("keymap conflicts: %v", conflicts) + } +} + +// convLinkApp is a conversation whose only message carries two http URLs and +// a javascript: one, with the conversation actions menu open. +func convLinkApp(t *testing.T, text string) *App { + t.Helper() + t.Setenv("HOME", t.TempDir()) + base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + app := setupConvApp(t, []session.Entry{makeTextEntry("assistant", base, text)}, 160, 40) + app.convList.Select(0) + app.updateConvPreview() + return send(app, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) +} + +// x o in the conversation opens the first URL of the selected message. +func TestConversationActionsOpenLinkOpensFirstURL(t *testing.T) { + launched := stubLauncher(t) + app := convLinkApp(t, "try javascript:alert(1) then see "+prURL+" (and https://example.com/b).") + if !app.convActionsMenu { + t.Fatal("x did not open the conversation actions menu") + } + if box := ansi.Strip(app.renderConvActionsHintBox()); !strings.Contains(box, "o:open-link") { + t.Fatalf("conversation actions lack o open-link:\n%s", box) + } + app, opened := pressOpenKey(t, app, 'o') + if got := launched(); len(got) != 1 || got[0] != prURL { + t.Fatalf("launcher got %q; want [%s]", got, prURL) + } + if len(opened) != 1 || opened[0].Of != 2 { + t.Fatalf("OpenedMsg = %+v; want the first of 2", opened) + } + if got := toastText(t, app); !strings.Contains(got, "the first of 2 links") { + t.Fatalf("toast = %q", got) + } +} + +func TestConversationActionsOpenLinkWithoutURLSaysSo(t *testing.T) { + launched := stubLauncher(t) + app := convLinkApp(t, "no links, only javascript:alert(1) and file:///etc/passwd") + app, _ = pressOpenKey(t, app, 'o') + if got := launched(); len(got) != 0 { + t.Fatalf("launcher got %q; want nothing", got) + } + if got := toastText(t, app); got != "No link here" { + t.Fatalf("toast = %q", got) + } +} + +// urlMenuApp opens the URL menu on the given items. +func urlMenuApp(t *testing.T, scope string, items []extract.Item) *App { + t.Helper() + t.Setenv("HOME", t.TempDir()) + app := newTestApp(fakeSessions()) + m, _ := app.openURLMenuFromItems(items, scope) + return m.(*App) +} + +// The URL menu links each label to its URL, and o opens the row under the +// cursor without closing the menu. +func TestURLMenuLinksLabelsAndOOpensCursorRow(t *testing.T) { + launched := stubLauncher(t) + a := "https://example.com/a" + b := "https://example.com/b" + app := urlMenuApp(t, "session", []extract.Item{ + {URL: a, Label: "example a", Category: "other"}, + {URL: b, Label: "example b", Category: "other"}, + }) + frame := app.View() + if l, ok := linkTo(frame, b); !ok || l.Text != "example b" { + t.Fatalf("URL menu link to b = %+v (found %t)", l, ok) + } + assertRowsFit(t, app) + app = send(app, tea.KeyMsg{Type: tea.KeyDown}) + app, opened := pressOpenKey(t, app, 'o') + if got := launched(); len(got) != 1 || got[0] != b { + t.Fatalf("launcher got %q; want [%s]", got, b) + } + if len(opened) != 1 || !app.urlMenu { + t.Fatalf("opened %+v, menu open %t; want one open and the menu kept", opened, app.urlMenu) + } +} + +// File rows carry paths: no link, and o says there is none. +func TestURLMenuFileRowHasNoLink(t *testing.T) { + launched := stubLauncher(t) + app := urlMenuApp(t, "session files", []extract.Item{{URL: "/tmp/repo/app.go", Label: "app.go", Category: "file"}}) + if links := tuitest.Links(app.View()); len(links) != 0 { + t.Fatalf("file row linked: %+v", links) + } + app, _ = pressOpenKey(t, app, 'o') + if got := launched(); len(got) != 0 { + t.Fatalf("launcher got %q", got) + } + if got := toastText(t, app); got != "No link here" { + t.Fatalf("toast = %q", got) + } +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index bb58756..68b3dcc 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -18,6 +18,13 @@ func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if consumed { return a, selCmd } + m, cmd := a.handleAppMouse(msg) + return m, tea.Batch(selCmd, cmd) +} + +// handleAppMouse is ccx's own click, wheel and resize handling, for events the +// drag selection did not consume. +func (a *App) handleAppMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Overlays own the mouse while open: nothing below may see the event. if m, cmd, handled := a.handleOverlayMouse(msg); handled { return m, cmd diff --git a/internal/tui/outputs.go b/internal/tui/outputs.go index 92729f1..bbd6079 100644 --- a/internal/tui/outputs.go +++ b/internal/tui/outputs.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "github.com/keyolk/tuikit/link" "os" "strings" @@ -241,7 +242,8 @@ func outputLine(o session.SessionOutput, width int, selected bool) string { cursor = lipgloss.NewStyle().Foreground(colorBorderFocused).Bold(true).Render("> ") titleStyle = titleStyle.Foreground(colorBorderFocused) } - head := cursor + outputGlyph(o) + " " + titleStyle.Render(o.Title) + // A PR/Jira/artifact title hides its URL: link the title, not the cursor. + head := cursor + outputGlyph(o) + " " + link.Wrap(o.URL, titleStyle.Render(o.Title)) suffix := "" if o.Count > 1 { diff --git a/internal/tui/sessions.go b/internal/tui/sessions.go index 6a0c127..e3a7e80 100644 --- a/internal/tui/sessions.go +++ b/internal/tui/sessions.go @@ -1792,6 +1792,7 @@ func (a *App) helpModalContextRows() (title string, rows []helpRow) { {regionNavHelpKeys(km), "Move between regions"}, {displayKey(km.Conversation.JumpToTree), "Jump to origin turn"}, {displayKey(km.Conversation.Actions), "Actions"}, + {displayKey(km.Conversation.Actions) + " " + displayKey(km.Actions.OpenLink), "Open the first link in the selected message"}, {displayKey(km.Conversation.SwitchRegion), "Switch region"}, {displayKey(km.Conversation.ExecutionContexts), "Execution rail"}, {displayKey(km.Conversation.Edit), "Edit"}, diff --git a/internal/tui/state.go b/internal/tui/state.go index 6bb2aa6..645c8bd 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -363,6 +363,9 @@ func fillKeymapDefaults(cfg *CCXConfig, d Keymap) { if a.URLs == "" { a.URLs = d.Actions.URLs } + if a.OpenLink == "" { + a.OpenLink = d.Actions.OpenLink + } if a.Files == "" { a.Files = d.Actions.Files } diff --git a/internal/tui/urls.go b/internal/tui/urls.go index 1fe16a7..681a8c5 100644 --- a/internal/tui/urls.go +++ b/internal/tui/urls.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "github.com/keyolk/tuikit/link" "strings" "time" @@ -23,6 +24,8 @@ func (a *App) handleConvActionsMenu(key string) (tea.Model, tea.Cmd) { switch { case interactionKeyMatches(actions, key, interactionActionURLs): a.openInspector(inspectorRefs, a.conv.inspector.Scope, false) + case interactionKeyMatches(actions, key, interactionActionOpenLink): + return a, a.openConversationLink() case interactionKeyMatches(actions, key, interactionActionFiles): a.openInspector(inspectorFiles, a.conv.inspector.Scope, false) case interactionKeyMatches(actions, key, interactionActionChanges): @@ -292,6 +295,15 @@ func (a *App) handleURLMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } a.flash(fmt.Sprintf("Opened %d URL(s)", opened)) return a, nil + case "o": + // The fleet's open-link key: the row under the cursor only, kept open + // so the next one can follow (enter opens the selection and closes). + // File rows carry paths, which OpenFirst refuses as "no link here". + var urls []string + if a.urlCursor >= 0 && a.urlCursor < len(a.urlItems) { + urls = []string{a.urlItems[a.urlCursor].URL} + } + return a, link.OpenFirst(urls) case "y": urls := a.selectedURLs() if len(urls) == 0 { @@ -515,10 +527,12 @@ func (a *App) renderURLMenu() string { if item.Category != "change" && !item.Timestamp.IsZero() { ts = " " + dimStyle.Render(timeAgo(item.Timestamp)) } + // The label is often a PR key or a cut URL: link it to the full URL + // (file rows carry a path, which link.Wrap leaves as text). if i == cursor { - lines = append(lines, sel.Render(">")+check+badge+" "+sel.Render(label)+status+ts) + lines = append(lines, sel.Render(">")+check+badge+" "+link.Wrap(item.URL, sel.Render(label))+status+ts) } else { - lines = append(lines, " "+check+badge+" "+hl.Render(label)+status+ts) + lines = append(lines, " "+check+badge+" "+link.Wrap(item.URL, hl.Render(label))+status+ts) } } @@ -538,7 +552,7 @@ func (a *App) renderURLMenu() string { } else if isFiles { lines = append(lines, d.Render("↵:edit y:copy space:select /:search esc:close")) } else { - lines = append(lines, d.Render("↵:open y:copy space:select /:search esc:close")) + lines = append(lines, d.Render("↵:open o:open-link y:copy space:select /:search esc:close")) } listBody := strings.Join(lines, "\n") diff --git a/main.go b/main.go index e0694d4..4acaaf4 100644 --- a/main.go +++ b/main.go @@ -293,6 +293,7 @@ func main() { configPath := filepath.Join(os.Getenv("HOME"), ".config", "ccx", "config.yaml") km, _, _, _, cc, oc, _ := tui.LoadCCXConfig(configPath) + tui.UseOpenerForLinks(oc) initialSessions := session.LoadCachedSessions(claudeDir) if len(initialSessions) == 0 {