Skip to content

refactor(tui): reduce duplication across picker dialogs#2556

Merged
dgageot merged 5 commits intodocker:mainfrom
dgageot:board/reducing-duplication-in-tui-dialogs-3d619a71
Apr 28, 2026
Merged

refactor(tui): reduce duplication across picker dialogs#2556
dgageot merged 5 commits intodocker:mainfrom
dgageot:board/reducing-duplication-in-tui-dialogs-3d619a71

Conversation

@dgageot
Copy link
Copy Markdown
Member

@dgageot dgageot commented Apr 27, 2026

What

Extract shared infrastructure for the TUI list-with-filter picker dialogs (command palette, theme picker, model picker, file picker, working-directory picker) so each dialog focuses on its own filtering / rendering / submission logic instead of repeating the same scrollview/textinput/mouse/keymap plumbing.

Why

The five picker dialogs each carried a near-identical copy of:

  • textInput + scrollview + selected + click-tracking fields
  • commandPaletteKeyMap (private to one file but used by all five)
  • dialogSize() / Position() / SetSize()
  • mouse Y → item index math with double-click detection
  • empty-state and error-state padding loops
  • for model_picker and theme_picker: section/group separator rendering plus the line ↔ item bookkeeping needed for mouse hit-testing and selection scrolling
  • for model_picker and theme_picker: the same priority/current/default/name sort comparator

Bug fixes and improvements to any of these had to be made in 4-5 places.

How

A new pkg/tui/dialog/picker.go introduces:

Symbol Purpose
pickerKeyMap / defaultPickerKeyMap() replaces the old, misnamed commandPaletteKeyMap
pickerLayout declarative dialog dimensions + chrome offsets
pickerCore embeddable struct owning textInput, scrollview, selected, double-click tracking, sizing, position, mouse hit-testing, navigation, placeholder rendering
groupedList builder that tracks the line ↔ item mapping for lists with separators/headers (used by model and theme pickers)
pickerSortKeys / comparePickerSortKeys the section/current/default/name comparator

Plus three small `pickerCore` helpers that absorb patterns repeated across every picker:

  • updateInput(msg, filter) — feed the textInput, run the filter, return the cmd
  • handleListClick(msg, lineToItem) — mouse hit-test + selection update + double-click detection in one call
  • navigate(delta, num, lineForSelected) — bounded selection move with EnsureLineVisible

`base.go` gains `RenderGroupSeparator(label, contentWidth)` to replace three hand-rolled `"── Label ──…"` constructions.

For `working_dir_picker.go`, an `activeSection()` snapshot returning `{entries, selected, scroll}` collapses what used to be 3-arm switches in `moveUp` / `moveDown` / `setSelected` / `handleSelection` / `toggleFavorite` / `mouseYToEntryIndex` / `cycleSection`.

Per-dialog impact

File Before After
command_palette.go 378 252
theme_picker.go 549 371
model_picker.go 873 662
file_picker.go 419 374
working_dir_picker.go 1087 1023
picker.go (NEW) 391

Net diff: -275 lines, and each dialog now reads as orchestration over the shared helpers.

No behaviour change

  • Diff-reviewed against the pre-refactor commit (`3bc200cd3`).
  • Two real fixes folded into the series:
    • `file_picker`: `maxNameLen := maxWidth - 20` could go negative in narrow terminals and panic on `name[:maxNameLen-1]`. Now clamped to `max(1, …)`. (Pre-existing bug, surfaced during review.)
    • `command_palette.filterCommands`: restored the original `reset selected to 0 when query is cleared` behaviour (matches the file picker; preserves selection only when filtering with a non-empty query).
    • `working_dir_picker.setSection`: now delegates to `updateSectionFocus` instead of duplicating its body.

