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

View File

@@ -237,6 +237,9 @@ repository = "" # optional "owner/repository"
show_all = false # requires repository
limit = 50 # 1-1000
endpoint = "https://api.github.com/graphql"
mascot = false # show the optional Difflet terminal mascot
mascot_expressive = false # allow emotional Difflet expressions
mascot_animated = false # allow brief state-driven motion
[display]
fold_resolved = true
@@ -301,6 +304,18 @@ 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.
Difflet is disabled by default. Set `mascot = true` to keep it visible to the
right next to the active view's naturally sized header, separated by a
small gap. On normal terminal widths Difflet is centered horizontally and the
header uses the space to its left. When centering would make the header too
narrow, Difflet falls back to a small right-edge inset. Header information
wraps when the combined header and mascot do not fit. The layout adds only the
vertical rows required to display the four-line mascot.
`mascot_animated` controls brief loading, blink, success, and error motion
independently from `mascot_expressive`, which permits stronger emotional
faces. Disabling animation leaves the appropriate final state visible.
Disabling the mascot preserves the normal header layout.
Thread categories are:
- `unresolved`: current unresolved threads;

View File

@@ -31,6 +31,9 @@ type Config struct {
ShowAll bool `toml:"show_all"`
Limit int `toml:"limit"`
Endpoint string `toml:"endpoint"`
Mascot bool `toml:"mascot"`
MascotExpressive bool `toml:"mascot_expressive"`
MascotAnimated bool `toml:"mascot_animated"`
Display DisplayConfig `toml:"display"`
Paths PathConfig `toml:"paths"`
Threads ThreadConfig `toml:"threads"`
@@ -101,6 +104,9 @@ func defaultConfig() Config {
RefreshInterval: configDuration{10 * time.Second},
Limit: 50,
Endpoint: "https://api.github.com/graphql",
Mascot: false,
MascotExpressive: false,
MascotAnimated: false,
Display: DisplayConfig{
FoldResolved: true,
ThreadListWidthPercent: 33,

View File

@@ -34,6 +34,9 @@ repository = "owner/repo"
show_all = true
limit = 75
endpoint = "https://github.example.com/api/graphql"
mascot = true
mascot_expressive = true
mascot_animated = true
[display]
fold_resolved = false
@@ -74,6 +77,7 @@ up = ["ctrl+k"]
}
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
!got.Mascot || !got.MascotExpressive || !got.MascotAnimated ||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
got.Display.DashboardMode != "hotkey" ||
got.Display.CompactReviews || got.Display.ViewerLabel != "you" ||

328
difflet.go Normal file
View File

@@ -0,0 +1,328 @@
package main
import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
diffletWidth = 9
diffletHeight = 4
diffletGap = 2
diffletMinimumHeaderWidth = 10
diffletFallbackRightPadding = 1
)
type DiffletExpression string
const (
DiffletIdle DiffletExpression = "idle"
DiffletFocused DiffletExpression = "focused"
DiffletHappy DiffletExpression = "happy"
DiffletApproved DiffletExpression = "approved"
DiffletSurprised DiffletExpression = "surprised"
DiffletConcerned DiffletExpression = "concerned"
DiffletConfused DiffletExpression = "confused"
DiffletSad DiffletExpression = "sad"
DiffletAnnoyed DiffletExpression = "annoyed"
DiffletError DiffletExpression = "error"
DiffletSleeping DiffletExpression = "sleeping"
DiffletCurious DiffletExpression = "curious"
)
var diffletFaces = map[DiffletExpression]string{
DiffletIdle: "•_•",
DiffletFocused: "-_-",
DiffletHappy: "^_^",
DiffletApproved: "^o^",
DiffletSurprised: "•o•",
DiffletConcerned: "•^•",
DiffletConfused: "•~•",
DiffletSad: ";_;",
DiffletAnnoyed: ">_<",
DiffletError: "x_x",
DiffletSleeping: "u_u",
DiffletCurious: "o_o",
}
type DiffletFrame struct {
Expression DiffletExpression
Feet string
}
func (frame DiffletFrame) lines() []string {
feet := frame.Feet
if feet == "" {
feet = " ▝ ▘"
}
return []string{
"▄███████▄",
"█+ " + diffletFaces[frame.Expression] + " -█",
"▀███████▀",
feet + strings.Repeat(" ", max(0, diffletWidth-lipgloss.Width(feet))),
}
}
func (frame DiffletFrame) render() []string {
lines := frame.lines()
lines[1] = "█" + okStyle.Render("+") + " " + diffletFaces[frame.Expression] +
" " + badStyle.Render("-") + "█"
return lines
}
type diffletAnimation struct {
frames []DiffletFrame
interval time.Duration
loop bool
settle DiffletExpression
}
type diffletState int
const (
diffletIdle diffletState = iota
diffletLoading
diffletFocused
diffletConcerned
diffletHappy
diffletApproved
diffletSleeping
diffletNewComment
diffletSuccess
diffletRecoverableError
diffletFatalError
diffletSad
)
type diffletTickMsg struct {
generation uint64
}
type diffletModel struct {
enabled bool
visible bool
expressive bool
animated bool
state diffletState
expression DiffletExpression
animation diffletAnimation
frame int
generation uint64
blinking bool
blinkCycle uint64
}
func newDifflet(enabled, expressive, animated bool) diffletModel {
model := diffletModel{
enabled: enabled, visible: true, expressive: expressive, animated: animated,
state: diffletLoading, expression: DiffletIdle,
}
_ = model.setState(diffletLoading)
return model
}
func (d *diffletModel) start() tea.Cmd {
if !d.enabled || !d.visible || !d.animated {
return nil
}
if d.state == diffletIdle {
return d.tick(d.blinkDelay())
}
if len(d.animation.frames) > 0 {
return d.tick(d.animation.interval)
}
return nil
}
func (d *diffletModel) setState(state diffletState) tea.Cmd {
d.state = state
d.generation++
d.frame = 0
d.blinking = false
d.animation = diffletAnimation{}
d.expression = d.staticExpression(state)
if !d.enabled || !d.visible || !d.animated {
return nil
}
switch state {
case diffletIdle:
return d.tick(d.blinkDelay())
case diffletLoading:
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle, Feet: " ▝ ▘"},
{Expression: DiffletIdle, Feet: " ▝ ▘"},
{Expression: DiffletIdle, Feet: " ▝ ▘"},
{Expression: DiffletIdle, Feet: " ▝ ▘"},
},
interval: 180 * time.Millisecond,
loop: true,
settle: DiffletIdle,
}
case diffletNewComment:
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle},
{Expression: DiffletSurprised},
{Expression: DiffletConcerned},
},
interval: 200 * time.Millisecond,
settle: DiffletConcerned,
}
case diffletSuccess:
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle},
{Expression: DiffletHappy},
{Expression: DiffletApproved},
{Expression: DiffletHappy},
},
interval: 175 * time.Millisecond,
settle: DiffletHappy,
}
case diffletRecoverableError:
if d.expressive {
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle},
{Expression: DiffletAnnoyed},
{Expression: DiffletSad},
},
interval: 220 * time.Millisecond,
settle: DiffletSad,
}
}
case diffletFatalError:
if d.expressive {
d.animation = diffletAnimation{
frames: []DiffletFrame{
{Expression: DiffletIdle},
{Expression: DiffletAnnoyed},
{Expression: DiffletError},
},
interval: 220 * time.Millisecond,
settle: DiffletError,
}
}
}
if len(d.animation.frames) == 0 {
return nil
}
d.expression = d.animation.frames[0].Expression
return d.tick(d.animation.interval)
}
func (d *diffletModel) setVisible(visible bool) tea.Cmd {
if d.visible == visible {
return nil
}
d.visible = visible
if !visible {
d.generation++
d.animation = diffletAnimation{}
d.blinking = false
return nil
}
return d.setState(d.state)
}
func (d *diffletModel) staticExpression(state diffletState) DiffletExpression {
switch state {
case diffletFocused:
return DiffletFocused
case diffletConcerned, diffletNewComment:
return DiffletConcerned
case diffletHappy, diffletSuccess:
return DiffletHappy
case diffletApproved:
return DiffletApproved
case diffletSleeping:
return DiffletSleeping
case diffletRecoverableError:
if d.expressive {
return DiffletAnnoyed
}
return DiffletConcerned
case diffletFatalError:
return DiffletError
case diffletSad:
if d.expressive {
return DiffletSad
}
return DiffletConcerned
default:
return DiffletIdle
}
}
func (d *diffletModel) update(msg diffletTickMsg) tea.Cmd {
if !d.enabled || !d.visible || !d.animated || msg.generation != d.generation {
return nil
}
if d.state == diffletIdle {
if !d.blinking {
d.blinking = true
d.expression = DiffletFocused
return d.tick(125 * time.Millisecond)
}
d.blinking = false
d.expression = DiffletIdle
d.blinkCycle++
return d.tick(d.blinkDelay())
}
if len(d.animation.frames) == 0 {
return nil
}
next := d.frame + 1
if next >= len(d.animation.frames) {
if !d.animation.loop {
settle := d.animation.settle
d.animation = diffletAnimation{}
d.expression = settle
return nil
}
next = 0
}
d.frame = next
d.expression = d.animation.frames[next].Expression
return d.tick(d.animation.interval)
}
func (d diffletModel) frameLines() []string {
if !d.enabled || !d.visible {
return nil
}
if len(d.animation.frames) > 0 && d.frame < len(d.animation.frames) {
frame := d.animation.frames[d.frame]
frame.Expression = d.expression
return frame.render()
}
return (DiffletFrame{Expression: d.expression}).render()
}
func diffletHeaderWidth(width int) int {
centeredLeft := max(0, (width-diffletWidth)/2)
if centeredLeft-diffletGap >= diffletMinimumHeaderWidth {
return centeredLeft - diffletGap
}
rightPadding := 0
if width-diffletWidth-diffletGap > 1 {
rightPadding = diffletFallbackRightPadding
}
return max(1, width-diffletWidth-diffletGap-rightPadding)
}
func (d diffletModel) tick(after time.Duration) tea.Cmd {
generation := d.generation
return tea.Tick(after, func(time.Time) tea.Msg {
return diffletTickMsg{generation: generation}
})
}
func (d diffletModel) blinkDelay() time.Duration {
return 5*time.Second + time.Duration((d.generation+d.blinkCycle*3)%6)*650*time.Millisecond
}

