Skip to content

Commit e92499d

Browse files
cpcloudclaude
andauthored
fix(ux): stabilize explore mode toggle visuals in extraction overlay (#931)
## Summary - **Hint bar stability**: `a`/`x`/`esc` now always appear as trailing group in both pipeline and explore modes — mode-specific hints precede them - **Overlay height stability**: `stablePreviewLines()` reserves height from tallest tab across all preview groups, padding shorter tabs with newlines - **Tab persistence**: exiting explore mode preserves last-viewed preview tab instead of resetting to tab 0 - **Edge case**: unknown-table ops (empty preview groups) fall back to rendered line count ## Reproduction steps 1. Open extraction overlay on a document with preview data 2. Note position of `a`/`x`/`esc` in hint bar and overlay height 3. Press `x` to toggle explore mode 4. **Before fix**: hint bar jumps (x key moves), overlay height changes 5. **After fix**: hint bar stable, overlay height unchanged, tab preserved ## Changes - `internal/app/extraction_render.go` — hint bar reorder, `stablePreviewLines()`, tab persistence, unknown-table fallback - `internal/app/extraction_test.go` — 5 new tests covering all fix behaviors Closes #892 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c7c7a55 commit e92499d

4 files changed

Lines changed: 864 additions & 11 deletions

File tree

internal/app/extraction_render.go

Lines changed: 44 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,22 @@ 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+
if previewLines == 0 {
159+
// Fallback for unknown-table ops where no preview groups
160+
// are generated: use rendered section's actual line count.
161+
previewLines = strings.Count(previewSection, "\n") + 2
162+
} else {
163+
// Pad rendered section to stable height so the overlay
164+
// doesn't shrink when a shorter tab is displayed.
165+
actualLines := strings.Count(previewSection, "\n")
166+
targetLines := previewLines - 2 // -2: sep+blank added as separate parts
167+
if pad := targetLines - actualLines; pad > 0 {
168+
previewSection += strings.Repeat("\n", pad)
169+
}
170+
}
137171
}
138172

