Skip to content

Commit c91ac06

Browse files
committed
fix(extraction): stabilize explore mode toggle visuals
Three fixes for visual jumping when toggling explore mode (#892): 1. Reorder pipeline-mode hints so a/x/esc are always the trailing three items, matching explore mode. Mode-specific hints (enter, t/layout) come before the stable tail. 2. Compute preview line reservation from the tallest tab's row count and pad the rendered section to that height, preventing the overlay from shrinking when switching between tabs with different row counts. 3. Use ex.previewTab in both modes instead of resetting to tab 0 in pipeline mode, maintaining visual continuity across toggle. closes #892
1 parent 936f23d commit c91ac06

2 files changed

Lines changed: 186 additions & 11 deletions

File tree

internal/app/extraction_render.go

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,25 @@ func previewNaturalWidth(groups []previewTableGroup, sepW int, currencySymbol st
5555
return maxW
5656
}
5757

58+
// stablePreviewLines returns a stable line reservation for the preview
59+
// section based on the tallest tab's row count. This prevents the overlay
60+
// height from changing when switching between tabs with different row counts.
61+
func stablePreviewLines(groups []previewTableGroup) int {
62+
if len(groups) == 0 {
63+
return 0
64+
}
65+
maxRows := 0
66+
for _, g := range groups {
67+
if len(g.cells) > maxRows {
68+
maxRows = len(g.cells)
69+
}
70+
}
71+
// Preview section structure: tabBar + underline + header + divider + rows.
72+
// That's maxRows+3 newlines within the section itself, plus 2 for the
73+
// separator rule and blank line above the section.
74+
return maxRows + 3 + 2
75+
}
76+
5877
// buildExtractionPipelineOverlay renders the pipeline step view with an
5978
// optional operation preview section below. The preview is dimmed when
6079
// not in explore mode and fully interactive when exploring.
@@ -133,7 +152,17 @@ func (m *Model) buildExtractionPipelineOverlay(
133152
previewLines := 0
134153
if hasOps {
135154
previewSection = m.renderOperationPreviewSection(innerW, ex.exploring)
136-
previewLines = strings.Count(previewSection, "\n") + 2 // +2 for separator + blank
155+
// Reserve height for the tallest preview tab so the overlay
156+
// doesn't jump when switching tabs in explore mode.
157+
previewLines = stablePreviewLines(ex.previewGroups)
158+
// Pad rendered section to stable height so the overlay
159+
// doesn't shrink when a shorter tab is displayed.
160+
actualLines := strings.Count(previewSection, "\n")
161+
targetLines := previewLines - 2 // -2: sep+blank added as separate parts
162+
for actualLines < targetLines {
163+
previewSection += "\n"
164+
actualLines++
165+
}
137166
}
138167

139168
maxH := max(m.effectiveHeight()*2/3-6-previewLines, 4)
@@ -209,9 +238,6 @@ func (m *Model) buildExtractionPipelineOverlay(
209238
if cursorStatus != stepPending {
210239
hints = append(hints, m.helpItem(symReturn, "expand"))
211240
}
212-
if hasOps {
213-
hints = append(hints, m.helpItem(keyX, "explore"))
214-
}
215241
if ex.Done {
216242
if ex.hasLLM {
217243
label := "layout on"
@@ -220,7 +246,11 @@ func (m *Model) buildExtractionPipelineOverlay(
220246
}
221247
hints = append(hints, m.helpItem(keyT, label))
222248
}
223-
hints = append(hints, m.helpItem(keyA, "accept"), m.helpItem(keyEsc, "discard"))
249+
if hasOps {
250+
hints = append(hints, m.helpItem(keyA, "accept"), m.helpItem(keyX, "explore"), m.helpItem(keyEsc, "discard"))
251+
} else {
252+
hints = append(hints, m.helpItem(keyA, "accept"), m.helpItem(keyEsc, "discard"))
253+
}
224254
} else {
225255
hints = append(hints,
226256
m.helpItem(symCtrlC, "int"),
@@ -279,12 +309,10 @@ func (m *Model) renderOperationPreviewSection(innerW int, interactive bool) stri
279309
tabBar := lipgloss.JoinHorizontal(lipgloss.Left, tabParts...)
280310
underline := m.styles.TabUnderline().Render(strings.Repeat(symHLineHeavy, innerW))
281311

282-
// Always render a single tab: the active one in explore mode,
283-
// the first one in pipeline mode.
284-
tabIdx := 0
285-
if interactive {
286-
tabIdx = ex.previewTab
287-
}
312+
// Always render the active preview tab. In explore mode it follows
313+
// user navigation; in pipeline mode it preserves the last-explored
314+
// tab so the view doesn't reset on toggle.
315+
tabIdx := ex.previewTab
288316
if tabIdx >= len(groups) {
289317
tabIdx = 0
290318
}

internal/app/extraction_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package app
66
import (
77
"context"
88
"errors"
9+
"strings"
910
"testing"
1011
"time"
1112

@@ -52,6 +53,13 @@ func newExtractionModel(t *testing.T, steps map[extractionStep]stepStatus) *Mode
5253
return m
5354
}
5455

56+
// hintIndex returns the byte index of a hint label in ANSI-stripped overlay
57+
// output. Keycap rendering inserts variable padding between key and label,
58+
// so this searches by label word alone.
59+
func hintIndex(stripped, label string) int {
60+
return strings.Index(stripped, label)
61+
}
62+
5563
func sendExtractionKey(m *Model, key string) {
5664
var msg tea.KeyPressMsg
5765
switch key {
@@ -3542,3 +3550,142 @@ func TestExtractionLLMCancelFn_SetSynchronouslyOnRerun(t *testing.T) {
35423550
// Clean up by cancelling the timeout context so the test doesn't leak.
35433551
ex.cancelLLMTimeout()
35443552
}
3553+
3554+
func TestExploreMode_HintBarTrailingOrder(t *testing.T) {
3555+
t.Parallel()
3556+
m := newPreviewModel(t, []extract.Operation{
3557+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "A"}},
3558+
})
3559+
ex := m.ex.extraction
3560+
3561+
// Pipeline mode: x should come after a and before esc.
3562+
pipelineOut := m.buildExtractionPipelineOverlay(100, 90, "test")
3563+
pipelinePlain := ansi.Strip(pipelineOut)
3564+
3565+
pipelineAcceptIdx := hintIndex(pipelinePlain, "accept")
3566+
pipelineExploreIdx := hintIndex(pipelinePlain, "explore")
3567+
pipelineEscIdx := hintIndex(pipelinePlain, "discard")
3568+
require.Greater(t, pipelineAcceptIdx, 0, "accept hint should appear")
3569+
require.Greater(t, pipelineExploreIdx, 0, "explore hint should appear")
3570+
require.Greater(t, pipelineEscIdx, 0, "discard hint should appear")
3571+
assert.Less(t, pipelineAcceptIdx, pipelineExploreIdx,
3572+
"accept should come before explore")
3573+
assert.Less(t, pipelineExploreIdx, pipelineEscIdx,
3574+
"explore should come before discard")
3575+
3576+
// Enter explore mode: x should still come after a and before esc.
3577+
sendExtractionKey(m, "x")
3578+
require.True(t, ex.exploring)
3579+
3580+
exploreOut := m.buildExtractionPipelineOverlay(100, 90, "test")
3581+
explorePlain := ansi.Strip(exploreOut)
3582+
3583+
exploreAcceptIdx := hintIndex(explorePlain, "accept")
3584+
exploreBackIdx := hintIndex(explorePlain, "back")
3585+
exploreEscIdx := hintIndex(explorePlain, "discard")
3586+
require.Greater(t, exploreAcceptIdx, 0, "accept hint should appear")
3587+
require.Greater(t, exploreBackIdx, 0, "back hint should appear")
3588+
require.Greater(t, exploreEscIdx, 0, "discard hint should appear")
3589+
assert.Less(t, exploreAcceptIdx, exploreBackIdx,
3590+
"accept should come before back")
3591+
assert.Less(t, exploreBackIdx, exploreEscIdx,
3592+
"back should come before discard")
3593+
}
3594+
3595+
func TestExploreMode_HintBarTrailingOrderMultiTab(t *testing.T) {
3596+
t.Parallel()
3597+
m := newPreviewModel(t, []extract.Operation{
3598+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "A"}},
3599+
{
3600+
Action: "create",
3601+
Table: data.TableQuotes,
3602+
Data: map[string]any{"total_cents": float64(100)},
3603+
},
3604+
})
3605+
ex := m.ex.extraction
3606+
3607+
// Explore mode with multiple tabs: b/f tabs hint appears but
3608+
// a/x/esc must still be trailing three.
3609+
sendExtractionKey(m, "x")
3610+
require.True(t, ex.exploring)
3611+
require.Greater(t, len(ex.previewGroups), 1, "need multiple tabs")
3612+
3613+
out := m.buildExtractionPipelineOverlay(100, 90, "test")
3614+
plain := ansi.Strip(out)
3615+
3616+
tabsIdx := hintIndex(plain, "tabs")
3617+
acceptIdx := hintIndex(plain, "accept")
3618+
backIdx := hintIndex(plain, "back")
3619+
escIdx := hintIndex(plain, "discard")
3620+
require.Greater(t, tabsIdx, 0, "tabs hint should appear")
3621+
require.Greater(t, acceptIdx, 0)
3622+
require.Greater(t, backIdx, 0)
3623+
require.Greater(t, escIdx, 0)
3624+
3625+
assert.Less(t, tabsIdx, acceptIdx,
3626+
"tabs should come before stable trailing group")
3627+
assert.Less(t, acceptIdx, backIdx,
3628+
"accept should come before back")
3629+
assert.Less(t, backIdx, escIdx,
3630+
"back should come before discard")
3631+
}
3632+
3633+
func TestExploreMode_OverlayHeightStableAcrossToggle(t *testing.T) {
3634+
t.Parallel()
3635+
// Two tables with different row counts to trigger height change.
3636+
m := newPreviewModel(t, []extract.Operation{
3637+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "A"}},
3638+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "B"}},
3639+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "C"}},
3640+
{
3641+
Action: "create",
3642+
Table: data.TableQuotes,
3643+
Data: map[string]any{"total_cents": float64(100)},
3644+
},
3645+
})
3646+
ex := m.ex.extraction
3647+
3648+
// Pipeline mode overlay.
3649+
pipelineOut := m.buildExtractionPipelineOverlay(100, 90, "test")
3650+
pipelineLines := strings.Count(pipelineOut, "\n")
3651+
3652+
// Enter explore mode and switch to tab with fewer rows.
3653+
sendExtractionKey(m, "x")
3654+
require.True(t, ex.exploring)
3655+
sendExtractionKey(m, "f") // switch to quotes tab (1 row vs 3)
3656+
require.Equal(t, 1, ex.previewTab)
3657+
3658+
exploreOut := m.buildExtractionPipelineOverlay(100, 90, "test")
3659+
exploreLines := strings.Count(exploreOut, "\n")
3660+
3661+
assert.Equal(t, pipelineLines, exploreLines,
3662+
"overlay height should be identical regardless of active tab row count")
3663+
}
3664+
3665+
func TestExploreMode_TabPersistsOnExit(t *testing.T) {
3666+
t.Parallel()
3667+
m := newPreviewModel(t, []extract.Operation{
3668+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "A"}},
3669+
{
3670+
Action: "create",
3671+
Table: data.TableQuotes,
3672+
Data: map[string]any{"total_cents": float64(100)},
3673+
},
3674+
})
3675+
ex := m.ex.extraction
3676+
3677+
// Enter explore, switch to tab 1, exit.
3678+
sendExtractionKey(m, "x")
3679+
require.True(t, ex.exploring)
3680+
sendExtractionKey(m, "f")
3681+
require.Equal(t, 1, ex.previewTab)
3682+
sendExtractionKey(m, "x") // exit explore
3683+
require.False(t, ex.exploring)
3684+
3685+
// Pipeline mode should now show tab 1 (dimmed), not reset to 0.
3686+
out := m.renderOperationPreviewSection(80, false)
3687+
plain := ansi.Strip(out)
3688+
// Tab 1 is Quotes -- its data should appear in rendered table.
3689+
assert.Contains(t, plain, "Total",
3690+
"pipeline mode should show last-explored tab content")
3691+
}

0 commit comments

Comments
 (0)