Back to Fresh

Code Tour Feature Design

docs/internal/code-tour-design.md

0.3.225.9 KB
Original Source

Code Tour Feature Design

Status: Implementation Phase Author: Claude Date: 2026-02-02 Branch: claude/implement-code-tour-zIYjM

1. Executive Summary

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.

2. UX Philosophy (NN/g Heuristics)

HeuristicImplementation
VisibilityPersistent status indicator "Tour Mode: Step X/Y" in status bar
User ControlUser can scroll away (pause) and resume with Tab key
Recognition over RecallAll commands in Command Palette, no obscure :commands
Focus (Figure/Ground)Active lines highlighted, context lines dimmed

3. Data Structure: Tour Manifest Schema

3.1 TypeScript Interface

typescript
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[];
}

3.2 JSON Schema (Draft-07)

The schema file should be placed at plugins/code-tour/tour-schema.json:

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"
        }
      }
    }
  }
}

3.3 Example .fresh-tour.json

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."
    }
  ]
}

4. Plugin API Analysis

4.1 Existing APIs That Support Code Tour

CapabilityPlugin APICode Tour Use
Line highlightingaddOverlay(bufferId, namespace, start, end, options)Highlight active tour step lines
Virtual textaddVirtualText(), addVirtualLine()Step annotations/explanations
File navigationopenFile(path, line, column)Navigate to step file/position
Command paletteregisterCommand(name, desc, handler, context)Tour: Next, Previous, Exit
Scroll detectionViewportChanged hookDetect user scroll (detour detection)
Cursor positioningsetBufferCursor(bufferId, position)Position cursor at step
Scroll controlsetSplitScroll(splitId, topByte)Scroll to step location
Line byte offsetgetLineStartPosition(line) asyncConvert line numbers to byte offsets
File readingreadFile(path)Load .fresh-tour.json manifest
File existencefileExists(path)Check if step files exist
Status messagessetStatus(message)Show "Tour Mode" indicator
Custom keybindingsdefineMode(name, parent, bindings)Tour navigation keys
Promptsprompt(label, initial, suggestions)File picker for loading tours
Viewport infogetViewport()Get current viewport dimensions
Buffer infogetBufferInfo(bufferId)Get buffer path and length

4.2 API Gaps Identified

The following capabilities are missing and need to be added:

Gap 1: 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:

  1. Calculates the byte offset for the target line
  2. Computes viewport height
  3. Scrolls so the line appears in the center
typescript
// Proposed API
editor.scrollToLineCenter(splitId: number, bufferId: number, line: number): boolean

Gap 2: 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():

typescript
// Proposed API
editor.getLineEndPosition(bufferId: number, line: number): Promise<number | null>

Gap 3: 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:

typescript
// Proposed API
editor.getBufferLineCount(bufferId: number): number | null

Gap 4: Full-Line Background Overlay with "Extend to Line End"

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():

typescript
// Proposed API addition to OverlayOptions
interface OverlayOptions {
  // ... existing fields ...
  extendToLineEnd?: boolean;  // NEW: Extend background to visual line end
}

4.3 Features NOT Needed

FeatureReason Not Needed
Code Folding APIFresh doesn't have folding implemented
Persistent Dock PanelVirtual buffer with panel_id achieves similar result

5. TourManager State Machine Design

                    ┌──────────────────────────────────────────────┐
                    │                                              │
                    ▼                                              │
              ┌─────────┐     load_tour()      ┌─────────────┐    │
              │  IDLE   │ ──────────────────▶  │   ACTIVE    │    │
              └─────────┘                      └─────────────┘    │
                    ▲                                │             │
                    │                                │             │
                    │          exit_tour()           │             │
                    └────────────────────────────────┘             │
                                                     │             │
                              user_scrolls_away()    │             │
                                     │               │             │
                                     ▼               │             │
                              ┌─────────────┐        │             │
                              │   PAUSED    │        │             │
                              │ (Detached)  │        │             │
                              └─────────────┘        │             │
                                     │               │             │
                         resume_location()           │             │
                                     │               │             │
                                     └───────────────┘             │
                                                                   │
                               next_step() / prev_step()           │
                                     │                             │
                                     └─────────────────────────────┘

5.1 State Definitions

typescript
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;
}

5.2 State Transitions

