fix QoL and Ai integration

This commit is contained in:
2026-07-29 09:13:16 +02:00
parent 07e8bc2f5b
commit a82f5e7e9f
13 changed files with 472 additions and 84 deletions

View File

@@ -236,6 +236,7 @@ fold_resolved = true
thread_list_width_percent = 33 # 20-60 thread_list_width_percent = 33 # 20-60
dashboard_mode = "hotkey" # "hotkey" or "intermediate" dashboard_mode = "hotkey" # "hotkey" or "intermediate"
compact_reviews = true compact_reviews = true
viewer_label = "login" # "login" or "you"
[paths] [paths]
scroll = false scroll = false
@@ -281,6 +282,9 @@ and thread viewer.
`compact_reviews = true` summarizes the submitted-review history instead of `compact_reviews = true` summarizes the submitted-review history instead of
showing every repeated `COMMENTED` event. 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: Thread categories are:
- `unresolved`: current unresolved threads; - `unresolved`: current unresolved threads;

7
ai.go
View File

@@ -145,6 +145,7 @@ type AIPreview struct {
chunks []string chunks []string
details PRDetails details PRDetails
threadID string threadID string
message string
validLines map[string]map[int]bool validLines map[string]map[int]bool
validDeleted map[string]map[int]bool validDeleted map[string]map[int]bool
diffText map[string]string diffText map[string]string
@@ -243,7 +244,7 @@ func (c *AIController) Prepare(ctx context.Context, details PRDetails, threadID,
Included: included, Included: included,
Redactions: redactions, HeadOID: details.HeadOID, Redactions: redactions, HeadOID: details.HeadOID,
Model: firstNonEmpty(c.config.Model, status.Model), chunks: chunks, Model: firstNonEmpty(c.config.Model, status.Model), chunks: chunks,
details: details, threadID: threadID, details: details, threadID: threadID, message: message,
validLines: validLines, validLines: validLines,
validDeleted: validDeleted, validDeleted: validDeleted,
diffText: diffText, diffText: diffText,
@@ -316,8 +317,8 @@ func (c *AIController) RunWithProgress(
return AIResult{}, err return AIResult{}, err
} }
findings, comments := state.Apply( findings, comments := state.Apply(
preview.details, combined, c.provider.Name(), model, preview.threadID, preview.validLines, preview.details, combined, c.provider.Name(), model, preview.threadID, preview.message,
preview.validDeleted, preview.diffText, preview.validLines, preview.validDeleted, preview.diffText,
) )
if err := c.store.Save(preview.details, state); err != nil { if err := c.store.Save(preview.details, state); err != nil {
return AIResult{}, err return AIResult{}, err

View File

@@ -111,6 +111,12 @@ func (s *aiStoredState) Merge(pr PRDetails) PRDetails {
} }
for i := range result.Threads { for i := range result.Threads {
if comments := s.Annotations[result.Threads[i].ID]; len(comments) > 0 { 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...) 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( 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, validLines map[string]map[int]bool,
validDeleted map[string]map[int]bool, validDeleted map[string]map[int]bool,
diffText map[string]string, diffText map[string]string,
@@ -142,6 +148,9 @@ func (s *aiStoredState) Apply(
for _, comment := range thread.Comments { for _, comment := range thread.Comments {
existing[aiFingerprint(thread.Path, thread.StartLine, thread.Line, "", comment.Body)] = true existing[aiFingerprint(thread.Path, thread.StartLine, thread.Line, "", comment.Body)] = true
existing[aiFingerprint(thread.ID, 0, 0, "", 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(" ")
combined.WriteString(comment.Body) combined.WriteString(comment.Body)
} }
@@ -234,6 +243,18 @@ func (s *aiStoredState) Apply(
if targetThread != "" { if targetThread != "" {
validThreads = map[string]bool{targetThread: true} 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 { for _, annotation := range output.ThreadComments {
if !validThreads[annotation.ThreadID] { if !validThreads[annotation.ThreadID] {
continue continue

View File

@@ -399,7 +399,7 @@ func TestExistingLocalAIFindingCanGainSuggestion(t *testing.T) {
}}, }},
} }
added, _ := state.Apply( 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, map[string]map[int]bool{"main.go": {2: true}}, nil, nil,
) )
if added != 0 { if added != 0 {
@@ -432,7 +432,9 @@ func TestAIStoreSkipsUnchangedWrites(t *testing.T) {
func TestWithoutLocalAIDoesNotMutateVisibleDetails(t *testing.T) { func TestWithoutLocalAIDoesNotMutateVisibleDetails(t *testing.T) {
remote := ReviewThread{ remote := ReviewThread{
ID: "remote", Comments: []ReviewComment{ 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} 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 { if len(clean.Threads) != 1 || len(clean.Threads[0].Comments) != 1 {
t.Fatalf("clean details = %#v", clean.Threads) 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") 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) { func TestReplyOnLocalAIThreadStartsInlineDiscussion(t *testing.T) {
config := defaultAIConfig() config := defaultAIConfig()
config.Enabled = true config.Enabled = true

View File

@@ -342,7 +342,7 @@ func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
} }
default: default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
m.aiInput += string(key.Runes) m.aiInput += textInputKeyValue(key)
} }
} }
m.scroll = m.detailMaxScroll() m.scroll = m.detailMaxScroll()
@@ -391,7 +391,8 @@ func withoutLocalAI(pr PRDetails) PRDetails {
func slicesDeleteLocalAIComments(comments []ReviewComment) []ReviewComment { func slicesDeleteLocalAIComments(comments []ReviewComment) []ReviewComment {
result := comments[:0] result := comments[:0]
for _, comment := range comments { for _, comment := range comments {
if comment.Origin != reviewOriginLocalAI { if comment.Origin != reviewOriginLocalAI &&
comment.Origin != reviewOriginLocalAIUser {
result = append(result, comment) result = append(result, comment)
} }
} }
@@ -441,10 +442,9 @@ func (m App) viewAI() string {
case aiDiscussion: case aiDiscussion:
lines = append(lines, titleStyle.Render("Local AI discussion"), "", lines = append(lines, titleStyle.Render("Local AI discussion"), "",
dimStyle.Render("This message stays local; only the configured model receives it."), "") dimStyle.Render("This message stays local; only the configured model receives it."), "")
draft := m.aiInput + "│" lines = append(lines, renderTextInput(
for _, source := range strings.Split(draft, "\n") { m.aiInput, width-2, m.cursorOutput != nil,
lines = append(lines, strings.Split(ansi.Wordwrap(source, width-2, ""), "\n")...) )...)
}
if m.err != nil { if m.err != nil {
lines = append(lines, "", badStyle.Render(m.err.Error())) 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 { 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 ""
} }
return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]")
} }
func (m App) inlineAIDiscussionLines(width int) []detailLine { func (m App) inlineAIDiscussionLines(width int) []detailLine {
@@ -603,17 +607,13 @@ func (m App) inlineAIDiscussionLines(width int) []detailLine {
warnStyle.Render("[LOCAL ONLY]"), warnStyle.Render("[LOCAL ONLY]"),
}, },
} }
draft := m.aiInput + "│" textWidth := max(1, width-5)
textWidth := max(1, width-4)
lineIndex := 0 lineIndex := 0
for _, sourceLine := range strings.Split(draft, "\n") { for _, part := range renderTextInput(m.aiInput, textWidth, m.cursorOutput != nil) {
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, textWidth, ""), textWidth, false) lines = append(lines, detailLine{
for _, part := range strings.Split(wrapped, "\n") { rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part,
lines = append(lines, detailLine{ })
rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part, lineIndex++
})
lineIndex++
}
} }
if m.err != nil { if m.err != nil {
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())}) lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})

View File

@@ -71,6 +71,7 @@ type DisplayConfig struct {
ThreadListWidthPercent int `toml:"thread_list_width_percent"` ThreadListWidthPercent int `toml:"thread_list_width_percent"`
DashboardMode string `toml:"dashboard_mode"` DashboardMode string `toml:"dashboard_mode"`
CompactReviews bool `toml:"compact_reviews"` CompactReviews bool `toml:"compact_reviews"`
ViewerLabel string `toml:"viewer_label"`
} }
type PathConfig struct { type PathConfig struct {
@@ -105,6 +106,7 @@ func defaultConfig() Config {
ThreadListWidthPercent: 33, ThreadListWidthPercent: 33,
DashboardMode: "hotkey", DashboardMode: "hotkey",
CompactReviews: true, CompactReviews: true,
ViewerLabel: "login",
}, },
Paths: PathConfig{ Paths: PathConfig{
Scroll: false, Scroll: false,
@@ -208,6 +210,11 @@ func validateConfig(config Config) error {
default: default:
return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey") 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 { if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil {
return err return err
} }

View File

@@ -40,6 +40,7 @@ fold_resolved = false
thread_list_width_percent = 45 thread_list_width_percent = 45
dashboard_mode = "hotkey" dashboard_mode = "hotkey"
compact_reviews = false compact_reviews = false
viewer_label = "you"
[paths] [paths]
scroll = true scroll = true
@@ -75,7 +76,7 @@ up = ["ctrl+k"]
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
got.Display.DashboardMode != "hotkey" || got.Display.DashboardMode != "hotkey" ||
got.Display.CompactReviews || got.Display.CompactReviews || got.Display.ViewerLabel != "you" ||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond || !got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled || 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) { func TestValidateConfigRejectsInvalidEditorMode(t *testing.T) {
config := defaultConfig() config := defaultConfig()
config.Editing.Mode = "emacs" config.Editing.Mode = "emacs"

View File

@@ -433,6 +433,7 @@ func nullableCursor(cursor string) any {
const detailsQuery = ` const detailsQuery = `
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
viewer { login }
repository(owner: $owner, name: $name) { repository(owner: $owner, name: $name) {
url mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed url mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed
viewerPermission viewerPermission
@@ -1092,6 +1093,7 @@ func (c *GitHubClient) allCheckAnnotations(
func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) { func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) {
var data struct { var data struct {
Viewer githubActor
Repository *struct { Repository *struct {
URL string URL string
ViewerPermission string `json:"viewerPermission"` 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, ID: node.ID, Owner: owner, Repository: name, RepoWithOwner: owner + "/" + name,
Number: node.Number, Title: node.Title, URL: node.URL, Number: node.Number, Title: node.Title, URL: node.URL,
Author: actorLogin(node.Author), IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, 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, HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
State: node.State, Merged: node.Merged, MergedAt: node.MergedAt, State: node.State, Merged: node.Merged, MergedAt: node.MergedAt,
RepositoryURL: data.Repository.URL, RepositoryURL: data.Repository.URL,

View File

@@ -454,7 +454,7 @@ func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing
"nodes":[{"id":"review-2","body":"approved","state":"APPROVED","submittedAt":"2026-01-03T00:00:00Z"}] "nodes":[{"id":"review-2","body":"approved","state":"APPROVED","submittedAt":"2026-01-03T00:00:00Z"}]
}}}}}`)) }}}}}`))
default: 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", "id":"pr","number":1,"title":"PR","url":"u","createdAt":"2026-01-01T00:00:00Z",
"updatedAt":"2026-01-01T00:00:00Z","author":{"login":"alice"}, "updatedAt":"2026-01-01T00:00:00Z","author":{"login":"alice"},
"assignees":{"nodes":[]},"labels":{"nodes":[]},"reviewRequests":{"nodes":[]}, "assignees":{"nodes":[]},"labels":{"nodes":[]},"reviewRequests":{"nodes":[]},
@@ -485,6 +485,9 @@ func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing
if !got.Permissions.CanResolveAny || got.Permissions.Repository != "WRITE" { if !got.Permissions.CanResolveAny || got.Permissions.Repository != "WRITE" {
t.Fatalf("permissions = %#v", got.Permissions) t.Fatalf("permissions = %#v", got.Permissions)
} }
if got.ViewerLogin != "current-user" {
t.Fatalf("viewer login = %q", got.ViewerLogin)
}
} }
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {

View File

@@ -179,6 +179,7 @@ func main() {
ThreadListWidthPercent: config.Display.ThreadListWidthPercent, ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
DashboardMode: config.Display.DashboardMode, DashboardMode: config.Display.DashboardMode,
CompactReviews: config.Display.CompactReviews, CompactReviews: config.Display.CompactReviews,
ViewerLabel: config.Display.ViewerLabel,
ReadState: loadReadState(statePath), ReadState: loadReadState(statePath),
Drafts: loadDraftStore(draftPath), Drafts: loadDraftStore(draftPath),
PathScroll: config.Paths.Scroll, PathScroll: config.Paths.Scroll,

260
tui.go
View File

@@ -164,6 +164,7 @@ type App struct {
dashboardMode string dashboardMode string
dashboardReturn screen dashboardReturn screen
compactReviews bool compactReviews bool
viewerLabel string
editorMode string editorMode string
keybindings KeyBindings keybindings KeyBindings
readState *readStateStore readState *readStateStore
@@ -197,6 +198,7 @@ type AppSettings struct {
ThreadWithinStatus string ThreadWithinStatus string
DashboardMode string DashboardMode string
CompactReviews bool CompactReviews bool
ViewerLabel string
EditorMode string EditorMode string
KeyBindings KeyBindings KeyBindings KeyBindings
ReadState *readStateStore ReadState *readStateStore
@@ -214,6 +216,7 @@ func defaultAppSettings() AppSettings {
ThreadWithinStatus: "file", ThreadWithinStatus: "file",
DashboardMode: "hotkey", DashboardMode: "hotkey",
CompactReviews: true, CompactReviews: true,
ViewerLabel: "login",
EditorMode: "vim", EditorMode: "vim",
KeyBindings: defaultKeyBindings(), KeyBindings: defaultKeyBindings(),
} }
@@ -246,6 +249,7 @@ func NewAppWithSettings(
healthReturn: prScreen, healthReturn: prScreen,
requests: &requestCoordinator{}, requests: &requestCoordinator{},
compactReviews: settings.CompactReviews, compactReviews: settings.CompactReviews,
viewerLabel: firstNonEmpty(settings.ViewerLabel, "login"),
editorMode: settings.EditorMode, editorMode: settings.EditorMode,
keybindings: settings.KeyBindings, keybindings: settings.KeyBindings,
readState: state, readState: state,
@@ -568,7 +572,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
m.err = nil m.err = nil
default: default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
m.replyDraft += string(key.Runes) m.replyDraft += textInputKeyValue(key)
m.err = nil m.err = nil
} }
} }
@@ -1061,7 +1065,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.scroll = 0 m.scroll = 0
default: default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
m.searchQuery += string(key.Runes) m.searchQuery += textInputKeyValue(key)
m.selectBestSearchMatch() m.selectBestSearchMatch()
} }
} }
@@ -1504,7 +1508,7 @@ func (m App) matchingThreadIndices() []int {
if filter.updated && !m.updatedThreads[thread.ID] { if filter.updated && !m.updatedThreads[thread.ID] {
continue continue
} }
if filter.author != "" && !threadHasAuthor(thread, filter.author) { if filter.author != "" && !m.threadHasAuthor(thread, filter.author) {
continue continue
} }
if score, ok := fuzzyPathScore(thread.Path, filter.path); ok { if score, ok := fuzzyPathScore(thread.Path, filter.path); ok {
@@ -1559,9 +1563,10 @@ func parseThreadFilter(query string) threadFilter {
return filter return filter
} }
func threadHasAuthor(thread ReviewThread, author string) bool { func (m App) threadHasAuthor(thread ReviewThread, author string) bool {
for _, comment := range thread.Comments { 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 return true
} }
} }
@@ -1708,10 +1713,7 @@ func (m App) dashboardMaxScroll() int {
} }
func (m App) detailPaneSize() (int, int) { func (m App) detailPaneSize() (int, int) {
topLines := 3 topLines := m.threadTopLineCount()
if m.details.ThreadsTruncated {
topLines++
}
height := max(3, m.height-topLines-1) height := max(3, m.height-topLines-1)
if m.width < 70 || m.listHidden { if m.width < 70 || m.listHidden {
return max(3, m.width), height return max(3, m.width), height
@@ -1720,6 +1722,17 @@ func (m App) detailPaneSize() (int, int) {
return max(20, m.width-leftWidth-1), height 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 { func (m App) threadListWidth() int {
percent := m.threadListWidthPercent percent := m.threadListWidthPercent
if percent == 0 { if percent == 0 {
@@ -1735,7 +1748,7 @@ func (m App) detailViewportHeight() int {
func (m App) detailMaxScroll() int { func (m App) detailMaxScroll() int {
width, _ := m.detailPaneSize() 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 { func (m App) View() string {
@@ -1783,11 +1796,9 @@ func (m App) viewWritePopup() string {
titleStyle.Render("Reply to " + location), titleStyle.Render("Reply to " + location),
"", "",
} }
draft := m.replyDraft + "█" lines = append(lines, renderTextInput(
for _, sourceLine := range strings.Split(draft, "\n") { m.replyDraft, width-2, m.cursorOutput != nil,
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width-2, ""), width-2, false) )...)
lines = append(lines, strings.Split(wrapped, "\n")...)
}
if m.err != nil { if m.err != nil {
lines = append(lines, "", badStyle.Render(m.err.Error())) lines = append(lines, "", badStyle.Render(m.err.Error()))
} }
@@ -2569,17 +2580,17 @@ func (m App) dashboardLines() []string {
} }
lines = append(lines, lines = append(lines,
"", "",
dashboardMetadata("author", authorStyle(pr.Author).Render("@"+pr.Author)), dashboardMetadata("author", m.authorText(pr.Author)),
dashboardMetadata("branches", pr.HeadRef+" → "+pr.BaseRef), dashboardMetadata("branches", pr.HeadRef+" → "+pr.BaseRef),
dashboardMetadata("review", reviewAndMergeState(pr)), dashboardMetadata("review", reviewAndMergeState(pr)),
dashboardMetadata("checks", coloredState(pr.CheckState)), dashboardMetadata("checks", coloredState(pr.CheckState)),
dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")), 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, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...)
lines = append(lines, lines = append(lines,
dashboardMetadata("assignees", handlesText(pr.Assignees)), dashboardMetadata("assignees", m.handlesText(pr.Assignees)),
dashboardMetadata("reviewers", reviewersText(pr.Reviewers)), dashboardMetadata("reviewers", m.reviewersText(pr.Reviewers)),
dashboardMetadata("labels", labels), dashboardMetadata("labels", labels),
dashboardMetadata("milestone", milestone), dashboardMetadata("milestone", milestone),
dashboardMetadata("activity", fmt.Sprintf( dashboardMetadata("activity", fmt.Sprintf(
@@ -2654,10 +2665,10 @@ func (m App) dashboardLines() []string {
when := event.CreatedAt.Local().Format("2006-01-02 15:04") when := event.CreatedAt.Local().Format("2006-01-02 15:04")
if event.Kind == "force-push" { if event.Kind == "force-push" {
lines = append(lines, warnStyle.Render("force-push")+" "+shortOID(event.BeforeOID)+" → "+ 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 { } else {
lines = append(lines, shortOID(event.OID)+" "+truncate(event.Title, max(10, width-35))+ 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))), "") 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.")) lines = append(lines, dimStyle.Render("No submitted reviews."))
} }
if m.compactReviews { if m.compactReviews {
lines = append(lines, compactReviewLines(pr.Reviews, width)...) lines = append(lines, m.compactReviewLines(pr.Reviews, width)...)
if len(pr.Reviews) > 0 { if len(pr.Reviews) > 0 {
lines = append(lines, "") lines = append(lines, "")
} }
} else { } else {
for _, review := range pr.Reviews { for _, review := range pr.Reviews {
header := authorStyle(review.Author).Render("@"+review.Author) + " " + header := m.authorText(review.Author) + " " +
coloredState(review.State) coloredState(review.State)
if !review.SubmittedAt.IsZero() { if !review.SubmittedAt.IsZero() {
header += " " + dimStyle.Render(review.SubmittedAt.Local().Format("2006-01-02 15:04")) 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.")) lines = append(lines, dimStyle.Render("No PR conversation comments."))
} }
for _, comment := range pr.Conversation { for _, comment := range pr.Conversation {
header := authorStyle(comment.Author).Render("@" + comment.Author) header := m.authorText(comment.Author)
if !comment.CreatedAt.IsZero() { if !comment.CreatedAt.IsZero() {
header += " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")) header += " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04"))
} }
@@ -2717,7 +2728,7 @@ func (m App) dashboardLines() []string {
return lines return lines
} }
func compactReviewLines(reviews []ReviewSummary, width int) []string { func (m App) compactReviewLines(reviews []ReviewSummary, width int) []string {
if len(reviews) == 0 { if len(reviews) == 0 {
return nil return nil
} }
@@ -2751,13 +2762,13 @@ func compactReviewLines(reviews []ReviewSummary, width int) []string {
} }
for _, author := range authors { for _, author := range authors {
summary = append(summary, 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, "…")} lines := []string{ansi.Truncate(strings.Join(summary, dimStyle.Render(" • ")), width, "…")}
for _, review := range reviews { for _, review := range reviews {
if body := compactReviewBody(review.Body, width); body != "" { 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 coloredState(review.State) + dimStyle.Render(" — ") + body
lines = append(lines, ansi.Truncate(line, width, "…")) lines = append(lines, ansi.Truncate(line, width, "…"))
} }
@@ -2961,7 +2972,7 @@ func (m App) viewThreads() string {
pr := m.details 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)))) 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)) 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} top := []string{header, meta, people}
if pr.FromCache { if pr.FromCache {
top = append(top, warnStyle.Render( top = append(top, warnStyle.Render(
@@ -3023,7 +3034,12 @@ func (m App) viewThreads() string {
primaryKeyLabel(m.keybindings.Input.Cancel), 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 { func (m App) threadList(width, height int) string {
@@ -3034,7 +3050,8 @@ func (m App) threadList(width, height int) string {
if m.searching { if m.searching {
queryWidth := max(1, innerWidth-len("Filter: ")-1) queryWidth := max(1, innerWidth-len("Filter: ")-1)
query := ansi.Truncate(m.searchQuery, queryWidth, "…") 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 { if m.loading && len(m.details.Threads) == 0 {
lines = append(lines, "Loading…") lines = append(lines, "Loading…")
@@ -3085,7 +3102,7 @@ func (m App) threadDetail(width, height int) string {
if len(m.details.Threads) == 0 { if len(m.details.Threads) == 0 {
return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane) return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane)
} }
lines := m.detailLines(width) lines := m.renderedDetailLines(width)
viewportHeight := max(1, height-2) viewportHeight := max(1, height-2)
maxScroll := max(0, len(lines)-viewportHeight) maxScroll := max(0, len(lines)-viewportHeight)
scroll := min(m.scroll, maxScroll) scroll := min(m.scroll, maxScroll)
@@ -3099,7 +3116,7 @@ func (m App) threadDetail(width, height int) string {
if line.suggestionChange != 0 { if line.suggestionChange != 0 {
renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange) renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange)
} else { } else {
renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "") renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "")
} }
renderedLine = line.rail + renderedLine renderedLine = line.rail + renderedLine
if line.selected { if line.selected {
@@ -3176,7 +3193,7 @@ func (m App) detailLines(width int) []detailLine {
lines = append(lines, detailLine{}, detailLine{ lines = append(lines, detailLine{}, detailLine{
rail: rail, rail: rail,
anchor: "comment:" + comment.ID + ":header", anchor: "comment:" + comment.ID + ":header",
text: authorStyle(comment.Author).Render("@"+comment.Author) + text: m.commentAuthorText(comment) +
localAICommentBadge(comment) + " " + localAICommentBadge(comment) + " " +
dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")), dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")),
}) })
@@ -3233,6 +3250,32 @@ func (m App) detailLines(width int) []detailLine {
return lines 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 { func renderReactionSummary(reactions []ReactionSummary, width int) []string {
var badges []string var badges []string
for _, reaction := range reactions { 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")}, {rail: rail, anchor: "reply:header", text: titleStyle.Render("Reply draft")},
} }
draft := m.replyDraft + "█" textWidth := max(1, width-5)
textWidth := max(1, width-4)
lineIndex := 0 lineIndex := 0
for _, sourceLine := range strings.Split(draft, "\n") { for _, part := range renderTextInput(m.replyDraft, textWidth, m.cursorOutput != nil) {
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, textWidth, ""), textWidth, false) lines = append(lines, detailLine{
for _, part := range strings.Split(wrapped, "\n") { rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part,
lines = append(lines, detailLine{ })
rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part, lineIndex++
})
lineIndex++
}
} }
if m.err != nil { if m.err != nil {
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())}) 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 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 { func latestForcePush(events []TimelineEvent) time.Time {
var latest time.Time var latest time.Time
for _, event := range events { for _, event := range events {
@@ -3339,7 +3468,7 @@ func latestForcePush(events []TimelineEvent) time.Time {
func (m App) detailScrollAnchor() string { func (m App) detailScrollAnchor() string {
width, _ := m.detailPaneSize() width, _ := m.detailPaneSize()
lines := m.detailLines(width) lines := m.renderedDetailLines(width)
for index := min(m.scroll, len(lines)-1); index >= 0; index-- { for index := min(m.scroll, len(lines)-1); index >= 0; index-- {
if lines[index].anchor != "" { if lines[index].anchor != "" {
return lines[index].anchor return lines[index].anchor
@@ -3350,7 +3479,7 @@ func (m App) detailScrollAnchor() string {
func (m *App) restoreDetailAnchor(anchor string) { func (m *App) restoreDetailAnchor(anchor string) {
width, _ := m.detailPaneSize() width, _ := m.detailPaneSize()
for index, line := range m.detailLines(width) { for index, line := range m.renderedDetailLines(width) {
if line.anchor == anchor { if line.anchor == anchor {
m.scroll = min(index, m.detailMaxScroll()) m.scroll = min(index, m.detailMaxScroll())
return return
@@ -3550,6 +3679,35 @@ func authorStyle(login string) lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(authorColor(login)) 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 { func authorColor(login string) lipgloss.Color {
hash := fnv.New32a() hash := fnv.New32a()
_, _ = hash.Write([]byte(strings.ToLower(login))) _, _ = 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 { if pr.Merged {
return okStyle.Render("merged") return okStyle.Render("merged")
} }
@@ -3661,7 +3819,7 @@ func autoMergeStateText(pr PRDetails) string {
} }
text := okStyle.Render("enabled") + " " + strings.ToLower(pr.AutoMerge.MergeMethod) text := okStyle.Render("enabled") + " " + strings.ToLower(pr.AutoMerge.MergeMethod)
if pr.AutoMerge.EnabledBy != "" { if pr.AutoMerge.EnabledBy != "" {
text += " by " + authorStyle(pr.AutoMerge.EnabledBy).Render("@"+pr.AutoMerge.EnabledBy) text += " by " + m.authorText(pr.AutoMerge.EnabledBy)
} }
return text 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 { if len(reviewers) == 0 {
return "none" return "none"
} }
items := make([]string, 0, len(reviewers)) items := make([]string, 0, len(reviewers))
for _, reviewer := range reviewers { for _, reviewer := range reviewers {
items = append(items, items = append(items,
authorStyle(reviewer.Login).Render("@"+reviewer.Login)+ m.authorText(reviewer.Login)+
dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"), dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"),
) )
} }
return strings.Join(items, ", ") return strings.Join(items, ", ")
} }
func handlesText(items []string) string { func (m App) handlesText(items []string) string {
if len(items) == 0 { if len(items) == 0 {
return "none" return "none"
} }
handles := make([]string, 0, len(items)) handles := make([]string, 0, len(items))
for _, item := range items { for _, item := range items {
handles = append(handles, authorStyle(item).Render("@"+item)) handles = append(handles, m.authorText(item))
} }
return strings.Join(handles, ", ") return strings.Join(handles, ", ")
} }

View File

@@ -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) { func TestDashboardEditorNormalizesMixedLineEndingsWithoutCreatingAnEdit(t *testing.T) {
const remoteBody = "first\nsecond\r\nthird\r" const remoteBody = "first\nsecond\r\nthird\r"
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) 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) { func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) {
service := &recordingService{} service := &recordingService{}
m := NewApp(service, "o", "r", false, 50, time.Second) 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 := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.searching, m.searchQuery = threadScreen, true, "ng" 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 " { if got := updated.(App).searchQuery; got != "ng " {
t.Fatalf("space key produced search query %q", got) 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) { func TestFileSearchCanJumpOrCancel(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen m.screen = threadScreen
@@ -1809,8 +1917,9 @@ func TestDetailsLoadUsesSelectedPRRepository(t *testing.T) {
} }
func TestPeopleMetadataUsesColoredHandles(t *testing.T) { func TestPeopleMetadataUsesColoredHandles(t *testing.T) {
assignees := handlesText([]string{"alice"}) m := App{}
reviewers := reviewersText([]Reviewer{{Login: "bob", State: "APPROVED"}}) assignees := m.handlesText([]string{"alice"})
reviewers := m.reviewersText([]Reviewer{{Login: "bob", State: "APPROVED"}})
if ansi.Strip(assignees) != "@alice" || ansi.Strip(reviewers) != "@bob (approved)" { if ansi.Strip(assignees) != "@alice" || ansi.Strip(reviewers) != "@bob (approved)" {
t.Fatalf("people metadata = %q / %q", ansi.Strip(assignees), ansi.Strip(reviewers)) 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) { func TestDashboardFrameDoesNotChangeWhenBackgroundRefreshStarts(t *testing.T) {
m := NewApp(&recordingService{}, "", "", false, 50, time.Minute) m := NewApp(&recordingService{}, "", "", false, 50, time.Minute)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 100, 30 m.screen, m.loading, m.width, m.height = dashboardScreen, false, 100, 30

View File

@@ -21,6 +21,7 @@ type PullRequest struct {
type PRDetails struct { type PRDetails struct {
PullRequest PullRequest
ViewerLogin string
Body string Body string
CreatedAt time.Time CreatedAt time.Time
BaseRef string BaseRef string
@@ -235,7 +236,10 @@ type ReviewComment struct {
Model string Model string
} }
const reviewOriginLocalAI = "local-ai" const (
reviewOriginLocalAI = "local-ai"
reviewOriginLocalAIUser = "local-ai-user"
)
type ReactionSummary struct { type ReactionSummary struct {
Content string Content string