Back to Fresh

Code Tour — Utility Dock redesign

docs/internal/code-tour-dock-redesign.md

0.4.750.6 KB
Original Source

Code Tour — Utility Dock redesign

Design note. Status: IMPLEMENTED. The "Today" section records the behaviour this change replaced — it is kept because it is the evidence for every decision that follows, not because it still describes the plugin.

The guided-tour feature (plugins/code-tour.ts, driven by a .fresh-tour.json manifest) used to present each step in a floating action popup. This note records what that actually looked and felt like when driven by hand, and why it was replaced with a Utility Dock panel rendered through the plugin widget library — clickable Prev/Next controls, a step rail, and properly wrapped step prose.


1. Today

Driven manually in tmux at 160×48 against this repo's own .fresh-tour.json ("Fresh Plugin System Tour", 4 steps). Command palette → Tour: Load Definition... → accept .fresh-tour.json.

text
    32 │ //! }                                                                                    ┌Step 1/4: Plugin API Overview──────────────────────────[×]┐
    33 │ //!                                                                                      │## QuickJS Backend Overview                               │
    34 │ //! ## 3. Implement the API Method                                                       │point for the Fresh plugin system. It uses QuickJS, a     │
    35 │ //!                                                                                      │lightweight JavaScript engine, to execute TypeScript      │
    36 │ //! In `JsEditorApi`, use typed parameters for automatic deserialization:                 │plugins.                                                  │
    37 │ //! ```rust,ignore                                                                       │TypeScript and transpiled to JavaScript                   │
    38 │ //! /// Description of what this method does                                              │`JsEditorApi` struct exposes editor functionality to      │
    39 │ //! pub fn my_method(&self, language: String, config: MyConfig) -> bool {                 │plugins                                                   │
    40 │ //!     self.command_sender                                                               │`#[plugin_api]` attribute                                 │
    41 │ //!         .send(PluginCommand::MyCommand { language, config })                          │                                                          │
    42 │ //!         .is_ok()                                                                      │Next →                                                    │
    43 │ //! }                                                                                     │Exit Tour                                                 │
    44 │ //! ```                                                                                   └──────────────────────────────────────────────────────────┘

What is wrong with it

  1. The step text is mangled. The popup body is a plain 60-column text box with no markdown handling and a wrap that drops the head of every wrapped line. Step 1's This is the main entry point for… renders as point for the Fresh plugin system…; the **Key points:** line vanishes entirely; markdown bullets lose their - and their leading words. Step 2's - `InsertText` - Insert text at a position disappears outright, and - `SetStatus` - Update the status bar renders as Update the status bar. Step 3's fenced bash block collapses to a bare write_fresh_dts_file -- --ignored. Tour prose is authored in markdown — the schema's explanation field is prose with headings, bullets, and code fences — and none of it survives.

  2. It covers the code it is talking about. The popup is pinned bottom-right at a fixed 60×15, floating over the very buffer the step is explaining. On a 160-column terminal it wastes the width it could have had and still occludes a third of the visible source.

  3. It steals the keyboard. While the popup is up, arrow keys move the popup's selection instead of the cursor, and Ctrl+P does not open the command palette. You cannot scroll around the code you are being shown without first dismissing the tour.

  4. Dismissing it loses the tour. Esc closes the popup, but the tour is still "active" — and now there is no on-screen trace of it. No status-bar indicator, no panel, nothing. The only way back is the command palette (Tour: Next Step).

  5. There are no keybindings. The plugin's own header comment advertises "Space/Right (next), Backspace/Left (prev), Tab (resume), Esc (exit)". None of those are bound anywhere — not in the plugin, not in the default keymap. Tour: Next Step / Tour: Previous Step / Tour: Exit are palette-only, gated on the tour-active context.

  6. The actions are list rows, not buttons. ← Previous / Next → / Exit Tour are rows in a PopupContentData::List. They read as a menu, not as navigation, and Enter silently activates whichever row happens to be selected.

  7. The highlight does not render. In a colour capture of step 1 (lines 1–88) no line in the range carries the highlight background. Two independent bugs:

    • renderStepOverlays looks the buffer up with findBufferByPath(step.file_path), which compares PathBufs for equality. Buffers store absolute paths and a manifest's file_path is repo-relative, so the lookup always returns 0 and the function returns before adding any overlay. This is the one that bites today.
    • Behind it, the byte range comes from getLineStartPosition / getLineEndPosition, which answer for the active buffer rather than the one being highlighted. Latent today (nothing reaches it); fatal the moment the panel holds focus, which is exactly what the dock design does.
    • Behind that, a host bug: OverlayManager::query_viewport dropped any overlay whose two markers both fell outside the queried range — i.e. one that spans the viewport. Only found once the first two were fixed and the highlight started working for short step ranges but not tall ones. See §9.
  8. Long tours have no map. There is no way to see where you are in a 20-step tour, jump back three steps, or skim what is coming.

