docs/internal/tui-editor-layout-design.md
Status: Design Document Date: April 2026 Branch:
claude/design-tui-editor-layout-J5iU4Adapts: external "TUI Editor Layout & UX Design Spec" (April 2026) to fresh's actual editor architecture (modes, prompt, popup, splits, panel ids, Finder lib).
Two layout-shaped UX problems keep coming up in plugin reviews and usability sessions:
Live Grep is a tabbed full split. The current live_grep.ts plugin
uses Finder in prompt mode plus a vertical preview split. The prompt is
anchored to the minibuffer row; the suggestion list is rendered just above
it via the same machinery as the command palette. There is no centred
"search overlay" feel, no Resume affordance, and no way to dump current
results into a parked panel ("quickfix") without keeping the palette open.
This is tracked as
issue #1796 — Live Grep: use a floating/full-window UI instead of
splitting the focused pane,
which is the canonical bug for Section 1 of this design.
Utility panels spam splits. Every utility (diagnostics, search/replace,
merge conflict, future terminal/tasks/output) creates its own split via
editor.createVirtualBufferInSplit({ panelId, direction: "horizontal" }).
Today the panel_id mapping (app/mod.rs:786) prevents the same
utility from spawning twice, but two different utilities still produce
two separate splits — and neither remembers the user's preferred location.
This document specifies the two features in terms of fresh's existing primitives and identifies the smallest set of additions that make them work.
This section is a tour of the pieces this design composes — read it before the section that builds on it. File/line citations point at the current code.
KeyContext)crates/fresh-editor/src/input/keybindings.rs:221 defines:
Global, Normal, Prompt, Popup, Completion, FileExplorer, Menu, Terminal,
Settings, CompositeBuffer, Mode(String)
Two facts matter for this design:
Prompt and Popup can be active simultaneously. The
command palette already does this — typing happens in Prompt, while
Up/Down navigate the suggestion list owned by the same Prompt (see
prompt_select_prev / prompt_select_next in keymaps/default.json).
We do not need a Popup separately to scroll a result list under a
prompt; Prompt::suggestions already covers it.Mode("foo")): plugins use these via
editor.defineMode(...). diagnostics_panel.ts uses one for its panel
buffer; search_replace.ts uses mode:search-replace-list.view/prompt.rs:181)Prompt { message, input, cursor_pos, prompt_type, suggestions, selected_suggestion, scroll_offset, ... } is the minibuffer/picker model.
PromptType::QuickOpen (view/prompt.rs:38) is the unified Ctrl+P prompt
backed by the provider trait at input/quick_open/mod.rs:181.
The renderer draws the prompt on the bottom row and a suggestion list of up
to MAX_VISIBLE_SUGGESTIONS = 10 rows directly above it
(view/prompt.rs:215). It is not a centred overlay today.
view/popup.rs:219)Popup is the centred / cursor-anchored floating window primitive. Used for
LSP completion, hover, code actions, plugin action popups. PopupPosition
already supports Centered, Fixed { x, y }, and a few cursor-anchored
modes — exactly what a "floating overlay" needs. Confirm/cancel dispatch
goes through PopupResolver.
view/split.rs)Tree of SplitNode::{Leaf, Split, Grouped} (view/split.rs:68). Every
on-screen pane is a LeafId. SplitManager::split_active_positioned
(view/split.rs:1137) is the one entry point that creates new splits.
SplitViewState (view/split.rs:272) carries per-buffer view state and is
keyed by LeafId in Editor::split_view_states.
app/mod.rs:786)panel_ids: HashMap<String, BufferId>,
Plugins call editor.createVirtualBufferInSplit({ panelId, ... }). The
dispatcher at app/plugin_dispatch.rs:1986 does the singleton check: if the
panel_id already maps to an existing buffer, it updates content and
focuses the split that owns it; otherwise it creates a fresh split.
This is the seed of the "utility dock" — but the mapping is keyed per utility, not per role, and the location is not persisted across utility switches.
app/tab_drag.rs)Tabs (including utility-buffer tabs) can already be dragged to other splits
via move_tab_to_split (app/tab_drag.rs:245). That means once a panel
is hosted in a window, the user can already physically relocate it; the
missing piece is remembering that relocation for future utility opens.
plugins/lib/finder.ts)Finder<T> exposes three modes: prompt(...), panel(...), and
livePanel(...). live_grep.ts, diagnostics_panel.ts,
find_references.ts, git_grep.ts, and the file-finder all use it.
This is the single TS-side seam for both features below.
keymaps/default.json)The external spec calls for Ctrl+Shift+F, Alt+S, Alt+Shift+S, Alt+Q,
Alt+D, Alt+~, and Ctrl+Enter. Audited:
| Key | Status in default keymap |
|---|---|
Ctrl+Shift+F | free (Ctrl+F is search) |
Alt+S | free (Ctrl+S is save; Alt+S unbound) |
Alt+Shift+S | free |
Alt+Q | free (Ctrl+Q is quit — must not reuse) |
Alt+D | free |
| `Alt+`` | free (Alt+~ is the same shifted key) |
Ctrl+Enter | free |
Alt+] / Alt+[ are already bound to next_split / prev_split in
Normal context, which we rely on rather than replace.
Implements the user-facing requirements in issue #1796:
- render Live Grep as a floating window over the editor area, regardless of current split configuration (no permanent layout mutation);
Escdismisses the float and leaves the underlying splits untouched;- the user can restore / re-show the float without re-running the search, so they can flip between editing a result and viewing the next match;
- a two-column layout (input + result list on the left, file preview on the right) must work inside the floating window — the preview is no longer a permanent vertical split.
A centred floating overlay occupying ~80% of the terminal that combines:
Typing edits the query; arrows move the selection in the result list without losing focus on the input; Enter opens the selected match in the underlying split (the one that was active before the overlay opened); Esc closes the overlay, restoring the user's prior split layout exactly (issue #1796: "return to their previous split layout untouched"); the search state can be resumed; results can be exported into the Utility Dock as a parked Quickfix list.
The natural mapping is Prompt + Popup composite, with the prompt hidden from the bottom minibuffer row and instead drawn inside the popup frame. We do not need a new mode: the existing composite that the command palette uses already proves Prompt+Popup can coexist.
┌──────────────── Live Grep ──────────────────────[?]─[Resume]─[Quickfix]─[×]┐
│ Search: split_active| 12/142 │ src/view/split.rs │
│ ─────────────────────────────────────────── │ ──────────────────────────── │
│ src/view/split.rs:1117 pub fn split_a… │ 1115 ┊ │
│ src/view/split.rs:1123 self.split_act… │ 1116 ┊ /// Split the active │
│ src/view/split.rs:1128 pub fn split_a… │ 1117 ► pub fn split_active( │
│ src/view/split.rs:1137 pub fn split_a… │ 1118 ┊ &mut self, │
│ src/view/split.rs:1623 let result = m… │ 1119 ┊ direction: …, │
│ … │ 1120 ┊ buffer_id: …, │
│ │ 1121 ┊ ratio: f32, │
│ Prompt + suggestion list │ File preview pane │
│ (left half — input + Popup list) │ (right half) │
└────────────────────────────────────────────────────────────────────────────┘
A new PopupPosition::CenteredOverlay { width_pct, height_pct }
variant in view/popup.rs. The existing Centered variant clamps to a
fixed inner content size; the overlay needs to reserve a percentage of
the frame regardless of result count, so neither the input nor the
preview visually jumps when results stream in.
PromptType::LiveGrep added to view/prompt.rs:11. We do not reuse
Plugin { custom_type } because Live Grep needs first-class layout
handling (it is not minibuffer-rendered) and its key bindings differ
(Ctrl+Enter exports to dock, Up/Down navigate the attached popup list
rather than the prompt's own suggestion list).
A binding in the renderer (the place that today decides "if there is
a Prompt, draw it on the bottom row") that, if prompt.prompt_type == LiveGrep, draws the overlay frame over the editor area without
touching the split tree, and inside that frame:
The popup itself takes PopupPosition::CenteredOverlay { 80, 80 }.
Crucially, the editor's SplitManager is not modified while the
overlay is up — this is what guarantees issue #1796's "return to the
previous split layout untouched" requirement: there is no layout to
restore because no layout was changed.
Plugin side: extend Finder<T> with a floatingOverlay: true
option for the prompt mode, plus previewInOverlay: true so the
preview is rendered inside the float instead of into a sibling split.
live_grep.ts opts in to both. The plugin keeps using the same
prompt_* event handlers it already uses; it just gets a different
visual shell. The current behaviour — createVirtualBufferInSplit
with panelId: "live-grep-preview" and direction: "vertical" — is
removed for Live Grep; the preview lives inside the float.
| Action | Binding | Context | Notes |
|---|---|---|---|
| Open Live Grep | Alt+/ | Normal | Primary. Ctrl+F stays buffer-local search. Ctrl+Shift+F was rejected because most terminals collapse it to Ctrl+F. The / mnemonic mirrors the search character in many editors. |
| Resume last grep | Alt+r | Normal | Reopens the overlay with the prior query, selected index, and cached results — no re-run. The same key is toggle_search_regex in prompt context (disjoint, no conflict). |
| Move selection ↓ / ↑ | Down / Up | Prompt (prompt_type == LiveGrep) | Reuses prompt_select_next / prompt_select_prev. |
| Confirm — open in active split | Enter | Prompt | Reuses prompt_confirm. |
| Cancel | Esc | Prompt | Reuses prompt_cancel. |
| Export to Quickfix dock | Alt+Q or Ctrl+Enter | Prompt (prompt_type == LiveGrep) | New action live_grep_export_quickfix. Must not reuse Ctrl+Q — that is quit in Normal. |
Issue #1796 calls out "restore / re-show the floating Live Grep without re-running the search (so they can flip between editing a result and viewing the next match)." That requirement is what shapes this struct:
pub struct LiveGrepLastState {
pub query: String,
pub selected_index: Option<usize>,
/// Cached results from the last invocation. When present, Resume
/// re-displays them immediately and does NOT re-run the search;
/// the user gets a fresh search only by editing the query.
pub cached_results: Option<Vec<GrepMatch>>,
pub cached_at: Option<std::time::Instant>,
/// Optional Quickfix snapshot id (Section 1 → Section 2 hand-off).
pub last_results_snapshot_id: Option<u64>,
}
Resume (Alt+Shift+S):
query already typed and the prior
selected_index highlighted.cached_results is Some.
This is the issue-#1796 flip-between-edit-and-next-match flow.prompt.input) marks
cached_results = None and resumes normal debounced live search.If the user prefers always-fresh resumes, a config bool
live_grep.resume_reruns_search can flip default behaviour.
Alt+Q / Ctrl+Enter while in the Live Grep overlay does:
Vec<GrepMatch>.panel_id = "quickfix" (Section 2 explains why this is one panel id,
not per-feature).The dock buffer's live_grep mode adds a one-shot binding Alt+Shift+S
that re-opens the overlay seeded from the snapshot, completing the
Resume↔Quickfix round trip.
Diagnostics, search/replace results, terminal, build/test output, the Quickfix list from Section 1, and any future "panel-like" plugin all share one persistent, user-relocatable docking window. Opening any utility either spawns the dock once (default: a horizontal split at the bottom of the screen) or swaps the existing dock window's active buffer to that utility — never adds a new split.
The seed is already in app/mod.rs:786 — the panel_ids: HashMap<String, BufferId> mapping that app/plugin_dispatch.rs:1986 consults before
splitting. Today each utility owns its own panel_id ("diagnostics",
"search-replace-panel", "merge-theirs", …), so two different utilities
still spawn two splits.
The fix is: promote the singleton from per-utility to per-role. A new
utility_dock role attaches to a window in the split tree; any utility
opened with role: "utility_dock" lands in that window regardless of
which panel_id it carries.
SplitRole tag on SplitNode::Leaf. Today the leaf only carries
buffer_id and split_id (view/split.rs:68). Add:
SplitNode::Leaf {
buffer_id: BufferId,
split_id: LeafId,
#[serde(default)]
role: Option<SplitRole>, // None for ordinary editor leaves
}
pub enum SplitRole {
UtilityDock,
// future: BottomBar, Sidebar, etc.
}
Persisted across workspace serialization, the same way the existing
fixed_first / fixed_second fields are persisted (default None).
Editor::utility_dock_leaf: Option<LeafId>. Cached pointer to the
leaf currently tagged UtilityDock. Recomputed on workspace load by
walking the tree once. Always reconciled with the split tree on close
(so a manually closed dock leaf clears the cache).
Dispatcher change. In handle_create_virtual_buffer_in_split
(app/plugin_dispatch.rs:1969), when the request includes
role: "utility_dock":
utility_dock_leaf is Some(leaf) and the leaf still exists,
do not split. Swap the active buffer of that leaf to the new
virtual buffer (SplitViewState::switch_buffer already handles per-
buffer view state). Add the buffer as a tab in that leaf so users
can flip between, e.g., Diagnostics and Quickfix without losing
either.utility_dock_leaf is None, create a horizontal split at the
bottom (the current code path), tag the new leaf with
role = Some(UtilityDock), set utility_dock_leaf = Some(leaf).Plugin API surface. Extend editor.createVirtualBufferInSplit
options:
createVirtualBufferInSplit({
panelId: "diagnostics",
role: "utility_dock", // NEW — default unset for back-compat
direction: "horizontal",
ratio: 0.3,
// ...
})
role is advisory: when set and the dock exists, direction and
ratio are ignored (the dock's geometry wins). When the dock doesn't
exist yet, they seed its initial geometry.
| Plugin | Today | After |
|---|---|---|
diagnostics_panel.ts | own bottom split (panel_id: "diagnostics") | dock with role: "utility_dock" |
search_replace.ts | own bottom split (panel_id: "search-replace-panel") | dock |
find_references.ts | own panel via Finder | dock |
git_grep.ts | own preview split | overlay (Section 1) + dock for Quickfix |
| Quickfix (new) | n/a | dock with panel_id: "quickfix" |
| Built-in terminal | own split via open_terminal() | dock (opt-in via config; preserves the existing user mental model for power users who deliberately open terminals in custom splits) |
merge_conflict.ts keeps its own splits — its panel_ids
(merge-theirs, merge-result) are part of a 3-way diff layout, not a
docking utility. We do not force every multi-buffer plugin into the
dock.
The dock is just a leaf in the split tree, so existing window navigation already works:
Alt+] / Alt+[ (next_split / prev_split in Normal context) cycle
through it like any other split. No new binding needed.New bindings for direct focus and toggling:
| Action | Binding | Context | Notes |
|---|---|---|---|
| Focus / toggle dock | Alt+D | Normal | Mnemonic "Dock". If the dock exists, focus it; pressing again from inside the dock returns focus to the previous editor split. |
| Direct: terminal in dock | `Alt+`` | Normal | Opens a terminal in the dock (swap or create). Avoids `Ctrl+`` collisions on terminals that capture it. |
| Direct: diagnostics | (palette) | Normal | The existing palette command continues to work; no dedicated key, since power users already palette-driven. |
| Hide dock | Alt+D | Normal | When focus is already in the dock, Alt+D hides it (closes the leaf, keeps panel_id mappings — re-opening any utility re-creates the dock leaf with the same geometry seed). |
Because SplitRole::UtilityDock lives on the leaf, the user can already
move the dock anywhere via the existing move_tab_to_split machinery
(app/tab_drag.rs:245):
role of the source leaf if and only if the source leaf
is being absorbed (i.e., the source leaf's last tab is moved). This
keeps the rule simple: "the role follows the window, not the buffer."utility_dock_leaf is updated to point at the new location.If the user's drop splits the source leaf rather than absorbing it
(e.g., dragging only one of two utility tabs out), the original leaf
keeps the role and the dragged tab opens in a brand-new untagged leaf,
i.e., it stops being part of the dock. This is the principled answer to
"what if the user wants two docks?" — they don't, by construction; if
they need two utility windows, they untag one explicitly via a
palette command (Untag Utility Dock).
The invariant we maintain through every code path:
At most one leaf in the split tree has
role == UtilityDock.
Enforcement points:
UtilityDock (e.g., a hand-edited workspace file), the editor logs a
warning and clears the role from the second one on first paint.keymaps/default.json)New Action variants, registered in input/keybindings.rs:305:
Action::OpenLiveGrep, // string id: "open_live_grep"
Action::ResumeLiveGrep, // "resume_live_grep"
Action::ExportLiveGrepQuickfix, // "live_grep_export_quickfix"
Action::FocusUtilityDock, // "focus_utility_dock"
Action::ToggleUtilityDock, // "toggle_utility_dock"
Action::OpenTerminalInDock, // "open_terminal_in_dock"
Default keymap additions (concrete JSON shape mirrors existing entries
in keymaps/default.json):
// Live Grep — open / resume
{ "key": "f", "modifiers": ["ctrl", "shift"], "action": "open_live_grep", "when": "normal" },
{ "key": "s", "modifiers": ["alt"], "action": "open_live_grep", "when": "normal" },
{ "key": "s", "modifiers": ["alt", "shift"], "action": "resume_live_grep", "when": "normal" },
// Live Grep — Quickfix export (only inside the overlay's prompt)
{ "key": "q", "modifiers": ["alt"], "action": "live_grep_export_quickfix", "when": "prompt" },
{ "key": "Enter", "modifiers": ["ctrl"], "action": "live_grep_export_quickfix", "when": "prompt" },
// Utility Dock
{ "key": "d", "modifiers": ["alt"], "action": "toggle_utility_dock", "when": "normal" },
{ "key": "`", "modifiers": ["alt"], "action": "open_terminal_in_dock", "when": "normal" }
The live_grep_export_quickfix action has a prompt when clause —
it must only fire when the active prompt is the Live Grep overlay. The
handler short-circuits when prompt.prompt_type != PromptType::LiveGrep
to avoid stealing Alt+Q from the (already nonexistent, but defensive)
hypothetical other prompts.
| Proposed key | Pre-existing binding | Conflict? |
|---|---|---|
Ctrl+Shift+F | none (Ctrl+F is search) | no |
Alt+S | Alt+S is menu_open in Global | yes — collision with menu mnemonic |
Alt+Shift+S | none | no |
Alt+Q | none (Ctrl+Q is quit) | no |
Alt+D | none | no |
| `Alt+`` | none | no |
Ctrl+Enter | none | no |
The Alt+S conflict matters: in Global context, Alt+S opens the menu
bar's "Search" menu (line 65 of keymaps/default.json). Two viable
resolutions:
Alt+S as a Live Grep alias; keep only Ctrl+Shift+F and
Alt+Shift+S (Resume). The menu mnemonic stays.Alt+S → L reaches it
through the menu, and add Ctrl+Shift+F as the direct binding.This design recommends B, since the menu already has a Search entry
and adding "Live Grep…" under it is the most discoverable place for new
users. Ctrl+Shift+F covers the keyboard-driven path.
Each phase is independently shippable; no phase merges before the preceding ones land.
PopupPosition::CenteredOverlay { width_pct, height_pct } and
layout logic in view/popup.rs + the popup renderer.Debug: Show Centered Overlay that
pops a stub overlay with placeholder text; verify resizing and
Esc-dismiss in tmux at 80×24, 200×50, and 300×80.Acceptance: Esc dismisses without touching the split tree
(SplitManager snapshot before/after is byte-equal).
PromptType::LiveGrep + composite renderLiveGrep variant to PromptType (view/prompt.rs:11).prompt.suggestions inside the overlay frame instead of the
bottom row when prompt_type == LiveGrep.prompt_select_next / prompt_select_prev so they navigate the
list as drawn (already true — just verify no fall-throughs to
popup-context bindings).Acceptance: existing Live Grep ripgrep pipeline (untouched) renders inside the overlay; Esc closes; selection and Enter still open files in the underlying split.
panelId: "live-grep-preview" split from live_grep.ts.Acceptance: this phase closes issue #1796: the float covers the editor area, has the two-column layout from the issue, and dismissing returns the user to an unmodified split layout.
LiveGrepLastState cache and the Resume binding
(Alt+Shift+S).Alt+Shift+S, see prior results without a
re-search, Down arrow to next match, Enter again.SplitRole and the Editor::utility_dock_leaf cache.diagnostics_panel.ts and search_replace.ts to pass
role: "utility_dock". Verify only one bottom split exists when
flipping between them.Action::ExportLiveGrepQuickfix and the Alt+Q /
Ctrl+Enter overlay bindings.panel_id: "quickfix",
role: "utility_dock") flow.Alt+Shift+S binding that re-seeds the overlay
from the snapshot (closes the Resume↔Quickfix loop).move_tab_to_split to transfer SplitRole on absorption only.Action::ToggleUtilityDock (Alt+D) with the focus-toggle
semantics from Section 2.Mouse interaction with the overlay. Should clicking outside the
overlay frame dismiss it (Mac-style modal sheet) or be ignored
(terminal-classic — treat the overlay as opaque)? Recommendation:
ignore, with a single click on the title bar's [×] to close.
Keeps muscle memory consistent with the existing menu/popup behaviour.
Preview pane size when terminal is narrow. Below ~120 columns the
left/right split inside the overlay becomes cramped. Either:
(a) auto-stack the preview under the result list at narrow widths,
or (b) hide the preview and require Tab to flip into it. Issue #1796
doesn't take a side. Recommendation: (a), with a config
live_grep.preview_min_width_cols = 120.
Should Quickfix be one global list or one per source feature?
This design proposes one global list keyed by panel_id = "quickfix". Alternative: per-feature (quickfix.live_grep,
quickfix.lsp_references, …) tabbed in the dock. We can defer this
— start with the single list and split later if users complain.
Terminal in dock by default? The current open_terminal() in
app/terminal.rs creates its own split at the active position. Many
users like that. Section 2 routes terminals to the dock only when
invoked through the new Alt+\`` action; the existing Open
Terminal` menu entry stays put. Worth revisiting after a usability
pass.
Workspace-format compatibility. The role field on
SplitNode::Leaf is added with #[serde(default)], so old workspace
files load fine. New workspaces saved with a tagged dock won't load
on older builds (the field is silently ignored, the dock just looks
like an ordinary leaf). Acceptable.
PopupPosition::CenteredOverlay. Plugin authors get the
capability through Finder<T>'s floatingOverlay flag, not raw
access. Generalising further is a separate design.panel_ids without role.crates/fresh-editor/src/view/popup.rs (CenteredOverlay variant)
crates/fresh-editor/src/view/prompt.rs (PromptType::LiveGrep)
crates/fresh-editor/src/view/split.rs (SplitRole on Leaf)
crates/fresh-editor/src/input/keybindings.rs (new Action variants)
crates/fresh-editor/src/app/mod.rs (LiveGrepLastState,
utility_dock_leaf cache)
crates/fresh-editor/src/app/plugin_dispatch.rs (role-aware dispatcher)
crates/fresh-editor/src/app/tab_drag.rs (role transfer on absorb)
crates/fresh-editor/src/app/split_actions.rs (toggle_utility_dock)
crates/fresh-editor/keymaps/default.json (new bindings)
crates/fresh-editor/plugins/lib/finder.ts (floatingOverlay,
previewInOverlay,
role)
crates/fresh-editor/plugins/live_grep.ts (opt-in, drop preview split)
crates/fresh-editor/plugins/diagnostics_panel.ts (role: utility_dock)
crates/fresh-editor/plugins/search_replace.ts (role: utility_dock)
docs/internal/finder-abstraction.md — the TS-side seam.docs/internal/grouped-splitnode-design.md — prior art for adding a
variant/field to SplitNode.docs/internal/project-search-replace.md — the search backend Live
Grep continues to rely on.eval-diagnostics-panel.md — usability bugs in the existing
diagnostics panel that the dock migration should regression-test
against.