From a82f5e7e9ff1814df491ef33e073fdce77a7f701 Mon Sep 17 00:00:00 2001 From: pablu Date: Wed, 29 Jul 2026 09:13:16 +0200 Subject: [PATCH] fix QoL and Ai integration --- README.md | 4 + ai.go | 7 +- ai_store.go | 23 ++++- ai_test.go | 51 +++++++++- ai_tui.go | 36 +++---- config.go | 7 ++ config_test.go | 11 ++- github.go | 7 +- github_test.go | 5 +- main.go | 1 + tui.go | 260 +++++++++++++++++++++++++++++++++++++++---------- tui_test.go | 138 +++++++++++++++++++++++++- types.go | 6 +- 13 files changed, 472 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index e8d5aaf..f40fbb5 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ fold_resolved = true thread_list_width_percent = 33 # 20-60 dashboard_mode = "hotkey" # "hotkey" or "intermediate" compact_reviews = true +viewer_label = "login" # "login" or "you" [paths] scroll = false @@ -281,6 +282,9 @@ and thread viewer. `compact_reviews = true` summarizes the submitted-review history instead of showing every repeated `COMMENTED` event. +`viewer_label = "login"` shows your GitHub username like every other author. +Set it to `"you"` to replace your username with `@you` throughout the UI. + Thread categories are: - `unresolved`: current unresolved threads; diff --git a/ai.go b/ai.go index abe0f82..b7a5949 100644 --- a/ai.go +++ b/ai.go @@ -145,6 +145,7 @@ type AIPreview struct { chunks []string details PRDetails threadID string + message string validLines map[string]map[int]bool validDeleted map[string]map[int]bool diffText map[string]string @@ -243,7 +244,7 @@ func (c *AIController) Prepare(ctx context.Context, details PRDetails, threadID, Included: included, Redactions: redactions, HeadOID: details.HeadOID, Model: firstNonEmpty(c.config.Model, status.Model), chunks: chunks, - details: details, threadID: threadID, + details: details, threadID: threadID, message: message, validLines: validLines, validDeleted: validDeleted, diffText: diffText, @@ -316,8 +317,8 @@ func (c *AIController) RunWithProgress( return AIResult{}, err } findings, comments := state.Apply( - preview.details, combined, c.provider.Name(), model, preview.threadID, preview.validLines, - preview.validDeleted, preview.diffText, + preview.details, combined, c.provider.Name(), model, preview.threadID, preview.message, + preview.validLines, preview.validDeleted, preview.diffText, ) if err := c.store.Save(preview.details, state); err != nil { return AIResult{}, err diff --git a/ai_store.go b/ai_store.go index 2bf498f..12aadb0 100644 --- a/ai_store.go +++ b/ai_store.go @@ -111,6 +111,12 @@ func (s *aiStoredState) Merge(pr PRDetails) PRDetails { } for i := range result.Threads { if comments := s.Annotations[result.Threads[i].ID]; len(comments) > 0 { + comments = slices.Clone(comments) + for index := range comments { + if comments[index].Origin == reviewOriginLocalAIUser && pr.ViewerLogin != "" { + comments[index].Author = pr.ViewerLogin + } + } result.Threads[i].Comments = append(result.Threads[i].Comments, comments...) } } @@ -118,7 +124,7 @@ func (s *aiStoredState) Merge(pr PRDetails) PRDetails { } func (s *aiStoredState) Apply( - pr PRDetails, output aiOutput, provider, model, targetThread string, + pr PRDetails, output aiOutput, provider, model, targetThread, message string, validLines map[string]map[int]bool, validDeleted map[string]map[int]bool, diffText map[string]string, @@ -142,6 +148,9 @@ func (s *aiStoredState) Apply( for _, comment := range thread.Comments { existing[aiFingerprint(thread.Path, thread.StartLine, thread.Line, "", comment.Body)] = true existing[aiFingerprint(thread.ID, 0, 0, "", comment.Body)] = true + if comment.Origin == reviewOriginLocalAIUser { + existing[aiFingerprint(thread.ID, 0, 0, "user", comment.Body)] = true + } combined.WriteString(" ") combined.WriteString(comment.Body) } @@ -234,6 +243,18 @@ func (s *aiStoredState) Apply( if targetThread != "" { validThreads = map[string]bool{targetThread: true} } + message = safeAIText(message) + if targetThread != "" && strings.TrimSpace(message) != "" { + fingerprint := aiFingerprint(targetThread, 0, 0, "user", message) + if !existing[fingerprint] { + existing[fingerprint] = true + s.Annotations[targetThread] = append(s.Annotations[targetThread], ReviewComment{ + ID: "local-ai-user-" + fingerprint, + Author: firstNonEmpty(pr.ViewerLogin, "you"), Body: message, + CreatedAt: time.Now(), Origin: reviewOriginLocalAIUser, + }) + } + } for _, annotation := range output.ThreadComments { if !validThreads[annotation.ThreadID] { continue diff --git a/ai_test.go b/ai_test.go index 0eb9bda..2f988bb 100644 --- a/ai_test.go +++ b/ai_test.go @@ -399,7 +399,7 @@ func TestExistingLocalAIFindingCanGainSuggestion(t *testing.T) { }}, } added, _ := state.Apply( - PRDetails{}, aiOutput{Findings: []aiFinding{finding}}, "fake", "model", "", + PRDetails{}, aiOutput{Findings: []aiFinding{finding}}, "fake", "model", "", "", map[string]map[int]bool{"main.go": {2: true}}, nil, nil, ) if added != 0 { @@ -432,7 +432,9 @@ func TestAIStoreSkipsUnchangedWrites(t *testing.T) { func TestWithoutLocalAIDoesNotMutateVisibleDetails(t *testing.T) { remote := ReviewThread{ ID: "remote", Comments: []ReviewComment{ - {ID: "github"}, {ID: "local", Origin: reviewOriginLocalAI}, + {ID: "github"}, + {ID: "local", Origin: reviewOriginLocalAI}, + {ID: "local-user", Origin: reviewOriginLocalAIUser}, }, } local := ReviewThread{ID: "local-thread", Origin: reviewOriginLocalAI} @@ -441,11 +443,54 @@ func TestWithoutLocalAIDoesNotMutateVisibleDetails(t *testing.T) { if len(clean.Threads) != 1 || len(clean.Threads[0].Comments) != 1 { t.Fatalf("clean details = %#v", clean.Threads) } - if len(pr.Threads) != 2 || len(pr.Threads[0].Comments) != 2 { + if len(pr.Threads) != 2 || len(pr.Threads[0].Comments) != 3 { t.Fatal("filter mutated the visible PR details") } } +func TestAIDiscussionStoresUserMessageBeforeProviderResponse(t *testing.T) { + state := &aiStoredState{ + Version: 1, Annotations: make(map[string][]ReviewComment), + } + pr := PRDetails{Threads: []ReviewThread{{ID: "thread-1"}}} + output := aiOutput{ThreadComments: []struct { + ThreadID string `json:"thread_id"` + Body string `json:"body"` + }{{ThreadID: "thread-1", Body: "Provider response"}}} + + _, added := state.Apply( + pr, output, "codex", "model", "thread-1", "User follow-up", nil, nil, nil, + ) + comments := state.Annotations["thread-1"] + if added != 1 || len(comments) != 2 { + t.Fatalf("added=%d comments=%#v", added, comments) + } + if comments[0].Origin != reviewOriginLocalAIUser || + comments[0].Author != "you" || comments[0].Body != "User follow-up" { + t.Fatalf("user message = %#v", comments[0]) + } + if comments[1].Origin != reviewOriginLocalAI || + comments[1].Body != "Provider response" { + t.Fatalf("provider response = %#v", comments[1]) + } +} + +func TestAIDiscussionUsesViewerGitHubLogin(t *testing.T) { + state := &aiStoredState{ + Version: 1, Annotations: make(map[string][]ReviewComment), + } + pr := PRDetails{ + ViewerLogin: "pablu", + Threads: []ReviewThread{{ID: "thread-1"}}, + } + state.Apply(pr, aiOutput{}, "codex", "model", "thread-1", "Follow-up", nil, nil, nil) + comments := state.Annotations["thread-1"] + if len(comments) != 1 || comments[0].Author != "pablu" || + comments[0].Origin != reviewOriginLocalAIUser { + t.Fatalf("local user comment = %#v", comments) + } +} + func TestReplyOnLocalAIThreadStartsInlineDiscussion(t *testing.T) { config := defaultAIConfig() config.Enabled = true diff --git a/ai_tui.go b/ai_tui.go index 932d282..9c58440 100644 --- a/ai_tui.go +++ b/ai_tui.go @@ -342,7 +342,7 @@ func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) { } default: if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { - m.aiInput += string(key.Runes) + m.aiInput += textInputKeyValue(key) } } m.scroll = m.detailMaxScroll() @@ -391,7 +391,8 @@ func withoutLocalAI(pr PRDetails) PRDetails { func slicesDeleteLocalAIComments(comments []ReviewComment) []ReviewComment { result := comments[:0] for _, comment := range comments { - if comment.Origin != reviewOriginLocalAI { + if comment.Origin != reviewOriginLocalAI && + comment.Origin != reviewOriginLocalAIUser { result = append(result, comment) } } @@ -441,10 +442,9 @@ func (m App) viewAI() string { case aiDiscussion: lines = append(lines, titleStyle.Render("Local AI discussion"), "", dimStyle.Render("This message stays local; only the configured model receives it."), "") - draft := m.aiInput + "│" - for _, source := range strings.Split(draft, "\n") { - lines = append(lines, strings.Split(ansi.Wordwrap(source, width-2, ""), "\n")...) - } + lines = append(lines, renderTextInput( + m.aiInput, width-2, m.cursorOutput != nil, + )...) if m.err != nil { lines = append(lines, "", badStyle.Render(m.err.Error())) } @@ -587,10 +587,14 @@ func renderAIProgressBar(width int, progress AIRunProgress, spinner int) string } func localAICommentBadge(comment ReviewComment) string { - if comment.Origin != reviewOriginLocalAI { + switch comment.Origin { + case reviewOriginLocalAI: + return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]") + case reviewOriginLocalAIUser: + return " " + warnStyle.Render("[LOCAL ONLY]") + default: return "" } - return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]") } func (m App) inlineAIDiscussionLines(width int) []detailLine { @@ -603,17 +607,13 @@ func (m App) inlineAIDiscussionLines(width int) []detailLine { warnStyle.Render("[LOCAL ONLY]"), }, } - draft := m.aiInput + "│" - textWidth := max(1, width-4) + textWidth := max(1, width-5) lineIndex := 0 - for _, sourceLine := range strings.Split(draft, "\n") { - wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, textWidth, ""), textWidth, false) - for _, part := range strings.Split(wrapped, "\n") { - lines = append(lines, detailLine{ - rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part, - }) - lineIndex++ - } + for _, part := range renderTextInput(m.aiInput, textWidth, m.cursorOutput != nil) { + lines = append(lines, detailLine{ + rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part, + }) + lineIndex++ } if m.err != nil { lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())}) diff --git a/config.go b/config.go index 65b10e4..5be1b3e 100644 --- a/config.go +++ b/config.go @@ -71,6 +71,7 @@ type DisplayConfig struct { ThreadListWidthPercent int `toml:"thread_list_width_percent"` DashboardMode string `toml:"dashboard_mode"` CompactReviews bool `toml:"compact_reviews"` + ViewerLabel string `toml:"viewer_label"` } type PathConfig struct { @@ -105,6 +106,7 @@ func defaultConfig() Config { ThreadListWidthPercent: 33, DashboardMode: "hotkey", CompactReviews: true, + ViewerLabel: "login", }, Paths: PathConfig{ Scroll: false, @@ -208,6 +210,11 @@ func validateConfig(config Config) error { default: return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey") } + switch config.Display.ViewerLabel { + case "login", "you": + default: + return fmt.Errorf("display.viewer_label must be login or you") + } if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil { return err } diff --git a/config_test.go b/config_test.go index 464d128..4c94bee 100644 --- a/config_test.go +++ b/config_test.go @@ -40,6 +40,7 @@ fold_resolved = false thread_list_width_percent = 45 dashboard_mode = "hotkey" compact_reviews = false +viewer_label = "you" [paths] scroll = true @@ -75,7 +76,7 @@ up = ["ctrl+k"] got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || got.Display.DashboardMode != "hotkey" || - got.Display.CompactReviews || + got.Display.CompactReviews || got.Display.ViewerLabel != "you" || !got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond || strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled || @@ -325,6 +326,14 @@ func TestValidateConfigRejectsInvalidDashboardMode(t *testing.T) { } } +func TestValidateConfigRejectsInvalidViewerLabel(t *testing.T) { + config := defaultConfig() + config.Display.ViewerLabel = "me" + if err := validateConfig(config); err == nil { + t.Fatal("unknown viewer label was accepted") + } +} + func TestValidateConfigRejectsInvalidEditorMode(t *testing.T) { config := defaultConfig() config.Editing.Mode = "emacs" diff --git a/github.go b/github.go index 673a27f..b151564 100644 --- a/github.go +++ b/github.go @@ -433,6 +433,7 @@ func nullableCursor(cursor string) any { const detailsQuery = ` query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { + viewer { login } repository(owner: $owner, name: $name) { url mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission @@ -1092,6 +1093,7 @@ func (c *GitHubClient) allCheckAnnotations( func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) { var data struct { + Viewer githubActor Repository *struct { URL string ViewerPermission string `json:"viewerPermission"` @@ -1171,9 +1173,10 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n ID: node.ID, Owner: owner, Repository: name, RepoWithOwner: owner + "/" + name, Number: node.Number, Title: node.Title, URL: node.URL, Author: actorLogin(node.Author), IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, - ReviewCount: len(threadNodes), + ReviewCount: len(threadNodes), ViewerAuthored: actorLogin(node.Author) == data.Viewer.Login, }, - Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName, + ViewerLogin: data.Viewer.Login, + Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName, HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus, State: node.State, Merged: node.Merged, MergedAt: node.MergedAt, RepositoryURL: data.Repository.URL, diff --git a/github_test.go b/github_test.go index 384eb7d..6c0a462 100644 --- a/github_test.go +++ b/github_test.go @@ -454,7 +454,7 @@ func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing "nodes":[{"id":"review-2","body":"approved","state":"APPROVED","submittedAt":"2026-01-03T00:00:00Z"}] }}}}}`)) default: - _, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{ + _, _ = w.Write([]byte(`{"data":{"viewer":{"login":"current-user"},"repository":{"viewerPermission":"WRITE","pullRequest":{ "id":"pr","number":1,"title":"PR","url":"u","createdAt":"2026-01-01T00:00:00Z", "updatedAt":"2026-01-01T00:00:00Z","author":{"login":"alice"}, "assignees":{"nodes":[]},"labels":{"nodes":[]},"reviewRequests":{"nodes":[]}, @@ -485,6 +485,9 @@ func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing if !got.Permissions.CanResolveAny || got.Permissions.Repository != "WRITE" { t.Fatalf("permissions = %#v", got.Permissions) } + if got.ViewerLogin != "current-user" { + t.Fatalf("viewer login = %q", got.ViewerLogin) + } } func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { diff --git a/main.go b/main.go index c6a5d1b..7c557bb 100644 --- a/main.go +++ b/main.go @@ -179,6 +179,7 @@ func main() { ThreadListWidthPercent: config.Display.ThreadListWidthPercent, DashboardMode: config.Display.DashboardMode, CompactReviews: config.Display.CompactReviews, + ViewerLabel: config.Display.ViewerLabel, ReadState: loadReadState(statePath), Drafts: loadDraftStore(draftPath), PathScroll: config.Paths.Scroll, diff --git a/tui.go b/tui.go index cfa059d..6ce1b12 100644 --- a/tui.go +++ b/tui.go @@ -164,6 +164,7 @@ type App struct { dashboardMode string dashboardReturn screen compactReviews bool + viewerLabel string editorMode string keybindings KeyBindings readState *readStateStore @@ -197,6 +198,7 @@ type AppSettings struct { ThreadWithinStatus string DashboardMode string CompactReviews bool + ViewerLabel string EditorMode string KeyBindings KeyBindings ReadState *readStateStore @@ -214,6 +216,7 @@ func defaultAppSettings() AppSettings { ThreadWithinStatus: "file", DashboardMode: "hotkey", CompactReviews: true, + ViewerLabel: "login", EditorMode: "vim", KeyBindings: defaultKeyBindings(), } @@ -246,6 +249,7 @@ func NewAppWithSettings( healthReturn: prScreen, requests: &requestCoordinator{}, compactReviews: settings.CompactReviews, + viewerLabel: firstNonEmpty(settings.ViewerLabel, "login"), editorMode: settings.EditorMode, keybindings: settings.KeyBindings, readState: state, @@ -568,7 +572,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { m.err = nil default: if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { - m.replyDraft += string(key.Runes) + m.replyDraft += textInputKeyValue(key) m.err = nil } } @@ -1061,7 +1065,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.scroll = 0 default: if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { - m.searchQuery += string(key.Runes) + m.searchQuery += textInputKeyValue(key) m.selectBestSearchMatch() } } @@ -1504,7 +1508,7 @@ func (m App) matchingThreadIndices() []int { if filter.updated && !m.updatedThreads[thread.ID] { continue } - if filter.author != "" && !threadHasAuthor(thread, filter.author) { + if filter.author != "" && !m.threadHasAuthor(thread, filter.author) { continue } if score, ok := fuzzyPathScore(thread.Path, filter.path); ok { @@ -1559,9 +1563,10 @@ func parseThreadFilter(query string) threadFilter { return filter } -func threadHasAuthor(thread ReviewThread, author string) bool { +func (m App) threadHasAuthor(thread ReviewThread, author string) bool { for _, comment := range thread.Comments { - if strings.Contains(strings.ToLower(comment.Author), author) { + if strings.Contains(strings.ToLower(comment.Author), author) || + strings.Contains(strings.ToLower(m.displayAuthor(comment.Author)), author) { return true } } @@ -1708,10 +1713,7 @@ func (m App) dashboardMaxScroll() int { } func (m App) detailPaneSize() (int, int) { - topLines := 3 - if m.details.ThreadsTruncated { - topLines++ - } + topLines := m.threadTopLineCount() height := max(3, m.height-topLines-1) if m.width < 70 || m.listHidden { return max(3, m.width), height @@ -1720,6 +1722,17 @@ func (m App) detailPaneSize() (int, int) { return max(20, m.width-leftWidth-1), height } +func (m App) threadTopLineCount() int { + topLines := 3 + if m.details.FromCache { + topLines++ + } + if m.details.ThreadsTruncated { + topLines++ + } + return topLines +} + func (m App) threadListWidth() int { percent := m.threadListWidthPercent if percent == 0 { @@ -1735,7 +1748,7 @@ func (m App) detailViewportHeight() int { func (m App) detailMaxScroll() int { width, _ := m.detailPaneSize() - return max(0, len(m.detailLines(width))-m.detailViewportHeight()) + return max(0, len(m.renderedDetailLines(width))-m.detailViewportHeight()) } func (m App) View() string { @@ -1783,11 +1796,9 @@ func (m App) viewWritePopup() string { titleStyle.Render("Reply to " + location), "", } - draft := m.replyDraft + "█" - for _, sourceLine := range strings.Split(draft, "\n") { - wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width-2, ""), width-2, false) - lines = append(lines, strings.Split(wrapped, "\n")...) - } + lines = append(lines, renderTextInput( + m.replyDraft, width-2, m.cursorOutput != nil, + )...) if m.err != nil { lines = append(lines, "", badStyle.Render(m.err.Error())) } @@ -2569,17 +2580,17 @@ func (m App) dashboardLines() []string { } lines = append(lines, "", - dashboardMetadata("author", authorStyle(pr.Author).Render("@"+pr.Author)), + dashboardMetadata("author", m.authorText(pr.Author)), dashboardMetadata("branches", pr.HeadRef+" → "+pr.BaseRef), dashboardMetadata("review", reviewAndMergeState(pr)), dashboardMetadata("checks", coloredState(pr.CheckState)), dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")), - dashboardMetadata("auto-merge", autoMergeStateText(pr)), + dashboardMetadata("auto-merge", m.autoMergeStateText(pr)), ) lines = append(lines, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...) lines = append(lines, - dashboardMetadata("assignees", handlesText(pr.Assignees)), - dashboardMetadata("reviewers", reviewersText(pr.Reviewers)), + dashboardMetadata("assignees", m.handlesText(pr.Assignees)), + dashboardMetadata("reviewers", m.reviewersText(pr.Reviewers)), dashboardMetadata("labels", labels), dashboardMetadata("milestone", milestone), dashboardMetadata("activity", fmt.Sprintf( @@ -2654,10 +2665,10 @@ func (m App) dashboardLines() []string { when := event.CreatedAt.Local().Format("2006-01-02 15:04") if event.Kind == "force-push" { lines = append(lines, warnStyle.Render("force-push")+" "+shortOID(event.BeforeOID)+" → "+ - shortOID(event.AfterOID)+" "+authorStyle(event.Author).Render("@"+event.Author)+" "+dimStyle.Render(when)) + shortOID(event.AfterOID)+" "+m.authorText(event.Author)+" "+dimStyle.Render(when)) } else { lines = append(lines, shortOID(event.OID)+" "+truncate(event.Title, max(10, width-35))+ - " "+authorStyle(event.Author).Render("@"+event.Author)+" "+dimStyle.Render(when)) + " "+m.authorText(event.Author)+" "+dimStyle.Render(when)) } } lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Applicable rulesets (%d)", applicableRulesetCount(pr.Rulesets))), "") @@ -2680,13 +2691,13 @@ func (m App) dashboardLines() []string { lines = append(lines, dimStyle.Render("No submitted reviews.")) } if m.compactReviews { - lines = append(lines, compactReviewLines(pr.Reviews, width)...) + lines = append(lines, m.compactReviewLines(pr.Reviews, width)...) if len(pr.Reviews) > 0 { lines = append(lines, "") } } else { for _, review := range pr.Reviews { - header := authorStyle(review.Author).Render("@"+review.Author) + " " + + header := m.authorText(review.Author) + " " + coloredState(review.State) if !review.SubmittedAt.IsZero() { header += " " + dimStyle.Render(review.SubmittedAt.Local().Format("2006-01-02 15:04")) @@ -2706,7 +2717,7 @@ func (m App) dashboardLines() []string { lines = append(lines, dimStyle.Render("No PR conversation comments.")) } for _, comment := range pr.Conversation { - header := authorStyle(comment.Author).Render("@" + comment.Author) + header := m.authorText(comment.Author) if !comment.CreatedAt.IsZero() { header += " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")) } @@ -2717,7 +2728,7 @@ func (m App) dashboardLines() []string { return lines } -func compactReviewLines(reviews []ReviewSummary, width int) []string { +func (m App) compactReviewLines(reviews []ReviewSummary, width int) []string { if len(reviews) == 0 { return nil } @@ -2751,13 +2762,13 @@ func compactReviewLines(reviews []ReviewSummary, width int) []string { } for _, author := range authors { summary = append(summary, - authorStyle(author).Render("@"+author)+dimStyle.Render(fmt.Sprintf(" ×%d", authorCounts[author])), + m.authorText(author)+dimStyle.Render(fmt.Sprintf(" ×%d", authorCounts[author])), ) } lines := []string{ansi.Truncate(strings.Join(summary, dimStyle.Render(" • ")), width, "…")} for _, review := range reviews { if body := compactReviewBody(review.Body, width); body != "" { - line := authorStyle(review.Author).Render("@"+review.Author) + " " + + line := m.authorText(review.Author) + " " + coloredState(review.State) + dimStyle.Render(" — ") + body lines = append(lines, ansi.Truncate(line, width, "…")) } @@ -2961,7 +2972,7 @@ func (m App) viewThreads() string { pr := m.details header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12)))) meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr)) - people := "assignees: " + handlesText(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers) + people := "assignees: " + m.handlesText(pr.Assignees) + " reviewers: " + m.reviewersText(pr.Reviewers) top := []string{header, meta, people} if pr.FromCache { top = append(top, warnStyle.Render( @@ -3023,7 +3034,12 @@ func (m App) viewThreads() string { primaryKeyLabel(m.keybindings.Input.Cancel), ) } - return m.frame(append(top, body), help) + m.positionThreadInputHardwareCursor() + view := m.frame(append(top, body), help) + if m.cursorOutput != nil { + view += m.cursorOutput.FrameMarker() + } + return view } func (m App) threadList(width, height int) string { @@ -3034,7 +3050,8 @@ func (m App) threadList(width, height int) string { if m.searching { queryWidth := max(1, innerWidth-len("Filter: ")-1) query := ansi.Truncate(m.searchQuery, queryWidth, "…") - lines = append(lines, titleStyle.Render("Filter: ")+query+"█") + lines = append(lines, titleStyle.Render("Filter: ")+query+ + inputCursorFallback(m.cursorOutput != nil)) } if m.loading && len(m.details.Threads) == 0 { lines = append(lines, "Loading…") @@ -3085,7 +3102,7 @@ func (m App) threadDetail(width, height int) string { if len(m.details.Threads) == 0 { return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane) } - lines := m.detailLines(width) + lines := m.renderedDetailLines(width) viewportHeight := max(1, height-2) maxScroll := max(0, len(lines)-viewportHeight) scroll := min(m.scroll, maxScroll) @@ -3099,7 +3116,7 @@ func (m App) threadDetail(width, height int) string { if line.suggestionChange != 0 { renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange) } else { - renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "…") + renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "") } renderedLine = line.rail + renderedLine if line.selected { @@ -3176,7 +3193,7 @@ func (m App) detailLines(width int) []detailLine { lines = append(lines, detailLine{}, detailLine{ rail: rail, anchor: "comment:" + comment.ID + ":header", - text: authorStyle(comment.Author).Render("@"+comment.Author) + + text: m.commentAuthorText(comment) + localAICommentBadge(comment) + " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")), }) @@ -3233,6 +3250,32 @@ func (m App) detailLines(width int) []detailLine { return lines } +func (m App) renderedDetailLines(width int) []detailLine { + lines := m.detailLines(width) + innerWidth := max(1, width-2) + rendered := make([]detailLine, 0, len(lines)) + for _, line := range lines { + contentWidth := max(1, innerWidth-ansi.StringWidth(line.rail)) + content := line.fixed + line.text + if line.suggestionChange != 0 || ansi.StringWidth(content) <= contentWidth { + rendered = append(rendered, line) + continue + } + for index, wrapped := range strings.Split( + ansi.Hardwrap(content, contentWidth, true), "\n", + ) { + continuation := line + continuation.fixed = "" + continuation.text = wrapped + if index > 0 && continuation.anchor != "" { + continuation.anchor += fmt.Sprintf(":wrap:%d", index) + } + rendered = append(rendered, continuation) + } + } + return rendered +} + func renderReactionSummary(reactions []ReactionSummary, width int) []string { var badges []string for _, reaction := range reactions { @@ -3300,17 +3343,13 @@ func (m App) inlineReplyLines(width int) []detailLine { {}, {rail: rail, anchor: "reply:header", text: titleStyle.Render("Reply draft")}, } - draft := m.replyDraft + "█" - textWidth := max(1, width-4) + textWidth := max(1, width-5) lineIndex := 0 - for _, sourceLine := range strings.Split(draft, "\n") { - wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, textWidth, ""), textWidth, false) - for _, part := range strings.Split(wrapped, "\n") { - lines = append(lines, detailLine{ - rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part, - }) - lineIndex++ - } + for _, part := range renderTextInput(m.replyDraft, textWidth, m.cursorOutput != nil) { + lines = append(lines, detailLine{ + rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part, + }) + lineIndex++ } if m.err != nil { lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())}) @@ -3327,6 +3366,96 @@ func (m App) inlineReplyLines(width int) []detailLine { return lines } +func inputCursorFallback(hardwareCursor bool) string { + if hardwareCursor { + return "" + } + return "\x1b[4m \x1b[24m" +} + +func renderTextInput(value string, width int, hardwareCursor bool) []string { + const cursorSentinel = "\ue000" + if hardwareCursor { + value += cursorSentinel + } else { + value += inputCursorFallback(false) + } + var lines []string + for _, sourceLine := range strings.Split(value, "\n") { + wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width, ""), width, false) + if hardwareCursor { + wrapped = strings.TrimSuffix(wrapped, cursorSentinel) + } + lines = append(lines, strings.Split(wrapped, "\n")...) + } + return lines +} + +func textInputKeyValue(key tea.KeyMsg) string { + if key.Type == tea.KeySpace { + return " " + } + return string(key.Runes) +} + +func (m App) positionThreadInputHardwareCursor() { + if m.cursorOutput == nil { + return + } + topLines := m.threadTopLineCount() + if m.searching { + if m.width >= 70 && m.listHidden { + return + } + paneWidth := m.width + if m.width >= 70 { + paneWidth = m.threadListWidth() + } + queryWidth := max(1, max(1, paneWidth-2)-len("Filter: ")-1) + query := ansi.Truncate(m.searchQuery, queryWidth, "…") + m.cursorOutput.SetCursor( + true, + 2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query), + topLines+3, + ) + return + } + if m.writeMode != writeReply && m.aiMode != aiDiscussion { + return + } + width, height := m.detailPaneSize() + lines := m.renderedDetailLines(width) + prefix := "reply:body:" + if m.aiMode == aiDiscussion { + prefix = "ai-discussion:body:" + } + cursorLine := -1 + for index := range lines { + if strings.HasPrefix(lines[index].anchor, prefix) { + cursorLine = index + } + } + if cursorLine < 0 { + return + } + viewportHeight := max(1, height-2) + scroll := min(m.scroll, max(0, len(lines)-viewportHeight)) + screenLine := cursorLine - scroll + if screenLine < 0 || screenLine >= viewportHeight { + return + } + paneStart := 0 + if m.width >= 70 && !m.listHidden { + paneStart = m.threadListWidth() + 1 + } + line := lines[cursorLine] + m.cursorOutput.SetCursor( + true, + paneStart+2+ansi.StringWidth(line.rail)+ansi.StringWidth(line.fixed+line.text), + topLines+2+screenLine, + ) +} + func latestForcePush(events []TimelineEvent) time.Time { var latest time.Time for _, event := range events { @@ -3339,7 +3468,7 @@ func latestForcePush(events []TimelineEvent) time.Time { func (m App) detailScrollAnchor() string { width, _ := m.detailPaneSize() - lines := m.detailLines(width) + lines := m.renderedDetailLines(width) for index := min(m.scroll, len(lines)-1); index >= 0; index-- { if lines[index].anchor != "" { return lines[index].anchor @@ -3350,7 +3479,7 @@ func (m App) detailScrollAnchor() string { func (m *App) restoreDetailAnchor(anchor string) { width, _ := m.detailPaneSize() - for index, line := range m.detailLines(width) { + for index, line := range m.renderedDetailLines(width) { if line.anchor == anchor { m.scroll = min(index, m.detailMaxScroll()) return @@ -3550,6 +3679,35 @@ func authorStyle(login string) lipgloss.Style { return lipgloss.NewStyle().Bold(true).Foreground(authorColor(login)) } +func (m App) viewerLogin() string { + if m.details.ViewerLogin != "" { + return m.details.ViewerLogin + } + if m.details.ViewerAuthored { + return m.details.Author + } + return "" +} + +func (m App) displayAuthor(login string) string { + if m.viewerLabel == "you" && strings.EqualFold(login, m.viewerLogin()) { + return "you" + } + return login +} + +func (m App) authorText(login string) string { + return authorStyle(login).Render("@" + m.displayAuthor(login)) +} + +func (m App) commentAuthorText(comment ReviewComment) string { + login := comment.Author + if comment.Origin == reviewOriginLocalAIUser && m.viewerLogin() != "" { + login = m.viewerLogin() + } + return m.authorText(login) +} + func authorColor(login string) lipgloss.Color { hash := fnv.New32a() _, _ = hash.Write([]byte(strings.ToLower(login))) @@ -3652,7 +3810,7 @@ func conflictStateText(pr PRDetails) string { } } -func autoMergeStateText(pr PRDetails) string { +func (m App) autoMergeStateText(pr PRDetails) string { if pr.Merged { return okStyle.Render("merged") } @@ -3661,7 +3819,7 @@ func autoMergeStateText(pr PRDetails) string { } text := okStyle.Render("enabled") + " " + strings.ToLower(pr.AutoMerge.MergeMethod) if pr.AutoMerge.EnabledBy != "" { - text += " by " + authorStyle(pr.AutoMerge.EnabledBy).Render("@"+pr.AutoMerge.EnabledBy) + text += " by " + m.authorText(pr.AutoMerge.EnabledBy) } return text } @@ -3684,26 +3842,26 @@ func coloredState(state string) string { } } -func reviewersText(reviewers []Reviewer) string { +func (m App) reviewersText(reviewers []Reviewer) string { if len(reviewers) == 0 { return "none" } items := make([]string, 0, len(reviewers)) for _, reviewer := range reviewers { items = append(items, - authorStyle(reviewer.Login).Render("@"+reviewer.Login)+ + m.authorText(reviewer.Login)+ dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"), ) } return strings.Join(items, ", ") } -func handlesText(items []string) string { +func (m App) handlesText(items []string) string { if len(items) == 0 { return "none" } handles := make([]string, 0, len(items)) for _, item := range items { - handles = append(handles, authorStyle(item).Render("@"+item)) + handles = append(handles, m.authorText(item)) } return strings.Join(handles, ", ") } diff --git a/tui_test.go b/tui_test.go index 5f1a128..edb6a25 100644 --- a/tui_test.go +++ b/tui_test.go @@ -1013,6 +1013,65 @@ func TestDashboardHardwareCursorMovementChangesZeroWidthFrameMarker(t *testing.T } } +func TestThreadInputsUseHardwareCursor(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "cursor-output") + if err != nil { + t.Fatal(err) + } + defer file.Close() + + m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) + m.cursorOutput = newTerminalCursorOutput(file) + m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24 + m.details = PRDetails{ + PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"}, + Threads: []ReviewThread{{ + ID: "thread-1", Path: "main.go", Line: 1, + Comments: []ReviewComment{{ID: "comment-1", Author: "alice", Body: "Review"}}, + }}, + } + + m.searching, m.searchQuery = true, "main" + _ = m.viewThreads() + m.cursorOutput.mu.Lock() + searchVisible, searchColumn := m.cursorOutput.visible, m.cursorOutput.column + m.cursorOutput.mu.Unlock() + if !searchVisible || searchColumn <= 0 { + t.Fatalf("search cursor visible=%v column=%d", searchVisible, searchColumn) + } + + m.searching = false + m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread-1", "Question" + m.focus = threadDetailPane + m.scroll = m.detailMaxScroll() + _ = m.viewThreads() + m.cursorOutput.mu.Lock() + discussionVisible, beforeSpaceColumn := m.cursorOutput.visible, m.cursorOutput.column + m.cursorOutput.mu.Unlock() + if !discussionVisible || beforeSpaceColumn <= searchColumn { + t.Fatalf( + "discussion cursor visible=%v column=%d search column=%d", + discussionVisible, beforeSpaceColumn, searchColumn, + ) + } + updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace}) + m = updated.(App) + view := m.viewThreads() + m.cursorOutput.mu.Lock() + afterSpaceColumn := m.cursorOutput.column + m.cursorOutput.mu.Unlock() + if !handled || m.aiInput != "Question " || afterSpaceColumn != beforeSpaceColumn+1 { + t.Fatalf( + "handled=%v input=%q cursor before=%d after=%d", + handled, m.aiInput, beforeSpaceColumn, afterSpaceColumn, + ) + } + if strings.Contains(ansi.Strip(view), "█") || + strings.Contains(ansi.Strip(view), "Question│") { + t.Fatalf("thread input still contains a painted cursor: %q", ansi.Strip(view)) + } +} + func TestDashboardEditorNormalizesMixedLineEndingsWithoutCreatingAnEdit(t *testing.T) { const remoteBody = "first\nsecond\r\nthird\r" m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) @@ -1103,6 +1162,38 @@ func TestReplyComposerRendersInlineWithCurrentThread(t *testing.T) { } } +func TestLongThreadCommentRemainsReachableByScrolling(t *testing.T) { + m := NewApp(nil, "o", "r", false, 50, time.Second) + m.screen, m.loading, m.width, m.height = threadScreen, false, 60, 12 + m.listHidden, m.focus = true, threadDetailPane + body := strings.Repeat("abcdefghij", 40) + "FINALMARKER" + m.details = PRDetails{ + PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"}, + Threads: []ReviewThread{{ + ID: "thread", Path: "main.go", Line: 12, + Comments: []ReviewComment{{ + ID: "comment", Author: "reviewer", Body: body, + }}, + }}, + } + + width, _ := m.detailPaneSize() + lines := m.renderedDetailLines(width) + for _, line := range lines { + available := max(1, width-2-ansi.StringWidth(line.rail)) + if got := ansi.StringWidth(line.fixed + line.text); got > available { + t.Fatalf("detail line width=%d available=%d text=%q", got, available, ansi.Strip(line.text)) + } + } + if m.detailMaxScroll() == 0 { + t.Fatal("long comment did not produce scrollable detail rows") + } + m.scroll = m.detailMaxScroll() + if view := ansi.Strip(m.viewThreads()); !strings.Contains(view, "FINALMARKER") { + t.Fatalf("final comment content is not reachable at maximum scroll:\n%s", view) + } +} + func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) { service := &recordingService{} m := NewApp(service, "o", "r", false, 50, time.Second) @@ -1494,12 +1585,29 @@ func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.searching, m.searchQuery = threadScreen, true, "ng" - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace}) if got := updated.(App).searchQuery; got != "ng " { t.Fatalf("space key produced search query %q", got) } } +func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) { + m := NewApp(nil, "o", "r", false, 50, 10*time.Second) + m.writeMode, m.replyDraft = writeReply, "reply" + updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeySpace}) + m = updated.(App) + if m.replyDraft != "reply " { + t.Fatalf("space key produced reply draft %q", m.replyDraft) + } + + m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "question" + updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace}) + m = updated.(App) + if !handled || m.aiInput != "question " { + t.Fatalf("handled=%v space key produced AI input %q", handled, m.aiInput) + } +} + func TestFileSearchCanJumpOrCancel(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen @@ -1809,8 +1917,9 @@ func TestDetailsLoadUsesSelectedPRRepository(t *testing.T) { } func TestPeopleMetadataUsesColoredHandles(t *testing.T) { - assignees := handlesText([]string{"alice"}) - reviewers := reviewersText([]Reviewer{{Login: "bob", State: "APPROVED"}}) + m := App{} + assignees := m.handlesText([]string{"alice"}) + reviewers := m.reviewersText([]Reviewer{{Login: "bob", State: "APPROVED"}}) if ansi.Strip(assignees) != "@alice" || ansi.Strip(reviewers) != "@bob (approved)" { t.Fatalf("people metadata = %q / %q", ansi.Strip(assignees), ansi.Strip(reviewers)) } @@ -1820,6 +1929,29 @@ func TestPeopleMetadataUsesColoredHandles(t *testing.T) { } } +func TestViewerLabelCanReplaceGitHubLoginWithYou(t *testing.T) { + m := App{ + viewerLabel: "you", + details: PRDetails{ + ViewerLogin: "pablu", + }, + } + if got := ansi.Strip(m.authorText("pablu")); got != "@you" { + t.Fatalf("viewer author = %q", got) + } + if got := ansi.Strip(m.authorText("reviewer")); got != "@reviewer" { + t.Fatalf("other author = %q", got) + } + + m.viewerLabel = "login" + localUser := ReviewComment{ + Author: "you", Origin: reviewOriginLocalAIUser, + } + if got := ansi.Strip(m.commentAuthorText(localUser)); got != "@pablu" { + t.Fatalf("local AI user author = %q", got) + } +} + func TestDashboardFrameDoesNotChangeWhenBackgroundRefreshStarts(t *testing.T) { m := NewApp(&recordingService{}, "", "", false, 50, time.Minute) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 100, 30 diff --git a/types.go b/types.go index b040867..1e0831f 100644 --- a/types.go +++ b/types.go @@ -21,6 +21,7 @@ type PullRequest struct { type PRDetails struct { PullRequest + ViewerLogin string Body string CreatedAt time.Time BaseRef string @@ -235,7 +236,10 @@ type ReviewComment struct { Model string } -const reviewOriginLocalAI = "local-ai" +const ( + reviewOriginLocalAI = "local-ai" + reviewOriginLocalAIUser = "local-ai-user" +) type ReactionSummary struct { Content string