Validation

  • `go build ./...` ✅
  • `go test ./pkg/tui/dialog/...` ✅
  • `mise lint` ✅ 0 issues
  • Manual diff review of every `Update()` / `View()` / filter / selection / sort path against the pre-refactor baseline (most of this code isn't covered by tests).

Commits

```
09beb18 fix(tui/dialog): preserve original behaviour after picker refactor
f246707 fix(tui/dialog): avoid negative slice index when truncating file names
199a80c refactor(tui/dialog): inline boolCompare, drop unused CommandExecuteMsg
0146793 refactor(tui): simplify picker dialogs around shared helpers
6ba31b4 refactor(tui): extract shared picker infrastructure
```

dgageot added 5 commits April 27, 2026 18:42
Reduce duplication across the list-with-filter dialogs (command palette,
theme picker, model picker, file picker, working-dir picker) by introducing
a shared picker.go with:

- pickerKeyMap / defaultPickerKeyMap (replaces commandPaletteKeyMap)
- pickerLayout: declarative dimensions
- pickerCore: embeddable type owning textInput, scrollview, selected,
  double-click tracking, sizing, position, mouseListIndex, recordClick,
  renderEmptyState, renderErrorState
- groupedList: builder that tracks line<->item mapping for lists with
  separators (used by model_picker and theme_picker)
- pickerSortKeys / comparePickerSortKeys: shared section/current/default/name
  comparator

Also adds RenderGroupSeparator to base.go (replaces three hand-rolled
'── Label ──…' constructions).

Result:
- command_palette.go: 378 -> 294 lines
- theme_picker.go:    549 -> 411 lines
- model_picker.go:    873 -> 702 lines
- file_picker.go:     419 -> 378 lines

No behaviour change. Tests + lint pass.

Assisted-By: docker-agent
Builds on the previous picker refactor by:

* Moving the shared picker* sizing constants from model_picker.go into
  picker.go where they belong (used by both model and theme pickers).
* Adding three small pickerCore helpers that capture patterns repeated
  across every picker:
    - updateInput(msg, filter): forward input to the textinput, run the
      filter callback, return the textinput cmd
    - handleListClick(msg, lineToItem): map the click to an item index,
      detect double-click, update selection, return (doubleClicked, changed)
    - navigate(delta, num, lineForSelected): bounded selection move that
      keeps the new selection visible
  Each picker's Update() loop becomes mostly a thin dispatch over these.
* Dropping the cached *groupedList field from theme_picker and model_picker.
  buildList(0) is cheap (O(n)), runs at most twice per Update, and removing
  the cache eliminates an awkward 'overwritten with empty content' state.
* Replacing the one-shot renderPaddedState with a single renderPlaceholder
  used by both renderEmptyState and renderErrorState.

For working_dir_picker.go, the 3 sections (browse/recent/pinned) had triple
copies of selection/scroll/entries logic. Introduced an activeSection()
helper that returns a {entries, *selected, scroll} snapshot, which collapses:
* moveUp / moveDown (3-arm switch -> 4 lines each)
* setSelected (3-arm switch -> 1 line)
* handleSelection (3 bounds-checks -> 1)
* toggleFavorite (3 bounds-checks -> 1, via selectedTogglePath helper)
* mouseYToEntryIndex (3-arm switch -> uses listStartOffset/sectionOverhead)
* cycleSection{Forward,Backward} (two switches -> shared cycleSection(delta))
* renderXList: the empty/error placeholder padding loop (4 copies) is now
  a single renderListPlaceholder helper

View() also collapses the three near-identical NewContent.AddX().AddX()
chains into a single builder with a conditional textInput row.

No behaviour change. Tests + lint pass.

Net diff: -112 lines across the 6 picker files.

Assisted-By: docker-agent
Inline the boolCompare helper inside comparePickerSortKeys: the swapped
arguments were less clear than spelling out the comparison directly.

CommandExecuteMsg has no callers anywhere in the workspace, so remove
its declaration from command_palette.go.

No behaviour change. Tests + lint pass.

Assisted-By: docker-agent
renderEntry computed maxNameLen as maxWidth-20 without a lower bound. When
the file picker is rendered in a very narrow terminal (maxWidth < 21),
maxNameLen goes <= 0, the unconditional 'len(name) > maxNameLen' branch is
taken, and 'name[:maxNameLen-1]' panics with a negative slice index.

Clamp maxNameLen to a minimum of 1 so very narrow widths render the file
name as a single ellipsis instead of crashing.

Pre-existing bug; surfaced during a code-quality review of the picker
refactor on this branch.

Assisted-By: docker-agent
Two issues found by a careful diff against the pre-refactor baseline:

1. command_palette.filterCommands: the original code always reset
   d.selected = 0 when the search field was cleared (matching how the
   file picker behaves). The refactored version preserved the previous
   selection across the filter call, which caused the cursor to jump to
   an unrelated command after typing then clearing a query. Restore the
   original 'reset on empty query, keep otherwise' semantics.

2. working_dir_picker.setSection still inlined the focus-toggle logic
   that updateSectionFocus already implements. Delegate to
   updateSectionFocus so the two stay in lockstep. (Pre-existing
   duplication that the refactor missed.)

Assisted-By: docker-agent
@dgageot dgageot requested a review from a team as a code owner April 27, 2026 17:24
@dgageot dgageot merged commit 419cca0 into docker:main Apr 28, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants