Skip to content

Commit 6055c11

Browse files
committed
feat(tui): enhance mx check — grouping, filtering, navigation, LLM anchors
Part 1: Error grouping + deduplication - Group errors by code, deduplicate by element-id with (xN) count - New CheckGroup/CheckGroupItem types with groupCheckErrors() Part 2: Error navigation - Enter in check overlay jumps to document in tree - ]e/[e navigation in both browser and overlay modes - Check nav mode with status bar indicator [n/N] - Lazy init: ]e works directly without entering overlay first Part 3: Warning + deprecation support - Run mx check with -w -d flags for full diagnostics - Tab cycles severity filter in check overlay (all/error/warn/depr) - Badge shows all severity counts: ✗ 8E 2W 1D Part 4: LLM anchor structured output - [mxcli:check] summary + per-item anchors with Faint styling - Replaces generic [mxcli:overlay] anchor in check overlays
1 parent 66ed84c commit 6055c11

6 files changed

Lines changed: 1056 additions & 79 deletions

File tree

cmd/mxcli/tui/app.go

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ type App struct {
3636
showHelp bool
3737
picker *PickerModel // non-nil when cross-project picker is open
3838

39+
// Check error navigation state (]e / [e)
40+
checkNavActive bool
41+
checkNavIndex int
42+
checkNavLocations []CheckNavLocation
43+
pendingKey rune // ']' or '[' waiting for 'e', 0 if none
44+
3945
tabBar TabBar
4046
hintBar HintBar
4147
statusBar StatusBar
@@ -210,6 +216,26 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
210216
}
211217
return a, nil
212218

