Skip to content

Commit a13fc4a

Browse files
committed
feat(tui): session restore (-c), mouse fix, compare NDSL|MDL, palette layout
Session restore: - Save TUI state to ~/.mxcli/tui-session.json on quit - -c/--continue flag restores project, navigation path, preview mode - Edge cases: deleted nodes fall back to miller path Mouse coordinate fix: - Fix tab bar click Y-offset for LLM anchor line (row 0 → 1) - Fix content area Y-offset (−1 → −2 for anchor + tab bar) - Remove broken DisableMouse/EnableMouseCellMotion on resize (these return Msg not Cmd, sending them broke mouse state) Compare view: - Default to NDSL|MDL mode, load both sides simultaneously Command palette layout: - Add scroll window to prevent overflow - Fix shortcut key alignment with plain string padding
1 parent 4c0be7f commit a13fc4a

File tree

5 files changed

+729
-22
lines changed

5 files changed

+729
-22
lines changed

cmd/mxcli/cmd_tui.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,33 @@ Commands (via : bar):
3333
:diagram open diagram in browser
3434
:search <kw> full-text search
3535
36+
Flags:
37+
-c, --continue Restore previous session (tab, navigation, preview mode)
38+
3639
Example:
3740
mxcli tui -p app.mpr
41+
mxcli tui -c
3842
`,
3943
Run: func(cmd *cobra.Command, args []string) {
4044
projectPath, _ := cmd.Flags().GetString("project")
45+
continueSession, _ := cmd.Flags().GetBool("continue")
4146
mxcliPath, _ := os.Executable()
4247

48+
// Try to restore session when -c flag is set
49+
var session *tui.TUISession
50+
if continueSession {
51+
loaded, err := tui.LoadSession()
52+
if err != nil {
53+
fmt.Fprintf(os.Stderr, "Warning: could not load session: %v\n", err)
54+
} else if loaded != nil {
55+
session = loaded
56+
// Use project path from session if not explicitly provided
57+
if projectPath == "" && len(session.Tabs) > 0 {
58+
projectPath = session.Tabs[0].ProjectPath
59+
}
60+
}
61+
}
62+
4363
if projectPath == "" {
4464
picker := tui.NewPickerModel()
4565
p := tea.NewProgram(picker, tea.WithAltScreen())
@@ -55,9 +75,18 @@ Example:
5575
projectPath = m.Chosen()
5676
}
5777

78+
// Verify project file exists
79+
if _, err := os.Stat(projectPath); err != nil {
80+
fmt.Fprintf(os.Stderr, "Error: project file not found: %s\n", projectPath)
81+
os.Exit(1)
82+
}
83+
5884
tui.SaveHistory(projectPath)
5985

6086
m := tui.NewApp(mxcliPath, projectPath)
87+
if session != nil {
88+
m.SetPendingSession(session)
89+
}
6190
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
6291
m.StartWatcher(p)
6392
if _, err := p.Run(); err != nil {
@@ -66,3 +95,7 @@ Example:
6695
}
6796
},
6897
}
98+
99+
func init() {
100+
tuiCmd.Flags().BoolP("continue", "c", false, "Restore previous TUI session")
101+
}

cmd/mxcli/tui/app.go

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ type App struct {
5050
watcher *Watcher
5151
checkErrors []CheckError // nil = no check run yet, empty = pass
5252
checkRunning bool
53+
54+
pendingSession *TUISession // session to restore after tree loads
5355
}
5456

5557
// NewApp creates the root App model.
@@ -405,8 +407,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
405407
return a, nil
406408
}
407409

408-
// Tab bar clicks (row 0) — only when in browser mode
409-
if msg.Y == 0 && a.views.Active().Mode() == ModeBrowser &&
410+
// Tab bar clicks (row 1, after LLM anchor line) — only when in browser mode
411+
if msg.Y == 1 && a.views.Active().Mode() == ModeBrowser &&
410412
msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
411413
if clickMsg := a.tabBar.HandleClick(msg.X); clickMsg != nil {
412414
if tc, ok := clickMsg.(TabClickMsg); ok {
@@ -434,10 +436,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
434436
}
435437
}
436438

437-
// Offset Y by -1 for tab bar when in browser mode
439+
// Offset Y by -2 (LLM anchor line + tab bar) when in browser mode
438440
if a.views.Active().Mode() == ModeBrowser {
439441
offsetMsg := tea.MouseMsg{
440-
X: msg.X, Y: msg.Y - 1,
442+
X: msg.X, Y: msg.Y - 2,
441443
Button: msg.Button, Action: msg.Action,
442444
}
443445
updated, cmd := a.views.Active().Update(offsetMsg)
@@ -487,6 +489,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
487489
a.views.SetBase(bv)
488490
}
489491
}
492+
493+
// Apply pending session restore after tree is loaded
494+
if a.pendingSession != nil {
495+
applySessionRestore(&a)
496+
}
490497
}
491498
return a, nil
492499

@@ -630,6 +637,10 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd {
630637

631638
switch msg.String() {
632639
case "q":
640+
// Save session state before quitting
641+
if session := ExtractSession(a); session != nil {
642+
_ = SaveSession(session)
643+
}
633644
if a.watcher != nil {
634645
a.watcher.Close()
635646
}
@@ -743,13 +754,17 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd {
743754
cv := NewCompareView()
744755
cv.mxcliPath = a.mxcliPath
745756
cv.projectPath = a.activeTabProjectPath()
746-
cv.Show(CompareNDSL, a.width, a.height)
757+
cv.Show(CompareNDSLMDL, a.width, a.height)
747758
if tab != nil {
748759
cv.SetItems(flattenQualifiedNames(tab.AllNodes))
749760
if node := tab.Miller.SelectedNode(); node != nil && node.QualifiedName != "" {
750761
cv.SetLoading(CompareFocusLeft)
762+
cv.SetLoading(CompareFocusRight)
751763
a.views.Push(cv)
752-
return cv.loadBsonNDSL(node.QualifiedName, node.Type, CompareFocusLeft)
764+
return tea.Batch(
765+
cv.loadBsonNDSL(node.QualifiedName, node.Type, CompareFocusLeft),
766+
cv.loadMDL(node.QualifiedName, node.Type, CompareFocusRight),
767+
)
753768
}
754769
}
755770
a.views.Push(cv)
@@ -779,6 +794,102 @@ func (a *App) switchToTabByID(id int) {
779794
}
780795
}
781796

797+
// SetPendingSession stores a session to be restored after the project tree loads.
798+
func (a *App) SetPendingSession(session *TUISession) {
799+
a.pendingSession = session
800+
}
801+
802+
// applySessionRestore applies the pending session state to the loaded app.
803+
// Called after LoadTreeMsg delivers nodes so navigation paths can be resolved.
804+
// Takes *App because it's called from Update (value receiver) via &a.
805+
func applySessionRestore(a *App) {
806+
session := a.pendingSession
807+
if session == nil {
808+
return
809+
}
810+
a.pendingSession = nil
811+
812+
if len(session.Tabs) == 0 {
813+
return
814+
}
815+
816+
// Restore the first tab's navigation (multi-tab restore: only the
817+
// primary tab is restored since additional tabs need separate
818+
// project-tree loads which are not wired yet).
819+
ts := session.Tabs[0]
820+
tab := a.activeTabPtr()
821+
if tab == nil || len(tab.AllNodes) == 0 {
822+
return
823+
}
824+
825+
// Navigate to the selected node if available
826+
if ts.SelectedNode != "" {
827+
if bv, ok := a.views.Base().(BrowserView); ok {
828+
bv.allNodes = tab.AllNodes
829+
bv.navigateToNode(ts.SelectedNode)
830+
// Set preview mode after navigation (navigateToNode resets miller)
831+
setPreviewMode(&bv.miller, ts.PreviewMode)
832+
tab.Miller = bv.miller
833+
tab.UpdateLabel()
834+
a.views.SetBase(bv)
835+
a.syncTabBar()
836+
Trace("app: session restored — navigated to %q", ts.SelectedNode)
837+
return
838+
}
839+
}
840+
841+
// Fallback: navigate the miller path breadcrumb
842+
if len(ts.MillerPath) > 0 {
843+
restoreMillerPath(a, tab, ts.MillerPath)
844+
}
845+
846+
// Set preview mode (for path-based or no-navigation restore)
847+
setPreviewMode(&tab.Miller, ts.PreviewMode)
848+
}
849+
850+
// setPreviewMode sets the miller preview mode from a string value.
851+
func setPreviewMode(miller *MillerView, mode string) {
852+
if mode == "NDSL" {
853+
miller.preview.mode = PreviewNDSL
854+
} else {
855+
miller.preview.mode = PreviewMDL
856+
}
857+
}
858+
859+
// restoreMillerPath drills the miller view through a breadcrumb path.
860+
func restoreMillerPath(a *App, tab *Tab, millerPath []string) {
861+
bv, ok := a.views.Base().(BrowserView)
862+
if !ok {
863+
return
864+
}
865+
bv.allNodes = tab.AllNodes
866+
bv.miller.SetRootNodes(tab.AllNodes)
867+
868+
for _, segment := range millerPath {
869+
found := false
870+
for j, item := range bv.miller.current.items {
871+
if item.Label == segment {
872+
bv.miller.current.SetCursor(j)
873+
if item.Node != nil && len(item.Node.Children) > 0 {
874+
bv.miller, _ = bv.miller.drillIn()
875+
}
876+
found = true
877+
break
878+
}
879+
}
880+
if !found {
881+
Trace("app: session restore — path segment %q not found, stopping", segment)
882+
break
883+
}
884+
}
885+
886+
tab.Miller = bv.miller
887+
tab.UpdateLabel()
888+
a.views.SetBase(bv)
889+
a.syncTabBar()
890+
Trace("app: session restored via miller path %v", millerPath)
891+
}
892+
782893
// --- View ---
783894

784895
func (a App) View() string {

cmd/mxcli/tui/commandpalette.go

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -163,39 +163,60 @@ func (cp CommandPaletteView) Render(width, height int) string {
163163
keyStyle := lipgloss.NewStyle().Foreground(MutedColor)
164164
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(AccentColor)
165165

166-
boxWidth := max(30, min(60, width-10))
166+
contentWidth := max(30, min(56, width-14)) // inner content width (box adds border+padding)
167167

168168
var sb strings.Builder
169169
sb.WriteString(titleStyle.Render("Commands") + "\n")
170170
sb.WriteString(cp.input.View() + "\n\n")
171171

172-
// Build visible entries with scroll
172+
// Determine scroll window
173+
maxVisibleLines := max(6, min(paletteMaxVisible, height-10))
173174
entries := cp.filtered
175+
176+
// Find scroll offset to keep selected item visible
177+
scrollOffset := cp.computeScrollOffset(maxVisibleLines)
178+
174179
selectableIdx := 0
180+
visibleLines := 0
181+
182+
for i, entry := range entries {
183+
if i < scrollOffset {
184+
if !entry.isHeader {
185+
selectableIdx++
186+
}
187+
continue
188+
}
189+
if visibleLines >= maxVisibleLines {
190+
break
191+
}
175192

176-
for _, entry := range entries {
177193
if entry.isHeader {
178194
sb.WriteString(catStyle.Render(" "+entry.category) + "\n")
195+
visibleLines++
179196
continue
180197
}
181198

182-
prefix := " "
183199
shortcut := entry.command.Key
184200
name := entry.command.Name
185201

202+
// Calculate padding between name and shortcut
203+
shortcutWidth := len(shortcut)
204+
nameMaxWidth := contentWidth - 6 - shortcutWidth // " > " prefix + spacing
205+
if len(name) > nameMaxWidth {
206+
name = name[:nameMaxWidth-1] + "~"
207+
}
208+
pad := contentWidth - 4 - len(name) - shortcutWidth
209+
if pad < 1 {
210+
pad = 1
211+
}
212+
186213
if selectableIdx == cp.selectedIdx {
187-
line := fmt.Sprintf("%s> %-*s %s",
188-
" ", boxWidth-12, name, shortcut)
189-
sb.WriteString(selStyle.Render(line) + "\n")
214+
sb.WriteString(selStyle.Render(" > "+name) + strings.Repeat(" ", pad) + selStyle.Render(shortcut) + "\n")
190215
} else {
191-
label := prefix + normStyle.Render(name)
192-
pad := boxWidth - 8 - lipgloss.Width(name)
193-
if pad < 1 {
194-
pad = 1
195-
}
196-
sb.WriteString(label + strings.Repeat(" ", pad) + keyStyle.Render(shortcut) + "\n")
216+
sb.WriteString(" " + normStyle.Render(name) + strings.Repeat(" ", pad) + keyStyle.Render(shortcut) + "\n")
197217
}
198218
selectableIdx++
219+
visibleLines++
199220
}
200221

201222
selectableCount := cp.countSelectable()
@@ -205,13 +226,42 @@ func (cp CommandPaletteView) Render(width, height int) string {
205226
Border(lipgloss.RoundedBorder()).
206227
BorderForeground(AccentColor).
207228
Padding(1, 2).
208-
Width(boxWidth).
209229
Render(sb.String())
210230

211231
return lipgloss.Place(width, height,
212232
lipgloss.Center, lipgloss.Center,
213-
box,
214-
lipgloss.WithWhitespaceBackground(lipgloss.Color("0")))
233+
box)
234+
}
235+
236+
// computeScrollOffset returns the first entry index to render, keeping selectedIdx visible.
237+
func (cp *CommandPaletteView) computeScrollOffset(maxVisible int) int {
238+
if len(cp.filtered) <= maxVisible {
239+
return 0
240+
}
241+
242+
// Find the line index of the selected command
243+
targetLine := 0
244+
selectableIdx := 0
245+
for i, entry := range cp.filtered {
246+
if !entry.isHeader {
247+
if selectableIdx == cp.selectedIdx {
248+
targetLine = i
249+
break
250+
}
251+
selectableIdx++
252+
}
253+
}
254+
255+
// Ensure selected item is within visible window
256+
// Show a few lines of context above
257+
offset := targetLine - maxVisible/3
258+
if offset < 0 {
259+
offset = 0
260+
}
261+
if offset+maxVisible > len(cp.filtered) {
262+
offset = max(0, len(cp.filtered)-maxVisible)
263+
}
264+
return offset
215265
}
216266

217267
// refilter rebuilds the filtered entries based on the current input.

0 commit comments

Comments
 (0)