add mascot

This commit is contained in:
2026-07-29 08:33:37 +02:00
committed by pablu
parent 027057e85f
commit 28e418abd6
8 changed files with 994 additions and 64 deletions

294
tui.go
View File

@@ -137,6 +137,8 @@ type App struct {
listHidden bool
scroll int
width, height int
contentTop int
headerWidth int
loading bool
secondaryLoading bool
err error
@@ -201,6 +203,7 @@ type App struct {
aiProgress AIRunProgress
aiSpinner int
aiEvents <-chan tea.Msg
difflet diffletModel
}
type AppSettings struct {
@@ -219,6 +222,9 @@ type AppSettings struct {
Drafts *draftStore
AI *AIController
AIStore *AIStore
Mascot bool
MascotExpressive bool
MascotAnimated bool
}
func defaultAppSettings() AppSettings {
@@ -252,6 +258,8 @@ func NewAppWithSettings(
if state == nil {
state = &readStateStore{Data: make(map[string]readPRState)}
}
difflet := newDifflet(settings.Mascot, settings.MascotExpressive, settings.MascotAnimated)
difflet.visible = false
return App{
service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll,
folded: make(map[string]bool), loading: true,
@@ -273,6 +281,7 @@ func NewAppWithSettings(
unreadComments: make(map[string]bool), newThreads: make(map[string]bool),
updatedThreads: make(map[string]bool),
ai: settings.AI, aiStore: settings.AIStore,
difflet: difflet,
}
}
@@ -281,6 +290,9 @@ func (m App) Init() tea.Cmd {
if m.pathScroll {
commands = append(commands, m.nextPathTick())
}
if command := m.difflet.start(); command != nil {
commands = append(commands, command)
}
return tea.Batch(commands...)
}
@@ -599,7 +611,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k {
case "y":
m.writeMode = writeReplyBusy
return m, m.submitReply()
return m, tea.Batch(m.submitReply(), m.difflet.setState(diffletLoading))
case "n", "esc":
m.writeMode = writeReply
m.scroll = m.detailMaxScroll()
@@ -608,7 +620,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k {
case "y":
m.writeMode = writeResolveBusy
return m, m.submitResolution()
return m, tea.Batch(m.submitResolution(), m.difflet.setState(diffletLoading))
case "n", "esc":
m.writeMode, m.writeThreadID = writeNone, ""
}
@@ -616,7 +628,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k {
case "y":
m.writeMode = writeAutoMergeBusy
return m, m.submitAutoMerge()
return m, tea.Batch(m.submitAutoMerge(), m.difflet.setState(diffletLoading))
case "n", "esc":
m.writeMode = writeNone
}
@@ -624,7 +636,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k {
case "y":
m.writeMode = writeMergeNowBusy
return m, m.submitMergeNow()
return m, tea.Batch(m.submitMergeNow(), m.difflet.setState(diffletLoading))
case "n", "esc":
m.writeMode = writeNone
}
@@ -688,6 +700,9 @@ func (m App) submitMergeNow() tea.Cmd {
}
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if tick, ok := msg.(diffletTickMsg); ok {
return m, m.difflet.update(tick)
}
if m.aiMode != aiNone {
if updated, command, handled := m.updateAI(msg); handled {
return updated, command
@@ -696,17 +711,22 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, m.difflet.setVisible(
m.width >= diffletWidth &&
m.height >= diffletHeight+4,
)
case tickMsg:
if !m.loading {
m.loading = true
diffletCommand := m.difflet.setState(diffletLoading)
targetScreen := m.screen
if targetScreen == healthScreen {
targetScreen = m.healthReturn
}
if (targetScreen == dashboardScreen || targetScreen == threadScreen) && m.details.Number != 0 {
return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick())
return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick(), diffletCommand)
}
return m, tea.Batch(m.loadPRs(false), m.nextTick())
return m, tea.Batch(m.loadPRs(false), m.nextTick(), diffletCommand)
}
return m, m.nextTick()
case pathTickMsg:
@@ -734,8 +754,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.err = msg.err
command := m.difflet.setState(diffletSad)
m.recordHealth("pull request list", healthError, msg.err.Error())
return m, nil
return m, command
}
selected := ""
if len(m.prs) > 0 && m.prIndex < len(m.prs) {
@@ -756,6 +777,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.lastRefresh = time.Now()
}
if len(m.prs) == 0 {
return m, m.difflet.setState(diffletSleeping)
}
return m, m.difflet.setState(diffletIdle)
case detailsLoadedMsg:
if m.requests != nil && !m.requests.current(msg.requestID) {
return m, nil
@@ -781,8 +806,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.err = msg.err
command := m.difflet.setState(diffletSad)
m.recordHealth("PR refresh", healthError, msg.err.Error())
return m, nil
return m, command
}
selected := ""
anchor := ""
@@ -801,6 +827,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.trackThreadUpdates(msg.details)
sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
m.details = msg.details
m.err = nil
diffletCommand := m.difflet.setState(m.restingDiffletState())
if len(m.updatedThreads) > 0 {
diffletCommand = m.difflet.setState(diffletNewComment)
}
for _, issue := range msg.details.DataIssues {
m.recordHealth(issue.Component, healthWarning, issue.Message)
}
@@ -832,9 +863,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lastRefresh = time.Now()
if _, ok := m.service.(GitHubEnrichmentService); ok {
m.secondaryLoading = true
return m, m.loadDetailsEnrichment(msg.details)
diffletCommand = m.difflet.setState(diffletLoading)
return m, tea.Batch(m.loadDetailsEnrichment(msg.details), diffletCommand)
}
}
return m, diffletCommand
case detailsEnrichedMsg:
if m.requests != nil && !m.requests.current(msg.requestID) {
return m, nil
@@ -866,6 +899,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.recordHealth(issue.Component, healthWarning, issue.Message)
}
return m, m.difflet.setState(m.restingDiffletState())
case draftFlushMsg:
if msg.err != nil {
m.recordHealth("draft persistence", healthWarning, msg.err.Error())
@@ -907,7 +941,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.err = fmt.Errorf("change thread resolution: %w", msg.err)
m.recordHealth("thread resolution", healthError, msg.err.Error())
return m, nil
return m, m.difflet.setState(diffletRecoverableError)
}
selected := ""
if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) {
@@ -931,13 +965,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.writeThreadID = ""
m.err = nil
m.lastRefresh = time.Now()
return m, m.difflet.setState(diffletSuccess)
case threadRepliedMsg:
if msg.err != nil {
m.writeMode = writeReply
m.err = fmt.Errorf("reply to review thread: %w", msg.err)
m.recordHealth("thread reply", healthError, msg.err.Error())
m.scroll = m.detailMaxScroll()
return m, nil
return m, m.difflet.setState(diffletRecoverableError)
}
m.writeMode = writeNone
for index := range m.details.Threads {
@@ -957,12 +992,13 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.replyDraft, m.writeThreadID = "", ""
m.err = nil
m.lastRefresh = time.Now()
return m, m.difflet.setState(diffletSuccess)
case autoMergeUpdatedMsg:
m.writeMode = writeNone
if msg.err != nil {
m.err = fmt.Errorf("change auto-merge: %w", msg.err)
m.recordHealth("auto-merge", healthError, msg.err.Error())
return m, nil
return m, m.difflet.setState(diffletRecoverableError)
}
m.err = nil
if msg.enabled {
@@ -975,12 +1011,13 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.details.Permissions.CanDisableMerge = false
}
m.lastRefresh = time.Now()
return m, m.difflet.setState(diffletSuccess)
case pullRequestMergedMsg:
m.writeMode = writeNone
if msg.err != nil {
m.err = fmt.Errorf("merge pull request: %w", msg.err)
m.recordHealth("merge pull request", healthError, msg.err.Error())
return m, nil
return m, m.difflet.setState(diffletRecoverableError)
}
m.err = nil
m.details.Merged = msg.result.Merged
@@ -988,6 +1025,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.details.State = "MERGED"
m.details.AutoMerge = nil
m.lastRefresh = time.Now()
return m, m.difflet.setState(diffletSuccess)
case pullRequestUpdatedMsg:
if msg.err != nil {
m.writeMode = writePREdit
@@ -999,7 +1037,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = fmt.Errorf("update pull request: %w", msg.err)
m.recordHealth("PR metadata update", healthError, msg.err.Error())
m.scroll = 0
return m, nil
return m, m.difflet.setState(diffletRecoverableError)
}
m.writeMode = writeNone
m.details.Title = msg.metadata.Title
@@ -1031,7 +1069,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil
m.lastRefresh = time.Now()
m.loading = true
return m, m.loadDetails(m.details.PullRequest, false)
return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.difflet.setState(diffletLoading))
}
key, ok := msg.(tea.KeyMsg)
@@ -1172,14 +1210,15 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.loading = true
diffletCommand := m.difflet.setState(diffletLoading)
targetScreen := m.screen
if targetScreen == healthScreen {
targetScreen = m.healthReturn
}
if targetScreen == dashboardScreen || targetScreen == threadScreen {
return m, m.loadDetails(m.details.PullRequest, false)
return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), diffletCommand)
}
return m, m.loadPRs(false)
return m, tea.Batch(m.loadPRs(false), diffletCommand)
case "j", "down":
if m.screen == dashboardScreen {
m.scrollDashboard(1)
@@ -1282,14 +1321,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.screen, m.err, m.loading = prScreen, nil, true
return m, m.loadPRs(false)
return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading))
}
if m.screen == dashboardScreen {
m.screen, m.scroll, m.err = m.dashboardReturn, 0, nil
if m.screen == prScreen {
m.loading = true
m.searching, m.searchQuery = false, ""
return m, m.loadPRs(false)
return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading))
}
return m, nil
}
@@ -1382,7 +1421,7 @@ func (m *App) openSelectedPR(target screen) tea.Cmd {
m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil
m.searching, m.searchQuery = false, ""
m.pathScrollStep = 0
return m.loadDetails(m.details.PullRequest, true)
return tea.Batch(m.loadDetails(m.details.PullRequest, true), m.difflet.setState(diffletLoading))
}
func (m *App) trackThreadUpdates(details PRDetails) {
@@ -1849,11 +1888,8 @@ func (m App) detailPaneSize() (int, int) {
}
func (m App) threadTopLineCount() int {
topLines := 3
if m.details.FromCache {
topLines++
}
if m.details.ThreadsTruncated {
topLines := len(m.threadTopLines())
if m.headerWidth > 0 {
topLines++
}
return topLines
@@ -1884,6 +1920,117 @@ func (m App) View() string {
if m.width == 0 {
return "Loading…"
}
mascot := m.difflet.frameLines()
if len(mascot) != diffletHeight {
return m.viewContent()
}
gap := diffletGap
headerWidth := diffletHeaderWidth(m.width)
if headerWidth < 1 {
headerWidth = m.width
}
content := m
content.headerWidth = headerWidth
headerLineCount, hasHeader := content.diffletHeaderLineCount()
if !hasHeader {
content.height = max(1, m.height-diffletHeight-1)
content.contentTop = diffletHeight + 1
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
strings.Join(mascot, "\n") + "\n\n" + content.viewContent(),
)
}
bandHeight := max(diffletHeight, headerLineCount)
addedRows := bandHeight - headerLineCount
if m.width <= diffletWidth {
addedRows = diffletHeight
}
content.height = max(1, m.height-addedRows)
content.contentTop = addedRows
rendered := content.viewContent()
header, body, ok := splitHeader(rendered)
if !ok {
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
strings.Join(mascot, "\n") + "\n\n" + rendered,
)
}
headerBand := renderHeaderWithDifflet(header, mascot, m.width, headerWidth, gap)
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
headerBand + "\n\n" + body,
)
}
func (m App) diffletHeaderLineCount() (int, bool) {
if m.helpVisible ||
(m.aiMode != aiNone && m.aiMode != aiDiscussion) ||
(m.writeMode != writeNone && m.writeMode != writeReply && m.writeMode != writePREdit) ||
m.screen == healthScreen {
return 0, false
}
switch m.screen {
case prScreen:
return len(m.prHeaderLines()), true
case dashboardScreen:
if m.scroll > 0 {
return 0, false
}
if m.writeMode == writePREdit {
return 1, true
}
return len(m.dashboardHeaderLines()), true
case threadScreen:
return len(m.threadTopLines()), true
default:
return 0, false
}
}
func splitHeader(view string) (header []string, body string, ok bool) {
lines := strings.Split(view, "\n")
for index, line := range lines {
if strings.TrimSpace(ansi.Strip(line)) == "" {
if index == 0 {
return nil, view, false
}
return lines[:index], strings.Join(lines[index+1:], "\n"), true
}
}
return nil, view, false
}
func renderHeaderWithDifflet(
header, mascot []string,
width, headerWidth, gap int,
) string {
if width <= diffletWidth {
lines := append([]string(nil), mascot...)
lines = append(lines, header...)
return strings.Join(lines, "\n")
}
height := max(len(header), len(mascot))
headerText := strings.Join(header, "\n")
left := lipgloss.NewStyle().Width(headerWidth).Height(height).Render(headerText)
right := lipgloss.NewStyle().Width(diffletWidth).Height(height).Render(strings.Join(mascot, "\n"))
return lipgloss.JoinHorizontal(
lipgloss.Top,
left,
strings.Repeat(" ", gap),
right,
)
}
func (m App) wrapHeaderLines(lines []string) []string {
if m.headerWidth < 1 {
return lines
}
var wrapped []string
for _, line := range lines {
value := ansi.Hardwrap(ansi.Wordwrap(line, m.headerWidth, ""), m.headerWidth, false)
wrapped = append(wrapped, strings.Split(value, "\n")...)
}
return wrapped
}
func (m App) viewContent() string {
if m.helpVisible {
return m.viewHelp()
}
@@ -2326,16 +2473,7 @@ func paneStyle(active bool) lipgloss.Style {
}
func (m App) viewPRs() string {
header := titleStyle.Render("diple")
if m.owner != "" {
header += " " + m.owner + "/" + m.repo
}
if m.showAll {
header += dimStyle.Render(" all open pull requests")
} else {
header += dimStyle.Render(" assigned to you")
}
lines := []string{header, ""}
lines := append(m.prHeaderLines(), "")
if m.loading && len(m.prs) == 0 {
lines = append(lines, "Loading open pull requests…")
} else if len(m.prs) == 0 && m.err == nil {
@@ -2384,6 +2522,19 @@ func (m App) viewPRs() string {
return m.frame(lines, footer)
}
func (m App) prHeaderLines() []string {
header := titleStyle.Render("diple")
if m.owner != "" {
header += " " + m.owner + "/" + m.repo
}
if m.showAll {
header += dimStyle.Render(" all open pull requests")
} else {
header += dimStyle.Render(" assigned to you")
}
return m.wrapHeaderLines([]string{header})
}
type prListRow struct {
repository string
prIndex int
@@ -2669,10 +2820,7 @@ func (m App) healthLines() []string {
return lines
}
func (m App) dashboardLines() []string {
if m.writeMode == writePREdit {
return m.dashboardEditLines()
}
func (m App) dashboardHeaderLines() []string {
pr := m.details
width := max(10, m.width-2)
draft := ""
@@ -2684,7 +2832,9 @@ func (m App) dashboardLines() []string {
titleStyle.Render(truncate(pr.Title, width)),
}
if pr.FromCache {
lines = append(lines, warnStyle.Render("OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04")))
lines = append(lines, warnStyle.Render(
"OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"),
))
}
if len(pr.DataIssues) > 0 {
lines = append(lines, warnStyle.Render(fmt.Sprintf(
@@ -2692,6 +2842,16 @@ func (m App) dashboardLines() []string {
len(pr.DataIssues), primaryKeyLabel(m.keybindings.Views.Health),
)))
}
return m.wrapHeaderLines(lines)
}
func (m App) dashboardLines() []string {
if m.writeMode == writePREdit {
return m.dashboardEditLines()
}
pr := m.details
width := max(10, m.width-2)
lines := m.dashboardHeaderLines()
if m.loading && pr.BaseRef == "" {
return append(lines, "", "Loading pull request details…")
}
@@ -3100,9 +3260,13 @@ func viewerPermissionsText(permissions ViewerPermissions) string {
return strings.Join(items, ", ")
}
func (m App) viewThreads() string {
func (m App) threadTopLines() []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))))
width := m.width
if m.headerWidth > 0 {
width = m.headerWidth
}
header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, width-len(pr.RepoWithOwner)-12))))
meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr))
people := "assignees: " + m.handlesText(pr.Assignees) + " reviewers: " + m.reviewersText(pr.Reviewers)
top := []string{header, meta, people}
@@ -3115,8 +3279,16 @@ func (m App) viewThreads() string {
if pr.ThreadsTruncated {
top = append(top, warnStyle.Render("Showing the first 100 review threads."))
}
return m.wrapHeaderLines(top)
}
contentHeight := max(3, m.height-len(top)-1)
func (m App) viewThreads() string {
top := m.threadTopLines()
topLineCount := len(top)
if m.headerWidth > 0 {
topLineCount++
}
contentHeight := max(3, m.height-topLineCount-1)
var body string
if m.width < 70 {
if m.focus == threadListPane {
@@ -3167,6 +3339,9 @@ func (m App) viewThreads() string {
)
}
m.positionThreadInputHardwareCursor()
if m.headerWidth > 0 {
top = append(top, "")
}
view := m.frame(append(top, body), help)
if m.cursorOutput != nil {
view += m.cursorOutput.FrameMarker()
@@ -3571,7 +3746,7 @@ func (m App) positionThreadInputHardwareCursor() {
m.cursorOutput.SetCursor(
true,
2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query),
topLines+3,
m.contentTop+topLines+3,
)
return
}
@@ -3607,7 +3782,7 @@ func (m App) positionThreadInputHardwareCursor() {
m.cursorOutput.SetCursor(
true,
paneStart+2+ansi.StringWidth(line.rail)+ansi.StringWidth(line.fixed+line.text),
topLines+2+screenLine,
m.contentTop+topLines+2+screenLine,
)
}
@@ -3902,6 +4077,35 @@ func (m App) frame(lines []string, help string) string {
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n"))
}
func (m App) restingDiffletState() diffletState {
if m.err != nil {
return diffletSad
}
if m.loading || m.secondaryLoading {
return diffletLoading
}
if m.details.Number == 0 {
if !m.lastRefresh.IsZero() && len(m.prs) == 0 {
return diffletSleeping
}
return diffletIdle
}
if m.details.ReviewDecision == "APPROVED" {
return diffletApproved
}
open, outdated, resolved := threadStatusCounts(m.details.Threads)
if open+outdated > 0 {
return diffletConcerned
}
if resolved > 0 {
return diffletHappy
}
if m.screen == threadScreen {
return diffletFocused
}
return diffletIdle
}
func renderPane(lines []string, width, height int, active bool) string {
innerWidth := max(1, width-2)
innerHeight := max(1, height-2)