kunchenguid--no-mistakes
26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
312 行
9.0 KiB
Go
312 行
9.0 KiB
Go
package tui
|
|
|
|
import (
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/kunchenguid/no-mistakes/internal/ipc"
|
|
"github.com/kunchenguid/no-mistakes/internal/types"
|
|
)
|
|
|
|
// Model is the root bubbletea model for the TUI.
|
|
type Model struct {
|
|
// Connection.
|
|
socketPath string
|
|
client *ipc.Client
|
|
events <-chan ipc.Event
|
|
cancelSub func()
|
|
runID string
|
|
subscriptionID uint64
|
|
|
|
// State.
|
|
run *ipc.RunInfo
|
|
steps []ipc.StepResultInfo
|
|
stepFindings map[types.StepName]string // step name → raw findings JSON
|
|
stepDiffs map[types.StepName]string // step name → raw unified diff
|
|
findingSelections map[types.StepName]map[string]bool // step name → finding ID → selected
|
|
findingCursor map[types.StepName]int // step name → current finding cursor
|
|
findingInstructions map[types.StepName]map[string]string // step name → finding ID → user note
|
|
addedFindings map[types.StepName][]types.Finding // user-authored findings per step
|
|
editor *editorState // active modal editor (nil when none)
|
|
logs []string
|
|
logPartial string // buffered partial line (no trailing newline yet)
|
|
|
|
// Timing.
|
|
stepStartTimes map[types.StepName]time.Time // when each step started running
|
|
|
|
// UI.
|
|
width int
|
|
height int
|
|
latestVersion string
|
|
err error
|
|
quitting bool
|
|
done bool // run completed or failed
|
|
rerunPending bool
|
|
rerunRequestID uint64
|
|
showDiff bool // toggle diff viewer
|
|
showHelp bool // toggle help overlay
|
|
confirmAbort bool // true after first x press, next x actually aborts
|
|
diffOffset int // scroll position in diff view
|
|
spinnerFrame int
|
|
spinnerScheduled bool
|
|
syntheticSteps bool
|
|
yoloMode bool // auto-resolve every step awaiting human action
|
|
yoloApproved map[types.StepName]bool // steps already finalized (approved) this run
|
|
yoloFixed map[types.StepName]bool // steps yolo has already requested a fix for
|
|
}
|
|
|
|
// NewModel creates a TUI model for the given run.
|
|
// The client should already be connected to the daemon.
|
|
func NewModel(socketPath string, client *ipc.Client, run *ipc.RunInfo) Model {
|
|
syntheticSteps := len(run.Steps) == 0 && shouldBackfillPipelineSteps(run.Status, run.Steps, types.AllSteps())
|
|
steps := normalizePipelineSteps(run.ID, run.Status, run.Steps)
|
|
run.Steps = steps
|
|
m := Model{
|
|
socketPath: socketPath,
|
|
client: client,
|
|
runID: run.ID,
|
|
subscriptionID: 1,
|
|
run: run,
|
|
done: run.Status == types.RunCompleted || run.Status == types.RunFailed || run.Status == types.RunCancelled,
|
|
steps: steps,
|
|
stepFindings: make(map[types.StepName]string),
|
|
stepDiffs: make(map[types.StepName]string),
|
|
findingSelections: make(map[types.StepName]map[string]bool),
|
|
findingCursor: make(map[types.StepName]int),
|
|
findingInstructions: make(map[types.StepName]map[string]string),
|
|
addedFindings: make(map[types.StepName][]types.Finding),
|
|
stepStartTimes: make(map[types.StepName]time.Time),
|
|
syntheticSteps: syntheticSteps,
|
|
yoloApproved: make(map[types.StepName]bool),
|
|
yoloFixed: make(map[types.StepName]bool),
|
|
}
|
|
// Populate findings and start times from initial step data (for re-attach scenarios).
|
|
for _, s := range steps {
|
|
if s.FindingsJSON != nil && *s.FindingsJSON != "" {
|
|
m.stepFindings[s.StepName] = *s.FindingsJSON
|
|
if s.Status == types.StepStatusAwaitingApproval || s.Status == types.StepStatusFixReview {
|
|
m.resetFindingSelection(s.StepName)
|
|
}
|
|
}
|
|
// Seed start times from DB so elapsed time can be computed on re-attach.
|
|
if s.StartedAt != nil && s.DurationMS == nil {
|
|
m.stepStartTimes[s.StepName] = time.Unix(*s.StartedAt, 0)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func normalizePipelineSteps(runID string, runStatus types.RunStatus, steps []ipc.StepResultInfo) []ipc.StepResultInfo {
|
|
knownSteps := types.AllSteps()
|
|
if !shouldBackfillPipelineSteps(runStatus, steps, knownSteps) {
|
|
return steps
|
|
}
|
|
|
|
byName := make(map[types.StepName]ipc.StepResultInfo, len(steps))
|
|
for _, step := range steps {
|
|
byName[step.StepName] = step
|
|
}
|
|
|
|
normalized := make([]ipc.StepResultInfo, 0, len(knownSteps)+len(steps))
|
|
for _, stepName := range knownSteps {
|
|
step, ok := byName[stepName]
|
|
if !ok {
|
|
normalized = append(normalized, ipc.StepResultInfo{
|
|
RunID: runID,
|
|
StepName: stepName,
|
|
StepOrder: stepName.Order(),
|
|
Status: types.StepStatusPending,
|
|
})
|
|
continue
|
|
}
|
|
if step.RunID == "" {
|
|
step.RunID = runID
|
|
}
|
|
if step.StepOrder == 0 {
|
|
step.StepOrder = stepName.Order()
|
|
}
|
|
normalized = append(normalized, step)
|
|
delete(byName, stepName)
|
|
}
|
|
|
|
for _, step := range steps {
|
|
if _, ok := byName[step.StepName]; !ok {
|
|
continue
|
|
}
|
|
normalized = append(normalized, step)
|
|
delete(byName, step.StepName)
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
func shouldBackfillPipelineSteps(runStatus types.RunStatus, steps []ipc.StepResultInfo, knownSteps []types.StepName) bool {
|
|
if len(steps) == 0 {
|
|
return runStatus == types.RunPending || runStatus == types.RunRunning
|
|
}
|
|
if len(steps) >= len(knownSteps) {
|
|
return false
|
|
}
|
|
for i, step := range steps {
|
|
if step.StepName != knownSteps[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return m.subscribeCmd()
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
return m.handleKey(msg)
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
return m, nil
|
|
|
|
case connectedMsg:
|
|
if msg.subscriptionID != m.subscriptionID {
|
|
if msg.cancelSub != nil {
|
|
msg.cancelSub()
|
|
}
|
|
return m, nil
|
|
}
|
|
m.events = msg.events
|
|
m.cancelSub = msg.cancelSub
|
|
return m, tea.Batch(m.waitForEvent(), m.startSpinnerIfNeeded())
|
|
|
|
case rerunStartedMsg:
|
|
if msg.requestID != m.rerunRequestID {
|
|
return m, nil
|
|
}
|
|
m.rerunPending = false
|
|
if m.cancelSub != nil {
|
|
m.cancelSub()
|
|
}
|
|
m.resetForRun(msg.run)
|
|
if m.done {
|
|
return m, nil
|
|
}
|
|
return m, tea.Batch(m.subscribeCmd(), m.startSpinnerIfNeeded())
|
|
|
|
case rerunErrMsg:
|
|
if msg.requestID != m.rerunRequestID {
|
|
return m, nil
|
|
}
|
|
m.rerunPending = false
|
|
m.err = msg.err
|
|
return m, nil
|
|
|
|
case eventMsg:
|
|
if msg.subscriptionID != m.subscriptionID {
|
|
return m, nil
|
|
}
|
|
m.applyEvent(msg.event)
|
|
if m.done {
|
|
return m, nil
|
|
}
|
|
return m, tea.Batch(m.waitForEvent(), m.startSpinnerIfNeeded(), m.maybeAutoApproveCmd())
|
|
|
|
case subscriptionErrMsg:
|
|
if msg.subscriptionID != m.subscriptionID {
|
|
return m, nil
|
|
}
|
|
m.err = msg.err
|
|
return m, nil
|
|
|
|
case spinnerTickMsg:
|
|
m.spinnerScheduled = false
|
|
if !m.hasSpinningStep() {
|
|
return m, nil
|
|
}
|
|
if len(spinnerFrames) > 0 {
|
|
m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames)
|
|
}
|
|
if m.done || m.quitting {
|
|
return m, nil
|
|
}
|
|
return m, m.startSpinnerIfNeeded()
|
|
|
|
case errMsg:
|
|
m.err = msg.err
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// terminalTitle returns the current terminal title string based on run state.
|
|
// Format: "<symbol> <status> - <branch_name>"
|
|
func (m Model) terminalTitle() string {
|
|
branch := ""
|
|
if m.run != nil {
|
|
branch = m.run.Branch
|
|
}
|
|
suffix := ""
|
|
if branch != "" {
|
|
suffix = " - " + branch
|
|
}
|
|
|
|
// Terminal states.
|
|
if m.done || m.run == nil {
|
|
switch {
|
|
case m.run == nil:
|
|
return "○ Pending" + suffix
|
|
case m.run.Status == types.RunCompleted:
|
|
return "✓ Completed" + suffix
|
|
case m.run.Status == types.RunFailed:
|
|
return "✗ Failed" + suffix
|
|
case m.run.Status == types.RunCancelled:
|
|
return "✗ Cancelled" + suffix
|
|
}
|
|
}
|
|
|
|
// Find the most relevant active step.
|
|
for _, s := range m.steps {
|
|
icon := stepStatusIndicator(s.Status, m.spinnerFrame)
|
|
switch s.Status {
|
|
case types.StepStatusRunning, types.StepStatusFixing:
|
|
if s.StepName == types.StepCI && parseCIActivity(m.logs).Ready {
|
|
return "✓ Checks passed" + suffix
|
|
}
|
|
return icon + " " + stepLabel(s.StepName) + suffix
|
|
case types.StepStatusAwaitingApproval, types.StepStatusFixReview:
|
|
return icon + " " + stepLabel(s.StepName) + suffix
|
|
}
|
|
}
|
|
|
|
return "○ Pending" + suffix
|
|
}
|
|
|
|
// setTerminalTitle returns the OSC escape sequence to set the terminal title.
|
|
func setTerminalTitle(title string) string {
|
|
return "\033]2;" + title + "\007"
|
|
}
|
|
|
|
func (m *Model) resetForRun(run *ipc.RunInfo) {
|
|
width, height := m.width, m.height
|
|
nextSubscriptionID := m.subscriptionID + 1
|
|
latestVersion := m.latestVersion
|
|
fresh := NewModel(m.socketPath, m.client, run)
|
|
fresh.width = width
|
|
fresh.height = height
|
|
fresh.subscriptionID = nextSubscriptionID
|
|
fresh.rerunRequestID = m.rerunRequestID
|
|
fresh.latestVersion = latestVersion
|
|
*m = fresh
|
|
}
|
|
|
|
// Run starts the TUI program.
|
|
func Run(socketPath string, client *ipc.Client, run *ipc.RunInfo, latestVersion string) error {
|
|
model := NewModel(socketPath, client, run)
|
|
model.latestVersion = latestVersion
|
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
|
_, err := p.Run()
|
|
return err
|
|
}
|