Points 1, 3, 4, 6 and 8 are all consequences of the same choice: a transient notification widget is the wrong container for a persistent, navigable reading surface.

The states, as built

Regenerated by the showcase test, the same way as every other feature demo:

sh
cargo nextest run -p fresh-editor --test e2e_tests code_tour_showcase -- --ignored
scripts/frames-to-gif.sh docs/blog/productivity/code-tour --colors 64 --dither none

2. Why the Utility Dock

The Utility Dock is the editor's existing shared bottom panel — a single role-tagged split leaf that Diagnostics, Search/Replace, Quickfix and dock terminals all swap into rather than each spawning their own split. It already provides, for free, everything the tour popup lacks:

  • Persistence with a visible handle. The dock has a tab bar; a *Tour: <title>* tab is durable proof the tour is still running, and clicking it returns to it. The tab bar holds several, so several tours can be open at once (§5).
  • Full width, user-resizable. Drag the separator; the panel reflows.
  • Focus you can leave and come back to. Alt+J (Toggle Utility Dock) moves keyboard focus between the dock and the editor. Read the code with the panel still on screen.
  • Coexistence. The tour tab sits beside Search/Replace and Diagnostics instead of floating over them.
  • A widget runtime. Panels mounted in the dock render a WidgetSpec tree, so buttons, lists and hint bars are host-owned: theming, focus affordance, click hit-testing, and virtual scrolling all come from the host.

For reference, this is the dock as Search/Replace uses it today, at the same 160×48:

text
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 *Search/Replace* ×   +                                                                                                                                      □×
 Search: [WidgetSpec                ]  Replace: [                         ]  (584 matches / 38 files)
 Files: [                                                      ]
 [v] All Files  [ ] Case(Alt+C)  [ ] Regex(Alt+R)  [ ] Whole(Alt+W)                                                                  [ Replace All (Alt+Ret) ]
Tab next  Space include/exclude  Enter open  Alt+Ret replace selected  Esc close
───────────────────────────────────────────────────────────── Matches (584 in 38 files) ──────────────────────────────────────────────────────────────────────
▼ [v] RS crates/fresh-core/src/api.rs (30/30)
    [v] crates/fresh-core/src/api.rs:1702 - // Plugins describe a widget tree as a `WidgetSpec`; the host reconciles the
    [v] crates/fresh-core/src/api.rs:1884 - pub enum WidgetSpec {

The tour panel should look like it belongs next to that.


3. Target design

3.1 Anatomy

Five bands, top to bottom:

BandContentWidgets
Tab barone *Tour: <title>* tab per open tour (host chrome)
Headertour title · step counter · progress meter · Prev / Next / Exitrow, raw, button
Rulefull-width separatordivider
BodySteps rail + step proserow of two labeledSections, each wrapping a list
Source linefile + line range, Jump / Re-highlightrow, button
Hintskey legendhintBar

3.2 Wireframes

A. Wide — 160×48 (the default laptop case)

text
 File   Edit   View   Selection   Go   LSP   Help
 api.rs ×   quickjs_backend.rs ×   +                                                            □×
   728 │     /// New pane first: left (vertical) or above (horizontal).
   729 │     Before,
   730 │ }
   731 │
▌  732 │ pub enum PluginCommand {          ░░░ step range 732–780 painted with the tour highlight ░░░
▌  733 │     InsertText { pos: usize, text: String },
▌  734 │     AddOverlay { ns: String, .. },
▌  735 │     SetStatus  { message: String },
▌   ⋮   ⋮
▌  779 │     RegisterCommand { name: String, .. },
▌  780 │ }
   781 │
~
~
~
~
~
~
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 *Tour: Plugin System* ×   *Tour: Rendering* ×   *Search/Replace* ×   +                                                                                       □×
 Fresh Plugin System Tour                                                                                Step 2 of 4  ▰▰▱▱   [ ◀ Prev ]  [ Next ▶ ]  [ ✕ Exit ] 
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
╭─ Steps ────────────────────────────────╮╭─ 2/4 · Plugin Command Types ───────────────────────────────────────────────────────────────────────────────────────╮
│  ✓ 1  Plugin API Overview              ││ PluginCommand Enum                                                                                                 │
│  ▸ 2  Plugin Command Types             ││ ──────────────────                                                                                                 │
│    3  TypeScript API Definition        ││                                                                                                                    │
│    4  Example Plugin: Git Find File    ││ Plugins communicate with the editor through typed commands. Each variant of this enum represents an                │
│                                        ││ action a plugin can request.                                                                                       │
│                                        ││                                                                                                                    │
│                                        ││ Common commands:                                                                                                   │
│                                        ││   • InsertText       Insert text at a position                                                                     │
│                                        ││   • AddOverlay       Add visual decorations                                                                        │
│                                        ││   • SetStatus        Update the status bar                                                                         │
│                                        ││   • RegisterCommand  Add to the command palette                                                                    │
╰────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 ▸ crates/fresh-core/src/api.rs   lines 732–780                                                                            [ Jump to code ⏎ ]  [ Re-highlight ] 
 n/→ next   p/← prev   ⏎ jump to code   ↑↓ scroll   Tab focus   g steps   q exit
 Trusted  Local  Ln 732, Col 1   Tour: step 2 of 4 — Plugin Command Types                                                     LF  UTF-8  Rust   Palette: Ctrl+P 

B. Medium — dock ≤ 110 cols: the Steps rail folds away (reachable with g)

text
────────────────────────────────────────────────────────────────────────────────────────────────────
 *Tour: Plugin System* ×   *Tour: Rendering* ×   +                                                □×
 Fresh Plugin System Tour                                             2/4 ▰▰▱▱  [ ◀ ]  [ ▶ ]  [ ✕ ] 
────────────────────────────────────────────────────────────────────────────────────────────────────
╭─ 2/4 · Plugin Command Types ─────────────────────────────────────────────────────────────────────╮
│ PluginCommand Enum                                                                               │
│ ──────────────────                                                                               │
│                                                                                                  │
│ Plugins communicate with the editor through typed                                                │
│ commands. Each variant of this enum represents an                                                │
│ action a plugin can request.                                                                     │
│                                                                                                  │
│ Common commands:                                                                                 │
│   • InsertText       Insert text at a position                                                   │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
 ▸ crates/fresh-core/src/api.rs:732–780                                                  [ Jump ⏎ ] 
 n next  p prev  ⏎ jump  ↑↓ scroll  g steps  q exit

C. Narrow — 72 cols: header wraps, button row gets its own line

text
────────────────────────────────────────────────────────────────────────
 *Tour: Plugin Sys…* ×   *Tour: Render…* ×   +                        □×
 Fresh Plugin System Tour                                      2/4 ▰▰▱▱ 
 [ ◀ Prev ]  [ Next ▶ ]  [ ✕ Exit ]
╭─ 2/4 · Plugin Command Types ─────────────────────────────────────────╮
│ PluginCommand Enum                                                   │
│                                                                      │
│ Plugins communicate with the editor through                          │
│ typed commands. Each variant of this enum                            │
│ represents an action a plugin can request.                           │
│                                                                      │
│ Common commands:                                                     │
╰──────────────────────────────────────────────────────────────────────╯
 ▸ api.rs:732–780
 n next  p prev  ⏎ jump  q exit

D. Terminal state — last step

text
──────────────────────────────────────────────────────────────────────────────────────────────────────────────
 *Tour: Plugin System* ×   *Tour: Rendering* ×   +                                                          □×
 Fresh Plugin System Tour                            Step 4 of 4  ▰▰▰▰   [ ◀ Prev ]  [ Next ▶ ]  [ ✓ Finish ] 
──────────────────────────────────────────────────────────────────────────────────────────────────────────────
╭─ 4/4 · Example Plugin: Git Find File ──────────────────────────────────────────────────────────────────────╮
│ Simple Plugin Example                                                                                      │
│ ─────────────────────                                                                                      │
│                                                                                                            │
│ This plugin demonstrates the core patterns:                                                                │
│                                                                                                            │
│   1. Get editor API with getEditor()                                                                       │
│   2. Define handler functions on globalThis                                                                │
│                                                                                                            │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 ▸ crates/fresh-editor/plugins/git_find_file.ts   lines 1–76             [ Jump to code ⏎ ]  [ Re-highlight ] 
 p/← prev   ⏎ jump to code   ↑↓ scroll   Tab focus   g steps   q finish tour

E. Degraded state — step points at a file that is gone

text
──────────────────────────────────────────────────────────────────────────────────────────────────────────────
 *Tour: Plugin System* ×   *Tour: Rendering* ×   +                                                          □×
 Fresh Plugin System Tour                              Step 3 of 4  ▰▰▰▱   [ ◀ Prev ]  [ Next ▶ ]  [ ✕ Exit ] 
──────────────────────────────────────────────────────────────────────────────────────────────────────────────
╭─ 3/4 · TypeScript API Definition ──────────────────────────────────────────────────────────────────────────╮
│ ⚠  crates/fresh-editor/plugins/lib/fresh.d.ts is not in this working tree.                                 │
│    The tour was recorded at commit a1b2c3d; you are on 4f103dc.                                            │
│                                                                                                            │
│ EditorAPI Interface                                                                                        │
│ ───────────────────                                                                                        │
│                                                                                                            │
│ This TypeScript interface defines all methods available to plugins.                                        │
│                                                                                                            │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 ⚠ file not found — step text still readable                                               [ Skip to next ▶ ] 
 n/→ next   p/← prev   ↑↓ scroll   g steps   q exit

3.3 Why each choice

  • Prose lives in a list, not a raw block. The list widget owns its scroll offset as host instance state and virtualises rows, so a 200-line step explanation scrolls inside the panel with ↑↓ / PageUp / PageDown and no scroll arithmetic in the plugin. A raw block would push the source line and hint bar off the bottom of the dock.

  • Markdown is rendered, not dumped. The plugin gains a small markdown-to-styledRow pass over the step's explanation, run once per step against the section's inner width (panel width minus the border and the Steps rail):

    • ##/### headings → bold, followed by an underline rule;
    • - / * bullets → with a hanging indent on wrap;
    • 1. ordered items → preserved numbering, hanging indent;
    • `code` → the inline-code theme key, backticks stripped;
    • **bold** → bold, markers stripped;
    • *emphasis* → italic, markers stripped (checked after ** so bold wins);
    • fenced blocks → indented two columns, code background, fence lines dropped. Each output row is one styledRow with segments; wrapping happens in the plugin because it must be re-run when the dock is resized (viewport_changed).
  • The Steps rail is a list, not a tree. A tour manifest is flat (steps: TourStep[]), so a tree's disclosure machinery buys nothing. The rail shows for visited, for current, blank for unvisited, and a click or Enter jumps straight to that step — this is the "map" that item 8 above is missing. It folds away below ~110 dock columns and stays reachable with g.

  • Prev/Next/Exit are real buttons. [ ◀ Prev ] / [ Next ▶ ] render through render_button, which gives them the shared focused fg/bg, the primary accent on Next, and — the point of the exercise — host hit-testing, so clicking them works. Prev is disabled: true on step 1 and Next on the last step, which keeps the row from reflowing as you move through the tour (today the popup simply omits the row, so the buttons jump around).

  • Exit becomes Finish on the last step. [ ✕ Exit ][ ✓ Finish ] with intent: "primary", so a completed tour has an affirmative end rather than an abandon.

  • The progress meter is ▰▰▱▱. Four to sixteen cells scaled to the step count, sitting next to Step 2 of 4. Legible in a plain capture, no colour dependency.

  • The source line is its own band. The step's file_path and line range are data the reader wants (and wants to click), not something to bury in the prose. [ Jump to code ⏎ ] moves focus into the editor split at the step's first line; [ Re-highlight ] re-applies the overlay after the user has wandered off — which is the "detour" case the current lastKnownTopByte / lastKnownBufferId fields in TourManager were clearly reaching for and never used.

3.4 Responsive rules

The panel re-renders on viewport_changed; three breakpoints, measured on the dock's own width:

Dock widthLayout
≥ 130Steps rail (26%) + prose (74%); full button labels; full hint bar
100 – 129Rail folds away (g opens it as a temporary full-width list); full labels
< 100Header title and the counter/button cluster split onto two lines via wrappingRow; buttons shrink to [ ◀ ] [ ▶ ] [ ✕ ]; hint bar trims to the four core keys

Dock height is requested at ratio: 0.35. Below ~8 rows of content the body section drops its border (labeledSection → bare list) to buy back two rows.


4. Interaction model

4.1 Keys — panel mode (code-tour-panel)

Registered with editor.defineMode("code-tour-panel", bindings, …), matching how Search/Replace binds its dock panel.

KeyAction
n, , Space, PageDown¹Next step
p, , Backspace, PageUp¹Previous step
EnterActivate focused widget — on the prose: jump to code; on the rail: go to that step
/ Scroll prose, or move the rail cursor, depending on focus
Tab / Shift+TabCycle focus: rail → prose → Prev → Next → Exit
gFocus the Steps rail (opening it first if folded)
rRe-apply the step highlight and re-centre the source view
q, EscExit the tour (closes the dock tab)
Alt+JHost builtin — toggle focus between dock and editor

¹ PageUp/PageDown step the tour only when focus is not on the prose list; with the prose focused they scroll it, which is the behaviour the widget runtime already gives for a focused list.

4.2 Keys — editor context, while tour-active

The point of the dock is that you can read the code with the tour still up. So step navigation must also work from the editor split:

KeyAction
Ctrl+Alt+NNext step
Ctrl+Alt+PPrevious step

Mnemonic, matching the n / p the panel itself uses.

Not Alt+] / Alt+[ — the default keymap gives those to next_split / prev_split. And not Ctrl+Alt+[ either, which is the trap this design originally fell into: terminals send Ctrl+[ as ESC, so the chord arrived as Escape and closed the tour instead of stepping back. The keymap already warns about it on the Search & Replace binding; driving the feature by hand is what surfaced it. Alt+n / Alt+p are find-selection, hence the Ctrl.

They bind in the normal context and no-op when no tour is open, so they cost nothing when the feature is unused.

4.3 Focus policy

  • Loading a tour mounts the dock panel focused, with the prose list focused inside it, so n/p work immediately.
  • Enter on the prose (or the [ Jump to code ⏎ ] button) moves focus to the editor split at the step's line. The panel stays mounted and keeps showing the step. Alt+J comes back.
  • Advancing a step re-renders the panel and parks focus on the prose list, because reading the step is what you do next. A List scrolls by moving its selection, so the prose also carries one — with the default -1 the hint bar's ↑↓ scroll would do nothing at all. Tab still reaches the rail and the buttons, and a user who tabs away keeps that focus until the step changes.
  • Focus stays in the dock, never jumping to the editor split: if you were reading in the editor, Ctrl+Alt+N advances the step and re-centres the source without yanking you into the dock.
  • Closing the dock tab (×, q, Esc) exits the tour: overlays cleared, tour-active unset. There is no longer an invisible-but-active state.

4.4 Events

Everything routes through one widget_event listener keyed on the panel id, the same shape Search/Replace uses:

event_typewidget_keyHandling
activateprev / next / exitStep navigation / teardown
activatejump / rehighlightFocus editor at step lines / re-apply overlay
selectstepListMove rail cursor; payload.via === "click" also jumps to that step
activatestepListJump to the selected step
selectproseListCursor move only — prose rows are not actionable


5. Multiple tours — one buffer per tour

A tour is a virtual buffer, not a singleton panel. Loading a second manifest opens a second buffer, and because both carry role: "utility_dock" the dock leaf shows them as sibling tabs:

text
 *Tour: Plugin System* ×   *Tour: Rendering* ×   *Search/Replace* ×   +                                                                                       □×

This is not a new capability — it is how the dock already behaves. Verified by opening Search/Replace and Diagnostics in one session:

text
 *Search/Replace* ×   *Diagnostics* ×   +                                                                                                                    □×

The dock dispatcher's fast path creates a fresh buffer and calls set_pane_buffer on the existing dock leaf, which adds it as a tab and makes it active. Nothing swaps a previous tour out.

What this forces in the plugin

The current plugin holds exactly one tour in a module-level tourManager singleton. That becomes a map, and four things that are implicitly global today become per-tour:

ConcernTodayMulti-tour
Tour stateone tourManager objectMap<tourId, TourInstance>, plus bufferId → tourId and panelId → tourId indexes
Overlay namespaceone constant "code-tour""code-tour:<tourId>" — so closing one tour cannot clear another's highlight, and two tours may highlight the same buffer at once
clearTourOverlaysclears the shared namespace on every open bufferclears only that tour's namespace
tour-active contextset on load, unset on exitset while any tour is open, unset when the last one closes

The panel mode (code-tour-panel) stays a single shared mode — modes are keyed by name, not by buffer. Every handler resolves its instance from editor.getActiveBufferId(), and the widget_event listener resolves its instance from args.panel_id, so both paths land on the right tour without the mode needing to know how many exist.

Tab naming and de-duplication

  • The tab is named from the manifest: *Tour: <manifest.title>*, truncated to the dock's per-tab cap. Two manifests with the same title get a disambiguating suffix (*Tour: Rendering (2)*).
  • The tour id is the resolved absolute manifest path. Loading a manifest that is already open focuses its existing tab instead of opening a duplicate — the plugin must do this itself, because the dock fast path runs before the panelId de-duplication path and will happily mint a second buffer.
  • buffer_closed on a tour buffer tears down that tour only: clear its overlay namespace, unmount its WidgetPanel, drop it from the map, drop it from persisted state.

Which tour do the editor-context keys drive?

Ctrl+Alt+N / Ctrl+Alt+P (§4.2) fire from the editor split, where there is no tour buffer to read. They target the most recently active tour: a lastTourId updated on any panel interaction and on buffer_activated for a tour buffer. With one tour open that is simply "the tour". With several, it is the one whose tab you last touched — which is also the one whose highlight is on screen.

Side effect worth having

Because each tour is a real buffer, the existing tab-drag machinery applies: a tour tab can be dragged out of the dock into an ordinary split. Two tours side by side, or one tour docked and another pinned to a right-hand pane, come for free — no additional design.


6. Persistence across restarts

An unfinished tour survives a restart, including its step position.

Mechanism

Plugin state written with editor.setWindowState(key, value) lands on the session's plugin_state, which is serialized into the per-directory workspace file as session_plugin_state and reloaded on restore. It is workspace-scoped, which is the right granularity: a tour is about this checkout.

The plugin keeps one key:

ts
type PersistedTour = {
  manifestPath: string;   // relative to the workspace root
  step: number;           // current step index
  visited: number[];      // step indices already seen — drives the ✓ column
  railOpen: boolean;      // user's Steps-rail preference for this tour
};

editor.setWindowState("openTours", tours);   // ordered: dock tab order

Write-through on every state change — step navigation, rail toggle, open, close. The writes are small and the API is already a snapshot write-through, so there is no need to batch them; a crash then costs at most the step you were on.

Restore

editor.on("ready", …) is the restore point. The ready lifecycle hook fires after the workspace restore and after initial buffers are opened, so getWindowState("openTours") already reflects the persisted session by the time the handler runs. (plugins_loaded fires earlier, before restore — too early.)

All persisted tours are restored, in stored tab order — no cap, no last-one-only shortcut. The list only ever holds tours the user left open (see below), so its length is already the user's own choice.

For each entry, in stored order:

  1. Re-read and re-validate the manifest. If it is gone, unparseable, or its schema_version no longer matches, skip it, drop it from the persisted list, and say so once on the status bar — one line naming the tours dropped, not one popup per tour.
  2. Re-check commit_hash against HEAD. Drift is normal after a restart, so it is surfaced the same way as at load time: the dim recorded at … · you are on … line in the header, not a blocking warning.
  3. Mount the dock tab and render the stored step, restoring visited so the rail's column is right.
  4. Do not open the step's source file, move the editor viewport, or take focus. Restoring a tour must not fight the workspace restore for what the editor is showing. The step highlight is applied only if the step's file happens to be among the restored buffers; otherwise [ Re-highlight ] is emphasised and applies it on demand.

Not taking focus needs saying explicitly, because mounting a dock panel does take it: the dock becomes the active split and gets the keyboard. Left alone, a tour left open weeks ago would swallow the first keystroke of every session. So restore records the active split up front and hands focus back once every tour is mounted — after a flush, so the queued set_active_split from the last mount cannot land afterwards and undo it. Restoring is not opening: the tab comes back, the focus does not move. Alt+J reaches the tour when the user wants it.

The last-active tour's tab is the one left selected in the dock.

Closing a tour forgets it

Closing a tour drops it from openTours, so it does not come back on the next launch. All four dismissal gestures are the same thing — × on the tab, q, Esc, and [ ✕ Exit ] all end with the buffer closed — and the single buffer_closed listener is what removes the entry. Finishing the last step with [ ✓ Finish ] closes it the same way: a completed tour is not pending work.

Closing goes through a small teardown rather than a bare closeBuffer: close_buffer substitutes a replacement buffer — resolved from the active split's focus history, so typically an ordinary source file — into every split still showing the closed one. For a dock pane that replaces the panel with a file, turning the Utility Dock into the tab strip for ordinary files it must never be. So teardown shows a sibling tour first when there is one, and otherwise closes the buffer, flushes, and asks what the dock now holds — describeWorkspace reads the plugin-state snapshot, which still describes the pre-close layout until the host drains the queued mutation — collapsing the dock when what is left is a file and leaving it standing when it is another plugin's panel.

Forgetting on close is only safe because quitting does not close buffers. The shutdown path auto-saves dirty buffers, ends the recovery session, calls save_all_windows_workspaces, and then drops the editor — no teardown loop over buffers, so buffer_closed never fires for a tour that was merely open at exit. Every call site of close_buffer is a real close: a tab's ×, a split collapsing, a plugin asking, a terminal exiting, a buffer being replaced. Without that property the listener would erase every tour on the way out and nothing would ever restore, so it is worth stating rather than assuming.

One consequence to accept deliberately: closing the dock split (the × in its control cluster) closes the buffers it holds, so the tours in it are forgotten too. That reads as dismissal, and there is no competing "hide the dock" gesture to confuse it with — Alt+J moves keyboard focus and leaves the dock mounted.

Interaction with restore settings

No special handling is needed for editor.restore_previous_session = false, --no-restore, or a first run in a directory: in each of those cases the workspace is not applied, so session_plugin_state is never loaded and getWindowState returns undefined. Tours simply do not come back, which matches what the flag promises for tabs and splits. --restore brings them back for the same reason.

Daemon mode needs nothing extra either — detach/reattach never tears the session down, so the tour buffers are still mounted when the client reconnects.


7. States

Beyond the steady state, five cases the panel must render (D and E are wireframed above):

  • First step[ ◀ Prev ] disabled, rail shows ▸ 1.
  • Last step[ Next ▶ ] disabled, Exit becomes [ ✓ Finish ].
  • Missing file — the step's file_path is not in the working tree. Today this shows ERROR: File not found glued in front of the explanation. Instead: a warning band at the top of the prose section naming the file and the commit drift, the step text still readable below it, and the source band offering [ Skip to next ▶ ] in place of Jump.
  • Commit driftmanifest.commit_hash does not match HEAD. Today this is a transient editor.warn at load time that scrolls away. Instead it becomes a persistent dim line in the header: recorded at a1b2c3d · you are on 4f103dc, and it is what the missing-file warning cites.
  • Manifest load failure — bad JSON, wrong schema_version, zero steps. No dock panel is mounted; these stay editor.error on the status bar, as now.

8. Spec sketch

ts
panel.set(col(
  // ── header ───────────────────────────────────────────────
  row(
    spacer(1),
    raw([styledRow([{ text: manifest.title, style: { bold: true } }])], "title"),
    flexSpacer(),
    raw([styledRow([
      { text: `Step ${i + 1} of ${n}  ` },
      { text: meter(i, n), style: { fg: "ui.help_key_fg" } },
    ])], "progress"),
    spacer(3),
    button(t("prev"), { key: "prev", disabled: i === 0 }),
    spacer(2),
    button(t("next"), { key: "next", intent: "primary", disabled: i === n - 1 }),
    spacer(2),
    button(i === n - 1 ? t("finish") : t("exit"),
           { key: "exit", intent: i === n - 1 ? "primary" : "normal" }),
    spacer(1),
  ),
  divider({ style: { fg: "ui.separator" } }),

  // ── body ─────────────────────────────────────────────────
  showRail
    ? row(
        labeledSection({ label: t("steps"), widthPct: 26, key: "railBox",
          child: list({ items: railRows, itemKeys: stepKeys,
                        selectedIndex: i, visibleRows: bodyRows, key: "stepList" }) }),
        labeledSection({ label: `${i + 1}/${n} · ${step.title}`, widthPct: 74, key: "proseBox",
          child: list({ items: proseRows, visibleRows: bodyRows,
                        focusable: true, key: "proseList" }) }),
      )
    : labeledSection({ label: `${i + 1}/${n} · ${step.title}`, key: "proseBox",
        child: list({ items: proseRows, visibleRows: bodyRows,
                      focusable: true, key: "proseList" }) }),

  // ── source line ──────────────────────────────────────────
  row(
    spacer(1),
    raw([locationRow(step, fileMissing)], "location"),
    flexSpacer(),
    fileMissing
      ? button(t("skip"), { key: "next" })
      : row(button(t("jump"), { key: "jump" }),
            spacer(2),
            button(t("rehighlight"), { key: "rehighlight" })),
    spacer(1),
  ),

  // ── hints ────────────────────────────────────────────────
  hintBar(hintsForWidth(dockWidth)),
));

Mount is the standard dock recipe — the same call Search/Replace makes, with role: "utility_dock" doing the swap-into-the-shared-leaf routing:

ts
const { bufferId, splitId } = await editor.createVirtualBufferInSplit({
  name: "*Tour*",
  mode: "code-tour-panel",
  role: "utility_dock",     // shared bottom dock, not a new split
  readOnly: true,
  editingDisabled: true,
  showLineNumbers: false,
  showCursors: false,
  scrollable: false,        // the lists own their scroll
  ratio: 0.35,
});
panel = new WidgetPanel(bufferId);

9. What changes in code-tour.ts

AreaChange
showStepPopupDeleted. Replaced by renderPanel() building the spec above.
action_popup_result listenerDeleted. Replaced by a widget_event listener.
TourManagerSingleton → Map<tourId, TourInstance> keyed by resolved manifest path, with bufferId → tourId and panelId → tourId indexes (§5). dockBufferId / dockSplitId / contentBufferId / contentSplitId are declared today and never assigned — per instance they become real, alongside widgetPanel, visited: Set<number>, railOpen, dockWidth.
TOUR_NAMESPACEConstant → code-tour:<tourId> per instance, so one tour's teardown cannot clear another's highlight. clearTourOverlays narrows to the instance's own namespace.
PersistenceNew: setWindowState("openTours", …) write-through on every step/rail/open/close change, and an editor.on("ready", …) restore pass (§6).
buffer_closedNew listener — tears down the one tour whose buffer closed; unsets tour-active only when the last tour goes.
renderStepOverlaysFix both highlight bugs (§1.7). A stepBufferId helper resolves a repo-relative file_path — literal, then cwd-joined, then a normalized suffix match over listBuffers — instead of relying on findBufferByPath alone. The byte range is computed from getBufferText(bufferId) + utf8ByteLength rather than the active-buffer line-position calls, so it is correct while the panel holds focus.
Mode + keysNew code-tour-panel mode with the table in §4.1; Ctrl+Alt+N / Ctrl+Alt+P in the default keymap.
MarkdownNew renderExplanation(md, width): TextPropertyEntry[].
i18nNew code-tour.i18n.json — the plugin currently hardcodes every user-visible string, unlike its neighbours.
Detour trackinglastKnownTopByte / lastKnownBufferId get their intended use: they drive whether [ Re-highlight ] is emphasised.

Every widget the design uses — button, list, labeledSection, divider, hintBar, spacer, flexSpacer, styledRow — already shipped, and the dock already accepted a widget panel via role: "utility_dock". The design was drafted expecting no host changes at all. Driving the result found three host bugs it was wrong about; all three are pre-existing and affect any widget panel, and the tour is simply the first panel whose shape exposes them.

Host areaChange
click_handlers.rsA click landing on a widget panel but missing every hit area — a labeledSection border, the padding under a short list — fell through to ordinary cursor placement. The buffer's cursor is hidden but the viewport still follows it, so the click scrolled the panel's own header and buttons out of view with no way back. Non-scrollable buffers were already swallowed for exactly this reason; widget panels were excluded so their clicks could route focus to the split. They now take the focus and stop, which was the stated intent.
widgets/registry.rsrow_select_hit — the fallback that lets a click past a compact row's text still select the row — returned the first list/tree hit on the buffer row, ignoring the column. Two lists side by side in a Row put two hits on one row, so every click in the right-hand column selected a row in the left-hand list. It now picks the rightmost row hit starting at or before the click; unchanged for a single-column panel.
view/overlay.rsquery_viewport required at least one of an overlay's two markers to fall inside the queried range. An overlay that spans the viewport has neither — start above the top, end below the bottom — so it was dropped even though it covered every visible line. A step range taller than the dock leaves visible is exactly that shape, which is why the highlight worked for some steps and silently did nothing for others. The marker query is an index; the overlap test below it is what decides visibility.
plugin_dispatch.rsshow_line_numbers is per (split, buffer) and the dock fast path never set it, so the second panel routed into the dock rendered with a line-number gutter the first one lacked — and the columns that gutter stole made the panel's widgets wrap. Visible today with Diagnostics beside Search/Replace; multi-tour makes it routine.

10. Open questions

Every question this note opened has been answered and folded into the design:

  • An unfinished tour persists across restarts, all open tours restore, and closing one forgets it (§6).
  • Each tour is its own buffer, so several can be open at once (§5).
  • Enter on the step rail changes the step and keeps focus in the panel. Moving to the code is the explicit gesture — Enter on the prose, the [ Jump to code ⏎ ] button, or Alt+J. Reading the rail is navigation within the tour, and having it throw you into the editor would make browsing a long tour cost a round trip back to the dock each time.
  • Authoring is delegated to agents, not an in-editor recorder. The Fresh CLI system prompt (FRESH_CLI_SYSTEM_PROMPT in orchestrator.ts, injected when "Teach Fresh CLI" is on) carries a recipe showing an agent how to write a manifest and open it, and the plugin publishes getPluginApi("code-tour").openTour(path) so a script can drive it. An agent that has just made a change can hand the human a tour of it. A "record step from selection" command remains possible, but is a separate feature.