Current StateEventNext StateActions
IDLEloadTour(manifest)ACTIVE(step=0)Parse JSON, create dock, go to step 0
ACTIVEnextStep()ACTIVE(step+1)Clear overlays, navigate, highlight
ACTIVEprevStep()ACTIVE(step-1)Clear overlays, navigate, highlight
ACTIVEviewport_changed (user scroll)ACTIVE(paused=true)Dim dock, show "Paused"
ACTIVE(paused)resumeLocation()ACTIVE(paused=false)Scroll back to step location
ACTIVEexitTour()IDLEClear overlays, close dock
ACTIVEfile missingACTIVEShow "Broken Link" in dock, allow skip

6. Implementation Architecture

6.1 Plugin File Structure

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

6.2 Command Palette Integration

typescript
// 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"
);

6.3 Keybinding Mode

typescript
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
);

6.4 Overlay Rendering Strategy

typescript
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);
  }
}

6.5 Tour Dock Implementation

The Tour Dock is a virtual buffer that displays:

  • Current step title and progress ("Step 2 of 5")
  • Explanation text (with markdown rendering if supported)
  • Navigation hints
typescript
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);
  }
}

6.6 Detour Detection

typescript
// 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();
  }
}

7. Required Plugin API Changes

7.1 New PluginCommand Variants

Add to fresh-core/src/api.rs:

rust
/// 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,
},

7.2 New PluginResponse Variants

rust
/// Response to GetLineEndPosition
LineEndPosition {
    request_id: u64,
    position: Option<usize>,
},

/// Response to GetBufferLineCount
BufferLineCount {
    request_id: u64,
    count: Option<usize>,
},

7.3 OverlayOptions Enhancement

Expose extend_to_line_end in the plugin API:

rust
// 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,
}

7.4 Implementation in Plugin Runtime

Add to fresh-plugin-runtime/src/backend/quickjs_backend.rs:

rust
/// 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
}

7.5 Handler in Editor

Add to fresh-editor/src/app/plugin_commands.rs:

rust
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);
        }
    }
}

8. Edge Case Handling

8.1 Missing File

typescript
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);
}

8.2 Version Mismatch (File Shorter Than Expected)

typescript
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
  // ...
}

8.3 Commit Hash Verification

typescript
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);
}

9. Theme Integration

Add theme keys for Code Tour in theme schema:

json
{
  "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"
  }
}

10. Implementation Phases

Phase 1: API Additions

Files to modify:

  1. crates/fresh-core/src/api.rs

    • Add ScrollToLineCenter variant to PluginCommand
    • Add GetLineEndPosition variant to PluginCommand
    • Add GetBufferLineCount variant to PluginCommand
    • Add LineEndPosition variant to PluginResponse
    • Add BufferLineCount variant to PluginResponse
    • Add extend_to_line_end: bool field to OverlayOptions
  2. crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs

    • Add scroll_to_line_center() method
    • Add get_line_end_position_start() async method
    • Add get_buffer_line_count_start() async method
  3. crates/fresh-editor/src/app/plugin_commands.rs

    • Add handle_scroll_to_line_center() handler
    • Add handle_get_line_end_position() handler
    • Add handle_get_buffer_line_count() handler
  4. crates/fresh-editor/src/app/mod.rs

    • Dispatch new PluginCommand variants to handlers
  5. Regenerate TypeScript definitions

    • Run cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignored

Phase 2: Core Plugin Implementation

Files to create:

  1. plugins/code-tour/tour-schema.json - JSON Schema for validation
  2. plugins/code-tour/index.ts - Plugin entry point
  3. plugins/code-tour/types.ts - TypeScript types

Plugin implementation:

  • Create plugin directory structure
  • Implement JSON schema file
  • Implement tour manifest loading and validation
  • Register commands: tour:load, tour:next, tour:prev, tour:exit, tour:resume
  • Define tour-mode keybindings
  • Implement step navigation (open file, scroll to line, highlight)
  • Implement Tour Dock (virtual buffer with step info)
  • Implement overlay rendering for active lines

Phase 3: Visual Polish & Edge Cases

  • Implement focus mode (dim non-active lines)
  • Implement detour detection via viewport_changed hook
  • Implement resume functionality
  • Handle missing files gracefully
  • Handle line count mismatch (clamp highlights)
  • Implement commit hash verification (optional)

Phase 4: Testing & Documentation

  • Create sample .fresh-tour.json for Fresh plugin system
  • Add user documentation to docs/plugins/code-tour.md
  • Test on various codebases

11. Summary

Code Tour can be implemented as a plugin with the following API additions:

APIPurposeComplexity
scrollToLineCenter()Center viewport on lineLow
getLineEndPosition()Get line end byte offsetLow
getBufferLineCount()Validate line rangesLow
extendToLineEnd overlay optionFull-line highlightingVery 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.