139173
maxH := max(m.effectiveHeight()*2/3-6-previewLines, 4)
@@ -209,9 +243,6 @@ func (m *Model) buildExtractionPipelineOverlay(
209243
if cursorStatus != stepPending {
210244
hints = append(hints, m.helpItem(symReturn, "expand"))
211245
}
212-
if hasOps {
213-
hints = append(hints, m.helpItem(keyX, "explore"))
214-
}
215246
if ex.Done {
216247
if ex.hasLLM {
217248
label := "layout on"
@@ -220,7 +251,11 @@ func (m *Model) buildExtractionPipelineOverlay(
220251
}
221252
hints = append(hints, m.helpItem(keyT, label))
222253
}
223-
hints = append(hints, m.helpItem(keyA, "accept"), m.helpItem(keyEsc, "discard"))
254+
if hasOps {
255+
hints = append(hints, m.helpItem(keyA, "accept"), m.helpItem(keyX, "explore"), m.helpItem(keyEsc, "discard"))
256+
} else {
257+
hints = append(hints, m.helpItem(keyA, "accept"), m.helpItem(keyEsc, "discard"))
258+
}
224259
} else {
225260
hints = append(hints,
226261
m.helpItem(symCtrlC, "int"),
@@ -279,12 +314,10 @@ func (m *Model) renderOperationPreviewSection(innerW int, interactive bool) stri
279314
tabBar := lipgloss.JoinHorizontal(lipgloss.Left, tabParts...)
280315
underline := m.styles.TabUnderline().Render(strings.Repeat(symHLineHeavy, innerW))
281316

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-
}
317+
// Always render the active preview tab. In explore mode it follows
318+
// user navigation; in pipeline mode it preserves the last-explored
319+
// tab so the view doesn't reset on toggle.
320+
tabIdx := ex.previewTab
288321
if tabIdx >= len(groups) {
289322
tabIdx = 0
290323
}

internal/app/extraction_test.go

Lines changed: 170 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 {
@@ -3644,3 +3652,165 @@ func TestExtractionClient_RetryClearsErrorOnSuccess(t *testing.T) {
36443652
require.NoError(t, m.ex.extractionClientErr)
36453653
require.Equal(t, "some-model", client.Model())
36463654
}
3655+
3656+
func TestExploreMode_HintBarTrailingOrder(t *testing.T) {
3657+
t.Parallel()
3658+
m := newPreviewModel(t, []extract.Operation{
3659+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "A"}},
3660+
})
3661+
ex := m.ex.extraction
3662+
3663+
// Pipeline mode: x should come after a and before esc.
3664+
pipelineOut := m.buildExtractionPipelineOverlay(100, 90, "test")
3665+
pipelinePlain := ansi.Strip(pipelineOut)
3666+
3667+
pipelineAcceptIdx := hintIndex(pipelinePlain, "accept")
3668+
pipelineExploreIdx := hintIndex(pipelinePlain, "explore")
3669+
pipelineEscIdx := hintIndex(pipelinePlain, "discard")
3670+
require.Positive(t, pipelineAcceptIdx, "accept hint should appear")
3671+
require.Positive(t, pipelineExploreIdx, "explore hint should appear")
3672+
require.Positive(t, pipelineEscIdx, "discard hint should appear")
3673+
assert.Less(t, pipelineAcceptIdx, pipelineExploreIdx,
3674+
"accept should come before explore")
3675+
assert.Less(t, pipelineExploreIdx, pipelineEscIdx,
3676+
"explore should come before discard")
3677+
3678+
// Enter explore mode: x should still come after a and before esc.
3679+
sendExtractionKey(m, "x")
3680+
require.True(t, ex.exploring)
3681+
3682+
exploreOut := m.buildExtractionPipelineOverlay(100, 90, "test")
3683+
explorePlain := ansi.Strip(exploreOut)
3684+
3685+
exploreAcceptIdx := hintIndex(explorePlain, "accept")
3686+
exploreBackIdx := hintIndex(explorePlain, "back")
3687+
exploreEscIdx := hintIndex(explorePlain, "discard")
3688+
require.Positive(t, exploreAcceptIdx, "accept hint should appear")
3689+
require.Positive(t, exploreBackIdx, "back hint should appear")
3690+
require.Positive(t, exploreEscIdx, "discard hint should appear")
3691+
assert.Less(t, exploreAcceptIdx, exploreBackIdx,
3692+
"accept should come before back")
3693+
assert.Less(t, exploreBackIdx, exploreEscIdx,
3694+
"back should come before discard")
3695+
}
3696+
3697+
func TestExploreMode_HintBarTrailingOrderMultiTab(t *testing.T) {
3698+
t.Parallel()
3699+
m := newPreviewModel(t, []extract.Operation{
3700+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "A"}},
3701+
{
3702+
Action: "create",
3703+
Table: data.TableQuotes,
3704+
Data: map[string]any{"total_cents": float64(100)},
3705+
},
3706+
})
3707+
ex := m.ex.extraction
3708+
3709+
// Explore mode with multiple tabs: b/f tabs hint appears but
3710+
// a/x/esc must still be trailing three.
3711+
sendExtractionKey(m, "x")
3712+
require.True(t, ex.exploring)
3713+
require.Greater(t, len(ex.previewGroups), 1, "need multiple tabs")
3714+
3715+
out := m.buildExtractionPipelineOverlay(100, 90, "test")
3716+
plain := ansi.Strip(out)
3717+
3718+
tabsIdx := hintIndex(plain, "tabs")
3719+
acceptIdx := hintIndex(plain, "accept")
3720+
backIdx := hintIndex(plain, "back")
3721+
escIdx := hintIndex(plain, "discard")
3722+
require.Positive(t, tabsIdx, "tabs hint should appear")
3723+
require.Positive(t, acceptIdx)
3724+
require.Positive(t, backIdx)
3725+
require.Positive(t, escIdx)
3726+
3727+
assert.Less(t, tabsIdx, acceptIdx,
3728+
"tabs should come before stable trailing group")
3729+
assert.Less(t, acceptIdx, backIdx,
3730+
"accept should come before back")
3731+
assert.Less(t, backIdx, escIdx,
3732+
"back should come before discard")
3733+
}
3734+
3735+
func TestExploreMode_OverlayHeightStableAcrossToggle(t *testing.T) {
3736+
t.Parallel()
3737+
// Two tables with different row counts to trigger height change.
3738+
m := newPreviewModel(t, []extract.Operation{
3739+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "A"}},
3740+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "B"}},
3741+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "C"}},
3742+
{
3743+
Action: "create",
3744+
Table: data.TableQuotes,
3745+
Data: map[string]any{"total_cents": float64(100)},
3746+
},
3747+
})
3748+
ex := m.ex.extraction
3749+
3750+
// Pipeline mode overlay.
3751+
pipelineOut := m.buildExtractionPipelineOverlay(100, 90, "test")
3752+
pipelineLines := strings.Count(pipelineOut, "\n")
3753+
3754+
// Enter explore mode and switch to tab with fewer rows.
3755+
sendExtractionKey(m, "x")
3756+
require.True(t, ex.exploring)
3757+
sendExtractionKey(m, "f") // switch to quotes tab (1 row vs 3)
3758+
require.Equal(t, 1, ex.previewTab)
3759+
3760+
exploreOut := m.buildExtractionPipelineOverlay(100, 90, "test")
3761+
exploreLines := strings.Count(exploreOut, "\n")
3762+
3763+
assert.Equal(t, pipelineLines, exploreLines,
3764+
"overlay height should be identical regardless of active tab row count")
3765+
}
3766+
3767+
func TestExploreMode_TabPersistsOnExit(t *testing.T) {
3768+
t.Parallel()
3769+
m := newPreviewModel(t, []extract.Operation{
3770+
{Action: "create", Table: data.TableVendors, Data: map[string]any{"name": "A"}},
3771+
{
3772+
Action: "create",
3773+
Table: data.TableQuotes,
3774+
Data: map[string]any{"total_cents": float64(100)},
3775+
},
3776+
})
3777+
ex := m.ex.extraction
3778+
3779+
// Enter explore, switch to tab 1, exit.
3780+
sendExtractionKey(m, "x")
3781+
require.True(t, ex.exploring)
3782+
sendExtractionKey(m, "f")
3783+
require.Equal(t, 1, ex.previewTab)
3784+
sendExtractionKey(m, "x") // exit explore
3785+
require.False(t, ex.exploring)
3786+
3787+
// Pipeline mode should now show tab 1 (dimmed), not reset to 0.
3788+
out := m.renderOperationPreviewSection(80, false)
3789+
plain := ansi.Strip(out)
3790+
// Tab 1 is Quotes -- its data should appear in rendered table.
3791+
assert.Contains(t, plain, "Total",
3792+
"pipeline mode should show last-explored tab content")
3793+
}
3794+
3795+
func TestExploreMode_UnknownTableOverlayStable(t *testing.T) {
3796+
t.Parallel()
3797+
// Unknown tables produce no preview groups but hasOps is still true.
3798+
// Overlay should render without panicking and maintain stable height.
3799+
m := newPreviewModel(t, []extract.Operation{
3800+
{Action: "create", Table: "unknown_table", Data: map[string]any{"x": "y"}},
3801+
})
3802+
3803+
out := m.buildExtractionPipelineOverlay(100, 90, "test")
3804+
plain := ansi.Strip(out)
3805+
lines := strings.Count(out, "\n")
3806+
3807+
assert.Positive(t, lines, "overlay should render with nonzero height")
3808+
assert.Contains(t, plain, "no operations",
3809+
"should show fallback text for unknown tables")
3810+
3811+
// Build again -- height should be identical (stable).
3812+
out2 := m.buildExtractionPipelineOverlay(100, 90, "test")
3813+
lines2 := strings.Count(out2, "\n")
3814+
assert.Equal(t, lines, lines2,
3815+
"overlay height should be stable across renders")
3816+
}

0 commit comments

Comments
 (0)