docs/internal/code-tour-design.md
Status: Implementation Phase
Author: Claude
Date: 2026-02-02
Branch: claude/implement-code-tour-zIYjM
Code Tour is a JSON-driven walkthrough system that guides users through a codebase using visual overlays and explanatory text. This document analyzes whether the feature can be implemented as a plugin and identifies the required plugin API additions.
Conclusion: Code Tour CAN be built as a plugin with the addition of 4 new plugin API methods.
| Heuristic | Implementation |
|---|---|
| Visibility | Persistent status indicator "Tour Mode: Step X/Y" in status bar |
| User Control | User can scroll away (pause) and resume with Tab key |
| Recognition over Recall | All commands in Command Palette, no obscure :commands |
| Focus (Figure/Ground) | Active lines highlighted, context lines dimmed |
interface TourStep {
step_id: number;
title: string;
file_path: string; // Relative to project root
lines: [number, number]; // Start and End line (1-indexed, inclusive)
explanation: string; // Markdown supported text
overlay_config?: {
type: 'block' | 'line';
focus_mode: boolean; // If true, dim non-active lines
};
}
interface TourManifest {
$schema?: string; // Optional: reference to JSON schema
title: string;
description: string;
schema_version: "1.0";
commit_hash?: string; // Optional: verify source matches expected state
steps: TourStep[];
}
The schema file should be placed at plugins/code-tour/tour-schema.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://fresh-editor.dev/schemas/tour-manifest-v1.json",
"title": "Fresh Code Tour Manifest",
"description": "Schema for .fresh-tour.json files that define guided code walkthroughs",
"type": "object",
"required": ["title", "description", "schema_version", "steps"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"description": "Reference to this JSON schema for editor validation"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"description": "Display title for the tour"
},
"description": {
"type": "string",
"maxLength": 500,
"description": "Brief description of what this tour covers"
},
"schema_version": {
"type": "string",
"enum": ["1.0"],
"description": "Schema version for forward compatibility"
},
"commit_hash": {
"type": "string",
"pattern": "^[a-f0-9]{7,40}$",
"description": "Git commit hash (short or full) this tour was created for"
},
"steps": {
"type": "array",
"minItems": 1,
"description": "Ordered list of tour steps",
"items": {
"$ref": "#/definitions/TourStep"
}
}
},
"definitions": {
"TourStep": {
"type": "object",
"required": ["step_id", "title", "file_path", "lines", "explanation"],
"additionalProperties": false,
"properties": {
"step_id": {
"type": "integer",
"minimum": 1,
"description": "Unique identifier for this step (1-indexed)"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"description": "Short title displayed in the tour dock"
},
"file_path": {
"type": "string",
"minLength": 1,
"description": "Path to the file, relative to project root"
},
"lines": {
"type": "array",
"items": {
"type": "integer",
"minimum": 1
},
"minItems": 2,
"maxItems": 2,
"description": "Line range [start, end] (1-indexed, inclusive)"
},
"explanation": {
"type": "string",
"description": "Markdown-formatted explanation shown in the tour dock"
},
"overlay_config": {
"$ref": "#/definitions/OverlayConfig"
}
}
},
"OverlayConfig": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["block", "line"],
"default": "block",
"description": "block = highlight entire range, line = highlight each line separately"
},
"focus_mode": {
"type": "boolean",
"default": false,
"description": "When true, dims lines outside the active range"
}
}
}
}
}
.fresh-tour.json{
"$schema": "./tour-schema.json",
"title": "Fresh Plugin System Tour",
"description": "Learn how plugins work in Fresh - from loading to execution",
"schema_version": "1.0",
"commit_hash": "ee3bda2",
"steps": [
{
"step_id": 1,
"title": "Plugin Entry Point",
"file_path": "crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs",
"lines": [1, 50],
"explanation": "## QuickJS Backend\n\nThis module provides the JavaScript runtime for executing TypeScript plugins.\n\n**Key concepts:**\n- Plugins run in a sandboxed QuickJS environment\n- TypeScript is transpiled to JavaScript using oxc\n- The `JsEditorApi` struct exposes editor functionality to plugins",
"overlay_config": {
"type": "block",
"focus_mode": true
}
},
{
"step_id": 2,
"title": "Plugin Command System",
"file_path": "crates/fresh-core/src/api.rs",
"lines": [735, 800],
"explanation": "## PluginCommand Enum\n\nPlugins communicate with the editor through commands.\n\nEach variant represents an action the plugin can request:\n- `InsertText` - Insert text at a position\n- `AddOverlay` - Add visual decorations\n- `OpenFile` - Navigate to a file\n\nCommands are sent through a channel and processed by the editor's main loop."
}
]
}
| Capability | Plugin API | Code Tour Use |
|---|---|---|
| Line highlighting | addOverlay(bufferId, namespace, start, end, options) | Highlight active tour step lines |
| Virtual text | addVirtualText(), addVirtualLine() | Step annotations/explanations |
| File navigation | openFile(path, line, column) | Navigate to step file/position |
| Command palette | registerCommand(name, desc, handler, context) | Tour: Next, Previous, Exit |
| Scroll detection | ViewportChanged hook | Detect user scroll (detour detection) |
| Cursor positioning | setBufferCursor(bufferId, position) | Position cursor at step |
| Scroll control | setSplitScroll(splitId, topByte) | Scroll to step location |
| Line byte offset | getLineStartPosition(line) async | Convert line numbers to byte offsets |
| File reading | readFile(path) | Load .fresh-tour.json manifest |
| File existence | fileExists(path) | Check if step files exist |
| Status messages | setStatus(message) | Show "Tour Mode" indicator |
| Custom keybindings | defineMode(name, parent, bindings) | Tour navigation keys |
| Prompts | prompt(label, initial, suggestions) | File picker for loading tours |
| Viewport info | getViewport() | Get current viewport dimensions |
| Buffer info | getBufferInfo(bufferId) | Get buffer path and length |
The following capabilities are missing and need to be added:
scrollToLineCenter(splitId, bufferId, line)Problem: setSplitScroll() requires a raw byte offset. There's no easy way to scroll such that a specific line is centered in the viewport.
Solution: Add a new API that:
// Proposed API
editor.scrollToLineCenter(splitId: number, bufferId: number, line: number): boolean
getLineEndPosition(bufferId, line)Problem: Only getLineStartPosition() exists. To highlight a line range, we need both start AND end byte offsets.
Solution: Add async method similar to getLineStartPosition():
// Proposed API
editor.getLineEndPosition(bufferId: number, line: number): Promise<number | null>
getBufferLineCount(bufferId)Problem: To validate that step line ranges are valid, we need to know the total line count.
Solution: Add to EditorStateSnapshot and expose via API:
// Proposed API
editor.getBufferLineCount(bufferId: number): number | null
Problem: Current overlays require exact byte ranges. For focus mode dimming, we need overlays that extend to the visual end of each line, regardless of actual content length.
Existing Support: The Overlay struct already has extend_to_line_end: bool field, but it's not exposed to the plugin API.
Solution: Expose extendToLineEnd option in addOverlay():
// Proposed API addition to OverlayOptions
interface OverlayOptions {
// ... existing fields ...
extendToLineEnd?: boolean; // NEW: Extend background to visual line end
}
| Feature | Reason Not Needed |
|---|---|
| Code Folding API | Fresh doesn't have folding implemented |
| Persistent Dock Panel | Virtual buffer with panel_id achieves similar result |
┌──────────────────────────────────────────────┐
│ │
▼ │
┌─────────┐ load_tour() ┌─────────────┐ │
│ IDLE │ ──────────────────▶ │ ACTIVE │ │
└─────────┘ └─────────────┘ │
▲ │ │
│ │ │
│ exit_tour() │ │
└────────────────────────────────┘ │
│ │
user_scrolls_away() │ │
│ │ │
▼ │ │
┌─────────────┐ │ │
│ PAUSED │ │ │
│ (Detached) │ │ │
└─────────────┘ │ │
│ │ │
resume_location() │ │
│ │ │
└───────────────┘ │
│
next_step() / prev_step() │
│ │
└─────────────────────────────┘
type TourState =
| { kind: 'idle' }
| { kind: 'active', currentStep: number, isPaused: boolean }
interface TourManager {
state: TourState;
manifest: TourManifest | null;
dockBufferId: number | null; // Virtual buffer for Tour Dock
dockSplitId: number | null; // Split containing the dock
overlayNamespace: string; // "code-tour" for cleanup
// Track last known viewport for detour detection
lastKnownTopByte: number;
lastKnownBufferId: number;
}
| Current State | Event | Next State | Actions |
|---|---|---|---|
| IDLE | loadTour(manifest) | ACTIVE(step=0) | Parse JSON, create dock, go to step 0 |
| ACTIVE | nextStep() | ACTIVE(step+1) | Clear overlays, navigate, highlight |
| ACTIVE | prevStep() | ACTIVE(step-1) | Clear overlays, navigate, highlight |
| ACTIVE | viewport_changed (user scroll) | ACTIVE(paused=true) | Dim dock, show "Paused" |
| ACTIVE(paused) | resumeLocation() | ACTIVE(paused=false) | Scroll back to step location |
| ACTIVE | exitTour() | IDLE | Clear overlays, close dock |
| ACTIVE | file missing | ACTIVE | Show "Broken Link" in dock, allow skip |
plugins/
└── code-tour/
├── index.ts # Main plugin entry point
├── tour-manager.ts # TourManager state machine
├── tour-renderer.ts # Overlay and virtual text rendering
├── tour-dock.ts # Tour Dock UI (virtual buffer)
└── types.ts # TypeScript types for manifest
// In index.ts
editor.registerCommand(
"tour:load",
"Tour: Load Definition...",
"handleLoadTour" // No context = always visible
);
editor.registerCommand(
"tour:next",
"Tour: Next Step",
"handleNextStep",
"tour-mode" // Only when tour is active
);
editor.registerCommand(
"tour:prev",
"Tour: Previous Step",
"handlePrevStep",
"tour-mode"
);
editor.registerCommand(
"tour:resume",
"Tour: Resume Location",
"handleResumeLocation",
"tour-mode"
);
editor.registerCommand(
"tour:exit",
"Tour: Exit",
"handleExitTour",
"tour-mode"
);
editor.defineMode(
"tour-mode",
"normal", // Parent mode
[
["<Space>", "tour:next"],
["<Right>", "tour:next"],
["<Backspace>", "tour:prev"],
["<Left>", "tour:prev"],
["<Tab>", "tour:resume"],
["<Escape>", "tour:exit"],
],
true // read_only
);
async function renderStepOverlays(step: TourStep) {
const bufferId = await editor.findBufferByPath(step.file_path);
if (!bufferId) return;
// Get line positions
const startPos = await editor.getLineStartPosition(step.lines[0] - 1);
const endPos = await editor.getLineEndPosition(step.lines[1] - 1);
if (startPos === null || endPos === null) {
// Handle version mismatch - clamp to file end
return renderClampedOverlay(bufferId, step);
}
// Clear previous overlays
editor.clearNamespace(bufferId, TOUR_NAMESPACE);
// Add highlight overlay for active lines
editor.addOverlay(bufferId, TOUR_NAMESPACE, startPos, endPos, {
bg: "tour.active_line_bg", // Theme key
extendToLineEnd: true,
priority: 100,
});
// If focus mode, dim surrounding context
if (step.overlay_config?.focus_mode) {
await renderDimmedContext(bufferId, startPos, endPos);
}
}
The Tour Dock is a virtual buffer that displays:
async function updateTourDock(step: TourStep, stepIndex: number, totalSteps: number) {
const entries: TextPropertyEntry[] = [
{
text: `Step ${stepIndex + 1} of ${totalSteps}`,
properties: { style: "bold", fg: [100, 200, 255] }
},
{ text: `\n\n${step.title}\n`, properties: { style: "bold" } },
{ text: `\n${step.explanation}\n`, properties: {} },
{ text: `\n─────────────────────────\n`, properties: { fg: [80, 80, 80] } },
{ text: `Space/→ Next ←/Backspace Prev Tab Resume Esc Exit`, properties: { fg: [120, 120, 120] } },
];
if (tourManager.dockBufferId) {
editor.setVirtualBufferContent(tourManager.dockBufferId, entries);
}
}
// Subscribe to viewport changes
editor.on("viewport_changed", "handleViewportChanged");
function handleViewportChanged(event: ViewportChangedEvent) {
if (tourManager.state.kind !== 'active') return;
// Check if this is the buffer we're touring
if (event.buffer_id !== tourManager.lastKnownBufferId) return;
// If scroll position changed significantly, user has "wandered"
const scrollDelta = Math.abs(event.top_byte - tourManager.lastKnownTopByte);
const threshold = event.height * 50; // ~50 bytes per line estimate
if (scrollDelta > threshold && !tourManager.state.isPaused) {
tourManager.state = { ...tourManager.state, isPaused: true };
showPausedIndicator();
}
}
Add to fresh-core/src/api.rs:
/// Scroll a split to center a specific line in the viewport
ScrollToLineCenter {
split_id: SplitId,
buffer_id: BufferId,
line: usize, // 0-indexed
},
/// Get the byte offset of the end of a line (async)
GetLineEndPosition {
buffer_id: BufferId,
line: usize, // 0-indexed
request_id: u64,
},
/// Get the total line count of a buffer
GetBufferLineCount {
buffer_id: BufferId,
request_id: u64,
},
/// Response to GetLineEndPosition
LineEndPosition {
request_id: u64,
position: Option<usize>,
},
/// Response to GetBufferLineCount
BufferLineCount {
request_id: u64,
count: Option<usize>,
},
Expose extend_to_line_end in the plugin API:
// In fresh-core/src/api.rs, OverlayOptions struct
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct OverlayOptions {
// ... existing fields ...
/// Extend the overlay background to the visual end of the line
/// Useful for full-line highlighting effects
#[serde(default)]
pub extend_to_line_end: bool,
}
Add to fresh-plugin-runtime/src/backend/quickjs_backend.rs:
/// Scroll to center a line in the viewport
pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
self.command_sender
.send(PluginCommand::ScrollToLineCenter {
split_id: SplitId(split_id as usize),
buffer_id: BufferId(buffer_id as usize),
line: line as usize,
})
.is_ok()
}
/// Get the byte offset of the end of a line (0-indexed)
#[plugin_api(async_promise, js_name = "getLineEndPosition", ts_return = "number | null")]
#[qjs(rename = "_getLineEndPositionStart")]
pub fn get_line_end_position_start(&self, buffer_id: u32, line: u32) -> u64 {
// Implementation similar to getLineStartPosition
}
/// Get total line count of a buffer
#[plugin_api(async_promise, js_name = "getBufferLineCount", ts_return = "number | null")]
#[qjs(rename = "_getBufferLineCountStart")]
pub fn get_buffer_line_count_start(&self, buffer_id: u32) -> u64 {
// Implementation
}
Add to fresh-editor/src/app/plugin_commands.rs:
pub(super) fn handle_scroll_to_line_center(
&mut self,
split_id: SplitId,
buffer_id: BufferId,
line: usize,
) {
if let Some(split_state) = self.split_states.get_mut(&split_id) {
if let Some(buffer_state) = self.buffers.get_mut(&buffer_id) {
// Calculate byte position for line start
let line_start = buffer_state.state.buffer.line_start_byte(line);
// Get viewport height
let viewport_height = split_state.viewport.height as usize;
// Calculate offset to center the line
let lines_above = viewport_height / 2;
let target_line = line.saturating_sub(lines_above);
let target_byte = buffer_state.state.buffer.line_start_byte(target_line);
split_state.viewport.scroll_to(&mut buffer_state.state.buffer, target_line);
}
}
}
async function navigateToStep(step: TourStep) {
const exists = await editor.fileExists(step.file_path);
if (!exists) {
// Show broken link indicator in dock
updateTourDock({
...step,
title: `[File Not Found] ${step.title}`,
explanation: `The file "${step.file_path}" could not be found.\n\nPress Space to skip to the next step.`
}, currentStep, totalSteps);
// Add warning icon to gutter
editor.setLineIndicator(dockBufferId, 0, "tour-warning", "⚠", [255, 200, 0], 100);
return;
}
// Proceed with normal navigation
await editor.openFile(step.file_path, step.lines[0], 1);
}
async function renderStepOverlays(step: TourStep) {
const lineCount = await editor.getBufferLineCount(bufferId);
if (lineCount !== null && step.lines[1] > lineCount) {
// Clamp to actual file length
const clampedEnd = lineCount;
// Show warning in dock
editor.setStatus(`Warning: File has ${lineCount} lines, tour expects ${step.lines[1]}`);
// Highlight what we can
const startPos = await editor.getLineStartPosition(step.lines[0] - 1);
const endPos = await editor.getLineEndPosition(clampedEnd - 1);
if (startPos !== null && endPos !== null) {
editor.addOverlay(bufferId, TOUR_NAMESPACE, startPos, endPos, {
bg: [80, 60, 0], // Amber warning color
extendToLineEnd: true,
});
}
return;
}
// Normal rendering
// ...
}
async function loadTour(manifestPath: string) {
const content = await editor.readFile(manifestPath);
const manifest: TourManifest = JSON.parse(content);
if (manifest.commit_hash) {
// Verify current commit matches
const result = await editor.spawnProcess("git", ["rev-parse", "--short", "HEAD"]);
const currentCommit = result.stdout.trim();
if (currentCommit !== manifest.commit_hash) {
const proceed = await editor.prompt(
`Tour was created for commit ${manifest.commit_hash}, but current commit is ${currentCommit}. Continue anyway?`,
"",
[
{ text: "Yes, continue", value: "yes" },
{ text: "No, cancel", value: "no" }
]
);
if (proceed !== "yes") return;
}
}
// Continue loading tour
initializeTour(manifest);
}
Add theme keys for Code Tour in theme schema:
{
"tour": {
"active_line_bg": "#2a4a6a",
"dimmed_line_fg": "#606060",
"dimmed_line_bg": "#1a1a1a",
"dock_header_fg": "#64b5f6",
"dock_hint_fg": "#808080",
"warning_fg": "#ffcc00",
"error_fg": "#ff6666"
}
}
Files to modify:
crates/fresh-core/src/api.rs
ScrollToLineCenter variant to PluginCommandGetLineEndPosition variant to PluginCommandGetBufferLineCount variant to PluginCommandLineEndPosition variant to PluginResponseBufferLineCount variant to PluginResponseextend_to_line_end: bool field to OverlayOptionscrates/fresh-plugin-runtime/src/backend/quickjs_backend.rs
scroll_to_line_center() methodget_line_end_position_start() async methodget_buffer_line_count_start() async methodcrates/fresh-editor/src/app/plugin_commands.rs
handle_scroll_to_line_center() handlerhandle_get_line_end_position() handlerhandle_get_buffer_line_count() handlercrates/fresh-editor/src/app/mod.rs
PluginCommand variants to handlersRegenerate TypeScript definitions
cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignoredFiles to create:
plugins/code-tour/tour-schema.json - JSON Schema for validationplugins/code-tour/index.ts - Plugin entry pointplugins/code-tour/types.ts - TypeScript typesPlugin implementation:
tour:load, tour:next, tour:prev, tour:exit, tour:resumetour-mode keybindingsviewport_changed hook.fresh-tour.json for Fresh plugin systemdocs/plugins/code-tour.mdCode Tour can be implemented as a plugin with the following API additions:
| API | Purpose | Complexity |
|---|---|---|
scrollToLineCenter() | Center viewport on line | Low |
getLineEndPosition() | Get line end byte offset | Low |
getBufferLineCount() | Validate line ranges | Low |
extendToLineEnd overlay option | Full-line highlighting | Very Low |
Total estimated effort: ~2-3 days for API additions, ~3-5 days for plugin implementation.
The existing plugin API already provides ~85% of the required functionality. The gaps identified are straightforward to implement and follow established patterns in the codebase.