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

260
tui.go
View File

@@ -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, ", ")
}