370
difflet_test.go Normal file
View File

@@ -0,0 +1,370 @@
package main
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/termenv"
)
func TestDiffletExpressionsAndFramesHaveStableCellDimensions(t *testing.T) {
expressions := []DiffletExpression{
DiffletIdle, DiffletFocused, DiffletHappy, DiffletApproved,
DiffletSurprised, DiffletConcerned, DiffletConfused, DiffletSad,
DiffletAnnoyed, DiffletError, DiffletSleeping, DiffletCurious,
}
for _, expression := range expressions {
face := diffletFaces[expression]
if width := lipgloss.Width(face); width != 3 {
t.Errorf("%s face width = %d, want 3: %q", expression, width, face)
}
lines := (DiffletFrame{Expression: expression}).lines()
if len(lines) != diffletHeight {
t.Fatalf("%s has %d lines, want %d", expression, len(lines), diffletHeight)
}
for row, line := range lines {
if width := lipgloss.Width(line); width != diffletWidth {
t.Errorf("%s row %d width = %d, want %d: %q",
expression, row, width, diffletWidth, line)
}
}
}
}
func TestDiffletCanonicalFramePreservesGlyphsAndSpacing(t *testing.T) {
got := (DiffletFrame{Expression: DiffletIdle}).lines()
want := []string{
"▄███████▄",
"█+ •_• -█",
"▀███████▀",
" ▝ ▘ ",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("canonical frame:\n%q\nwant:\n%q", got, want)
}
}
func TestDiffletStylesOnlyDiffSigns(t *testing.T) {
previousProfile := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(previousProfile) })
previousOK, previousBad := okStyle, badStyle
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000"))
t.Cleanup(func() { okStyle, badStyle = previousOK, previousBad })
rendered := (DiffletFrame{Expression: DiffletIdle}).render()
wantMiddle := "█" + okStyle.Render("+") + " •_• " + badStyle.Render("-") + "█"
if rendered[1] != wantMiddle {
t.Fatalf("middle row = %q, want %q", rendered[1], wantMiddle)
}
if ansi.Strip(strings.Join(rendered, "\n")) !=
strings.Join((DiffletFrame{Expression: DiffletIdle}).lines(), "\n") {
t.Fatal("styling changed Difflet glyphs or spacing")
}
for _, row := range []int{0, 2, 3} {
if strings.Contains(rendered[row], "\x1b[") {
t.Fatalf("row %d unexpectedly styled: %q", row, rendered[row])
}
}
}
func TestDiffletEnabledSitsRightOfWrappingHeader(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
updated, _ := app.Update(tea.WindowSizeMsg{Width: 24, Height: 12})
app = updated.(App)
rendered := strings.Split(app.View(), "\n")
if len(rendered) < diffletHeight+2 {
t.Fatalf("rendered rows = %d, want at least %d", len(rendered), diffletHeight+2)
}
headerWidth := diffletHeaderWidth(app.width)
content := app
content.headerWidth = headerWidth
_, _, ok := splitHeader(content.viewContent())
if !ok {
t.Fatal("test view did not expose a header")
}
wantMascotLeft := headerWidth + diffletGap
for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() {
plain := ansi.Strip(rendered[row])
if !strings.Contains(plain, strings.TrimRight(mascotLine, " ")) {
t.Fatalf("mascot row %d is not visible: %q", row, plain)
}
left := strings.Index(plain, strings.TrimRight(mascotLine, " "))
if left != wantMascotLeft {
t.Fatalf("mascot row %d starts at %d, want adjacent position %d",
row, left, wantMascotLeft)
}
}
blankRow := -1
var headerText strings.Builder
for row := 0; row < len(rendered); row++ {
line := rendered[row]
plain := ansi.Strip(line)
if strings.TrimSpace(plain) == "" {
blankRow = row
break
}
headerText.WriteString(strings.TrimSpace(
ansi.Cut(plain, 0, wantMascotLeft-diffletGap),
))
}
normalizedHeader := strings.ReplaceAll(headerText.String(), " ", "")
for _, information := range []string{"diple", "owner/repository", "assignedtoyou"} {
if !strings.Contains(normalizedHeader, information) {
t.Fatalf("wrapped header does not contain %q: %s", information, normalizedHeader)
}
}
if blankRow < diffletHeight {
t.Fatalf("blank row = %d, want at or below mascot row %d",
blankRow, diffletHeight)
}
}
func TestDiffletIsCenteredAtNormalWidths(t *testing.T) {
mascot := (DiffletFrame{Expression: DiffletIdle}).lines()
const width = 40
headerWidth := diffletHeaderWidth(width)
rendered := strings.Split(
renderHeaderWithDifflet([]string{"diple"}, mascot, width, headerWidth, diffletGap),
"\n",
)
for row, line := range rendered[:diffletHeight] {
mascotText := strings.TrimRight(mascot[row], " ")
wantLeft := (width - diffletWidth) / 2
if left := strings.Index(ansi.Strip(line), mascotText); left != wantLeft {
t.Fatalf("row %d mascot starts at %d, want %d: %q",
row, left, wantLeft, line)
}
}
}
func TestDiffletLongHeaderKeepsMascotCentered(t *testing.T) {
const width = 80
headerWidth := diffletHeaderWidth(width)
if headerWidth != 33 {
t.Fatalf("header width = %d, want 33", headerWidth)
}
mascot := (DiffletFrame{Expression: DiffletIdle}).lines()
rendered := strings.Split(
renderHeaderWithDifflet(
[]string{strings.Repeat("x", headerWidth)},
mascot,
width,
headerWidth,
diffletGap,
),
"\n",
)
mascotLeft := strings.Index(ansi.Strip(rendered[0]), strings.TrimRight(mascot[0], " "))
if mascotLeft != headerWidth+diffletGap {
t.Fatalf("mascot starts at %d, want %d", mascotLeft, headerWidth+diffletGap)
}
if mascotLeft != (width-diffletWidth)/2 {
t.Fatalf("mascot starts at %d, want centered position %d",
mascotLeft, (width-diffletWidth)/2)
}
}
func TestDiffletFallsBackToSmallRightPadding(t *testing.T) {
const width = 24
headerWidth := diffletHeaderWidth(width)
mascotLeft := headerWidth + diffletGap
if rightPadding := width - mascotLeft - diffletWidth; rightPadding != 1 {
t.Fatalf("fallback right padding = %d, want 1", rightPadding)
}
}
func TestDiffletDisabledPreservesViewExactly(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "", "", false, 10, 10,
AppSettings{MascotExpressive: true, MascotAnimated: true},
)
updated, _ := app.Update(tea.WindowSizeMsg{Width: 20, Height: 12})
app = updated.(App)
got := app.View()
want := app.viewContent()
if got != want {
t.Fatalf("disabled mascot changed rendered view:\ngot:\n%q\nwant:\n%q", got, want)
}
}
func TestDiffletHeaderMeasurementMatchesRenderedHeader(t *testing.T) {
tests := []struct {
name string
screen screen
}{
{name: "picker", screen: prScreen},
{name: "dashboard", screen: dashboardScreen},
{name: "threads", screen: threadScreen},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
app.screen = test.screen
app.details = PRDetails{PullRequest: PullRequest{
Owner: "owner", Repository: "repository",
RepoWithOwner: "owner/repository", Number: 42,
Title: "A pull request with a useful title",
}}
updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
app = updated.(App)
app.headerWidth = diffletHeaderWidth(app.width)
measured, ok := app.diffletHeaderLineCount()
if !ok {
t.Fatal("normal screen did not expose measurable header lines")
}
renderedHeader, _, ok := splitHeader(app.viewContent())
if !ok {
t.Fatal("rendered normal screen did not expose a header")
}
if measured != len(renderedHeader) {
t.Fatalf("measured header lines = %d, rendered = %d",
measured, len(renderedHeader))
}
})
}
}
func TestDiffletKeepsCompleteFrameAtNarrowPhysicalMinimum(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "", "", false, 10, 10,
AppSettings{Mascot: true},
)
updated, _ := app.Update(tea.WindowSizeMsg{Width: diffletWidth, Height: 12})
app = updated.(App)
rendered := strings.Split(app.View(), "\n")
for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() {
if got := ansi.Strip(rendered[row]); got != mascotLine {
t.Fatalf("mascot row %d = %q, want %q", row, got, mascotLine)
}
}
headerBelowMascot := ansi.Strip(strings.Join(rendered[diffletHeight:], "\n"))
normalizedHeader := strings.NewReplacer("\n", "", " ", "").Replace(headerBelowMascot)
if !strings.Contains(normalizedHeader, "diple") {
t.Fatalf("header did not wrap below complete mascot: %q", headerBelowMascot)
}
}
func TestDiffletStopsTicksBelowPhysicalMinimum(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "", "", false, 10, 10,
AppSettings{Mascot: true, MascotAnimated: true},
)
if command := app.difflet.start(); command != nil {
t.Fatal("hidden startup mascot scheduled a tick")
}
updated, command := app.Update(tea.WindowSizeMsg{Width: 12, Height: 12})
app = updated.(App)
if command == nil {
t.Fatal("visible animated mascot did not schedule a tick")
}
generation := app.difflet.generation
updated, command = app.Update(tea.WindowSizeMsg{Width: 8, Height: 12})
app = updated.(App)
if command != nil {
t.Fatal("mascot below its nine-cell minimum scheduled a tick")
}
if app.difflet.visible {
t.Fatal("mascot below its nine-cell minimum remained visible")
}
updated, command = app.Update(diffletTickMsg{generation: generation})
app = updated.(App)
if command != nil || app.difflet.visible {
t.Fatal("stale tick restarted hidden mascot")
}
}
func TestDiffletStaticOptionsSelectFinalSafeExpression(t *testing.T) {
model := newDifflet(true, false, false)
if command := model.setState(diffletSuccess); command != nil {
t.Fatal("animation-disabled Difflet scheduled a tick")
}
if model.expression != DiffletHappy {
t.Fatalf("static success = %s, want %s", model.expression, DiffletHappy)
}
if command := model.setState(diffletRecoverableError); command != nil {
t.Fatal("non-expressive Difflet scheduled an error animation")
}
if model.expression != DiffletConcerned {
t.Fatalf("non-expressive error = %s, want %s", model.expression, DiffletConcerned)
}
model.setState(diffletFatalError)
if model.expression != DiffletError {
t.Fatalf("non-expressive fatal error = %s, want %s", model.expression, DiffletError)
}
}
func TestDiffletFocusedStateDoesNotLoop(t *testing.T) {
model := newDifflet(true, true, true)
if command := model.setState(diffletFocused); command != nil {
t.Fatal("focused state scheduled continuous animation")
}
if model.expression != DiffletFocused || len(model.animation.frames) != 0 {
t.Fatalf("focused state = expression %s, animation %#v",
model.expression, model.animation)
}
}
func TestDiffletLoadingFootShuffleSequence(t *testing.T) {
model := newDifflet(true, false, true)
if command := model.setState(diffletLoading); command == nil {
t.Fatal("loading animation did not schedule a tick")
}
want := []string{" ▝ ▘ ", " ▝ ▘ ", " ▝ ▘ ", " ▝ ▘ "}
for index, feet := range want {
if got := model.frameLines()[3]; got != feet {
t.Fatalf("loading frame %d feet = %q, want %q", index, got, feet)
}
if index < len(want)-1 {
if command := model.update(diffletTickMsg{generation: model.generation}); command == nil {
t.Fatalf("loading frame %d stopped looping", index)
}
}
}
}
func TestDiffletOneShotStopsAndSettles(t *testing.T) {
model := newDifflet(true, true, true)
model.setState(diffletSuccess)
for index := 0; index < 3; index++ {
if command := model.update(diffletTickMsg{generation: model.generation}); command == nil {
t.Fatalf("success animation stopped at frame %d", index)
}
}
if command := model.update(diffletTickMsg{generation: model.generation}); command != nil {
t.Fatal("completed success animation scheduled another tick")
}
if len(model.animation.frames) != 0 || model.expression != DiffletHappy {
t.Fatalf("success settled with animation=%#v expression=%s",
model.animation, model.expression)
}
}
func TestDiffletLoopStopsOnStateChangeAndRejectsStaleTick(t *testing.T) {
model := newDifflet(true, true, true)
model.setState(diffletLoading)
oldGeneration := model.generation
if command := model.update(diffletTickMsg{generation: oldGeneration}); command == nil {
t.Fatal("loading animation did not continue")
}
model.setState(diffletConcerned)
if command := model.update(diffletTickMsg{generation: oldGeneration}); command != nil {
t.Fatal("stale tick scheduled another tick")
}
if model.expression != DiffletConcerned || model.frame != 0 {
t.Fatalf("stale tick changed newer state: expression=%s frame=%d",
model.expression, model.frame)
}
}

View File

@@ -191,6 +191,9 @@ func main() {
KeyBindings: config.KeyBindings,
AI: aiController,
AIStore: aiStore,
Mascot: config.Mascot,
MascotExpressive: config.MascotExpressive,
MascotAnimated: config.MascotAnimated,
},
)
cursorOutput := newTerminalCursorOutput(os.Stdout)

View File

@@ -292,7 +292,7 @@ func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) {
_, column := editorCursorVisualPosition(editor, m.prEditEditorWidth())
// Rows and columns are one-based. Each editor row has a two-cell "│ "
// context rail before its text.
m.cursorOutput.SetCursor(true, column+3, screenRow+1)
m.cursorOutput.SetCursor(true, column+3, m.contentTop+screenRow+1)
}
func (m App) submitPREdit() tea.Cmd {

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)