219+
case NavigateToDocMsg:
220+
// Close overlay, navigate tree to document, enter check nav mode
221+
a.views.Pop()
222+
qname := docNameToQualifiedName(msg.ModuleName, msg.DocumentName)
223+
if bv, ok := a.views.Base().(BrowserView); ok {
224+
cmd := bv.navigateToNode(qname)
225+
a.views.SetBase(bv)
226+
if tab := a.activeTabPtr(); tab != nil {
227+
tab.Miller = bv.miller
228+
tab.UpdateLabel()
229+
a.syncTabBar()
230+
}
231+
// Enter check nav mode
232+
a.checkNavActive = true
233+
a.checkNavIndex = msg.NavIndex
234+
a.checkNavLocations = extractCheckNavLocations(filterCheckErrors(a.checkErrors, "all"))
235+
return a, cmd
236+
}
237+
return a, nil
238+
213239
case DiffOpenMsg:
214240
dv := NewDiffView(msg, a.width, a.height)
215241
a.views.Push(dv)
@@ -488,8 +514,18 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
488514
}
489515
// Update check overlay content if it's currently visible
490516
if ov, ok := a.views.Active().(OverlayView); ok && ov.refreshable {
491-
content := renderCheckResults(a.checkErrors)
492-
ov.overlay.Show("mx check", content, ov.overlay.width, ov.overlay.height)
517+
ov.checkErrors = a.checkErrors
518+
filtered := filterCheckErrors(a.checkErrors, ov.checkFilter)
519+
ov.checkNavLocs = extractCheckNavLocations(filtered)
520+
if ov.selectedIdx >= len(ov.checkNavLocs) {
521+
ov.selectedIdx = max(0, len(ov.checkNavLocs)-1)
522+
}
523+
if len(ov.checkNavLocs) == 0 {
524+
ov.selectedIdx = -1
525+
}
526+
title := renderCheckFilterTitle(a.checkErrors, ov.checkFilter)
527+
content := renderCheckResults(a.checkErrors, ov.checkFilter)
528+
ov.overlay.Show(title, content, ov.overlay.width, ov.overlay.height)
493529
a.views.SetActive(ov)
494530
}
495531
return a, nil
@@ -523,6 +559,68 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
523559
func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd {
524560
tab := a.activeTabPtr()
525561

562+
// Handle two-key sequence: ]e / [e (check error navigation)
563+
if a.pendingKey != 0 {
564+
pending := a.pendingKey
565+
a.pendingKey = 0
566+
if msg.String() == "e" && len(a.checkErrors) > 0 {
567+
// Lazily initialize check nav state if not already active
568+
if !a.checkNavActive {
569+
a.checkNavActive = true
570+
a.checkNavLocations = extractCheckNavLocations(filterCheckErrors(a.checkErrors, "all"))
571+
a.checkNavIndex = -1 // will be incremented to 0 for ], or wrapped to last for [
572+
}
573+
if pending == ']' {
574+
a.checkNavIndex++
575+
if a.checkNavIndex >= len(a.checkNavLocations) {
576+
a.checkNavIndex = 0 // wrap around
577+
}
578+
} else {
579+
a.checkNavIndex--
580+
if a.checkNavIndex < 0 {
581+
a.checkNavIndex = len(a.checkNavLocations) - 1 // wrap around
582+
}
583+
}
584+
loc := a.checkNavLocations[a.checkNavIndex]
585+
qname := docNameToQualifiedName(loc.ModuleName, loc.DocumentName)
586+
if bv, ok := a.views.Base().(BrowserView); ok {
587+
cmd := bv.navigateToNode(qname)
588+
a.views.SetBase(bv)
589+
if tab := a.activeTabPtr(); tab != nil {
590+
tab.Miller = bv.miller
591+
tab.UpdateLabel()
592+
a.syncTabBar()
593+
}
594+
return cmd
595+
}
596+
return handledCmd
597+
}
598+
// Not 'e' — fall through to normal handling for the pending key
599+
// Re-process the pending key's original action
600+
if pending == ']' {
601+
if a.activeTab < len(a.tabs)-1 {
602+
a.activeTab++
603+
a.syncBrowserView()
604+
a.syncTabBar()
605+
}
606+
} else if pending == '[' {
607+
if a.activeTab > 0 {
608+
a.activeTab--
609+
a.syncBrowserView()
610+
a.syncTabBar()
611+
}
612+
}
613+
// Now process the current key normally (fall through)
614+
}
615+
616+
// Non-nav keys exit check nav mode (preserve for ]/[/! which are nav-related)
617+
if a.checkNavActive {
618+
key := msg.String()
619+
if key != "]" && key != "[" && key != "!" && key != "\\!" {
620+
a.checkNavActive = false
621+
}
622+
}
623+
526624
switch msg.String() {
527625
case "q":
528626
if a.watcher != nil {
@@ -574,6 +672,10 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd {
574672
return handledCmd
575673

576674
case "[":
675+
if len(a.checkErrors) > 0 {
676+
a.pendingKey = '['
677+
return handledCmd
678+
}
577679
if a.activeTab > 0 {
578680
a.activeTab--
579681
a.syncBrowserView()
@@ -582,6 +684,10 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd {
582684
return handledCmd
583685

584686
case "]":
687+
if len(a.checkErrors) > 0 {
688+
a.pendingKey = ']'
689+
return handledCmd
690+
}
585691
if a.activeTab < len(a.tabs)-1 {
586692
a.activeTab++
587693
a.syncBrowserView()
@@ -606,11 +712,17 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd {
606712
return handledCmd
607713

608714
case "!", "\\!": // some terminals send "\\!" for shifted-1; accept both forms
609-
content := renderCheckResults(a.checkErrors)
610-
ov := NewOverlayView("mx check", content, a.width, a.height, OverlayViewOpts{
715+
filter := "all"
716+
title := renderCheckFilterTitle(a.checkErrors, filter)
717+
content := renderCheckResults(a.checkErrors, filter)
718+
navLocs := extractCheckNavLocations(a.checkErrors)
719+
ov := NewOverlayView(title, content, a.width, a.height, OverlayViewOpts{
611720
HideLineNumbers: true,
612721
Refreshable: true,
613722
RefreshMsg: MxCheckRerunMsg{},
723+
CheckFilter: filter,
724+
CheckErrors: a.checkErrors,
725+
CheckNavLocs: navLocs,
614726
})
615727
a.views.Push(ov)
616728
return handledCmd
@@ -724,7 +836,15 @@ func (a App) View() string {
724836
a.statusBar.SetBreadcrumb(info.Breadcrumb)
725837
a.statusBar.SetPosition(info.Position)
726838
a.statusBar.SetMode(info.Mode)
727-
a.statusBar.SetCheckBadge(formatCheckBadge(a.checkErrors, a.checkRunning))
839+
if a.checkNavActive && len(a.checkNavLocations) > 0 {
840+
loc := a.checkNavLocations[a.checkNavIndex]
841+
navInfo := fmt.Sprintf("[%d/%d] %s: %s ]e next [e prev",
842+
a.checkNavIndex+1, len(a.checkNavLocations),
843+
loc.Code, docNameToQualifiedName(loc.ModuleName, loc.DocumentName))
844+
a.statusBar.SetCheckBadge(CheckWarnStyle.Render(navInfo))
845+
} else {
846+
a.statusBar.SetCheckBadge(formatCheckBadge(a.checkErrors, a.checkRunning))
847+
}
728848
viewModeNames := a.collectViewModeNames()
729849
a.statusBar.SetViewDepth(a.views.Depth(), viewModeNames)
730850
statusLine := StatusBarStyle.Width(a.width).Render(a.statusBar.View(a.width))

0 commit comments

Comments
 (0)