Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 97 additions & 8 deletions src/lib/components/layout/CheatSheet.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
Eye,
FileCode,
FolderPlus,
Gamepad2,
Globe,
LifeBuoy,
ListFilter,
Lock,
Package,
Puzzle,
Route,
Scissors,
Workflow,
Expand All @@ -34,6 +37,8 @@
type CheatSheetCommand
} from '$lib/data/cheat-sheet';
import { tokenizeShellCommand } from '$lib/data/bash-syntax';
import { readingContext } from '$lib/ai/reading-context.svelte';
import { exerciseFocusOf, rowUsesWords } from '$lib/playground/exercise-commands';

let { open = false, onToggle }: { open: boolean; onToggle: () => void } = $props();

Expand Down Expand Up @@ -73,12 +78,41 @@
'life-buoy': LifeBuoy
};

/* ── exercise focus ──────────────────────────────────────────────────
When the learner is AT a playground or challenge — the scroll-spy
anchor resolves to one, or the panel opened on one — the sheet can
narrow itself to the commands that exercise actually reaches for.
The lightbulb-free ListFilter toggle in the toolbar turns it off and
on; it only appears while there is an exercise to focus on. */
let focusEnabled = $state(true);

const exercise = $derived(exerciseFocusOf(readingContext.scenarioId ?? readingContext.sectionId));

/** The sheet narrowed to the exercise's commands — null when that would
* leave nothing to show (an exercise whose commands the sheet lacks). */
const focusedCategories = $derived.by(() => {
if (!exercise) return null;
const result: CheatSheetCategory[] = [];
for (const category of cheatSheet) {
const commands = category.commands.filter((cmd) => rowUsesWords(cmd.command, exercise.words));
if (commands.length > 0) result.push({ ...category, commands });
}
return result.length > 0 ? result : null;
});

const focusActive = $derived(focusEnabled && focusedCategories !== null);
const focusAccent = $derived(
exercise?.kind === 'challenge' ? 'var(--color-challenge)' : 'var(--color-important)'
);
const FocusIcon = $derived(exercise?.kind === 'challenge' ? Puzzle : Gamepad2);

let filteredCategories = $derived.by(() => {
const base = focusActive && focusedCategories ? focusedCategories : cheatSheet;
const query = searchQuery.toLowerCase().trim();
if (!query) return cheatSheet;
if (!query) return base;

const result: CheatSheetCategory[] = [];
for (const category of cheatSheet) {
for (const category of base) {
const matchingCommands = category.commands.filter(
(cmd) =>
cmd.command.toLowerCase().includes(query) ||
Expand Down Expand Up @@ -116,10 +150,57 @@
for (const category of filteredCategories) expandedCategories.add(category.label);
}
});

// A focused sheet is short; a collapsed category inside it hides half of
// an already-small kit. Expand what the focus surfaces (never collapse).
$effect(() => {
if (focusActive && focusedCategories) {
for (const category of focusedCategories) expandedCategories.add(category.label);
}
});
</script>

<svelte:window onkeydown={handleKeydown} />

{#snippet focusToggle()}
<!-- Only rendered while an exercise is in view AND the sheet has rows for
it — a filter that could only produce an empty list never appears. -->
{#if exercise && focusedCategories}
<button
onclick={() => (focusEnabled = !focusEnabled)}
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors hover:opacity-70"
style="color: {focusActive
? focusAccent
: 'var(--color-text-muted)'}; background: {focusActive
? `color-mix(in srgb, ${focusAccent} 14%, transparent)`
: 'transparent'};"
aria-pressed={focusActive}
aria-label="Show only this exercise's commands"
title={focusActive
? 'Showing this exercise’s commands — click for all'
: 'Show only this exercise’s commands'}
>
<ListFilter size={14} />
</button>
{/if}
{/snippet}

{#snippet focusStrip()}
<!-- Names what the filter is doing, so a suddenly-short list reads as a
feature rather than as missing content. -->
{#if focusActive && exercise}
<div
class="flex items-center gap-2 px-4 py-1.5"
style="background: color-mix(in srgb, {focusAccent} 7%, transparent);"
>
<FocusIcon size={12} style="color: {focusAccent}; flex-shrink: 0;" />
<p class="min-w-0 truncate text-[11px]" style="color: var(--color-text-secondary);">
Commands for <strong style="font-weight: 600;">{exercise.title}</strong>
</p>
</div>
{/if}
{/snippet}

{#snippet legend()}
<!-- Placeholder key. Sits above the list rather than inside a category:
it explains how to read every row, so it must be seen before them. -->
Expand Down Expand Up @@ -212,7 +293,7 @@

<!-- Right-side sliding panel -->
<aside
class="cheat-panel fixed top-0 right-0 bottom-0 z-40 flex w-full flex-col border-l transition-transform duration-200 ease-out sm:w-84"
class="cheat-panel fixed top-0 right-0 bottom-0 z-40 flex w-full flex-col border-l transition-transform duration-200 ease-out sm:w-[var(--cheatsheet-width)]"
style="padding-top: var(--header-height); border-color: var(--color-border);"
class:translate-x-0={open}
class:translate-x-full={!open}
Expand All @@ -227,9 +308,10 @@
class="text-xs font-semibold tracking-wider uppercase"
style="color: var(--color-text-muted); letter-spacing: 0.08em;"
>
Terminal Cheat Sheet
Cheat Sheet
</span>
<div class="flex items-center gap-0.5">
{@render focusToggle()}
<a
href={pdfHref}
download="terminalvibes-cheatsheet.pdf"
Expand Down Expand Up @@ -275,9 +357,13 @@
/>
</div>

<!-- Scrollable command list -->
{@render focusStrip()}

<!-- Scrollable command list. The legend rests while the sheet is focused
on an exercise: a learner mid-exercise is copying commands, not
decoding notation, and the short list should read at a glance. -->
<div class="flex-1 overflow-y-auto px-2 py-2.5" use:autohideScroll>
{#if filteredCategories.length > 0}
{#if filteredCategories.length > 0 && !focusActive}
{@render legend()}
{/if}
{#each filteredCategories as category (category.label)}
Expand Down Expand Up @@ -349,9 +435,10 @@
class="text-xs font-semibold tracking-wider uppercase"
style="color: var(--color-text-muted); letter-spacing: 0.08em;"
>
Terminal Cheat Sheet
Cheat Sheet
</span>
<div class="flex items-center gap-1">
{@render focusToggle()}
<a
href={pdfHref}
download="terminalvibes-cheatsheet.pdf"
Expand Down Expand Up @@ -388,8 +475,10 @@
/>
</div>

{@render focusStrip()}

<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4" use:autohideScroll>
{#if filteredCategories.length > 0}
{#if filteredCategories.length > 0 && !focusActive}
{@render legend()}
{/if}
<div class="cheat-modal-columns">
Expand Down
61 changes: 61 additions & 0 deletions src/lib/playground/exercise-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { commandWordsOf, exerciseFocusOf, rowUsesWords } from './exercise-commands';

describe('commandWordsOf', () => {
it('takes the first word of a simple command', () => {
expect(commandWordsOf('ls -la')).toEqual(['ls']);
});

it('yields one word per pipeline segment', () => {
expect(commandWordsOf('cat access.log | sort | uniq -c')).toEqual(['cat', 'sort', 'uniq']);
});

it('unwraps sudo, keeping both words', () => {
expect(commandWordsOf('sudo chown neo notes.txt')).toEqual(['sudo', 'chown']);
});

it('skips VAR=value prefixes', () => {
expect(commandWordsOf('PORT=3000 node server.js')).toEqual(['node']);
});

it('does not split on pipes inside quotes', () => {
expect(commandWordsOf("sed 's/a|b/c/' notes.txt")).toEqual(['sed']);
});
});

describe('exerciseFocusOf', () => {
it('resolves a playground anchor to its suggested command words', () => {
const focus = exerciseFocusOf('first-steps');
expect(focus?.kind).toBe('playground');
expect(focus?.title).toBe('Say hello to the machine');
for (const word of ['whoami', 'pwd', 'date', 'echo']) {
expect(focus?.words.has(word)).toBe(true);
}
});

it('resolves a challenge anchor to its pool command words', () => {
const focus = exerciseFocusOf('ch-3-after-the-agent');
expect(focus?.kind).toBe('challenge');
expect(focus?.words.size).toBeGreaterThan(0);
});

it('returns null for ordinary sections and null input', () => {
expect(exerciseFocusOf('section-3-2')).toBeNull();
expect(exerciseFocusOf('hero')).toBeNull();
expect(exerciseFocusOf(null)).toBeNull();
});
});

describe('rowUsesWords', () => {
const words = new Set(['ls', 'grep']);

it('matches a row whose command word is in the set', () => {
expect(rowUsesWords('ls -a', words)).toBe(true);
expect(rowUsesWords('grep -r "<text>" .', words)).toBe(true);
});

it('rejects rows outside the set, including key chords', () => {
expect(rowUsesWords('mkdir <folder>', words)).toBe(false);
expect(rowUsesWords('Ctrl+C', words)).toBe(false);
});
});
76 changes: 76 additions & 0 deletions src/lib/playground/exercise-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Which commands does the exercise under the learner's cursor reach for?
*
* The cheat sheet's focus filter reads this. The scroll-spy anchor of every
* inline activity IS its scenario id (LessonActivity passes the same string
* to both props), and a challenge's anchor IS its challenge id — so one
* lookup against both registries resolves "where the learner is" into an
* exercise. Playgrounds contribute their `suggestedCommands` walkthrough;
* challenges contribute the whole kit, distractors included — the cheat
* sheet explains what each command does, which is exactly the audit a salted
* pool invites, and it never reveals which pool entries are the solution.
*/
import { playgroundScenarios } from './scenarios';
import { allChallenges } from './challenges';
import { commandWordOf, splitSegments } from './challenge-parsing';

export interface ExerciseFocus {
/** Anchor/scenario id ('tidy-up', 'ch-3-after-the-agent'). */
id: string;
title: string;
kind: 'playground' | 'challenge';
/** Command words the exercise's command lines use ('ls', 'grep', …). */
words: ReadonlySet<string>;
}

/**
* Every command word on one command line: one per pipeline segment, with a
* `sudo` prefix contributing both itself and the command it wraps.
*/
export function commandWordsOf(line: string): string[] {
const words: string[] = [];
for (const segment of splitSegments(line)) {
let word = commandWordOf(segment);
if (word === 'sudo') {
words.push('sudo');
word = commandWordOf(segment.replace(/^\s*sudo\s+/, ''));
}
if (word) words.push(word);
}
return words;
}

function toFocus(
id: string,
title: string,
kind: ExerciseFocus['kind'],
lines: readonly string[]
): ExerciseFocus {
const words = new Set<string>();
for (const line of lines) for (const word of commandWordsOf(line)) words.add(word);
return { id, title, kind, words };
}

/** The exercise an anchor id points at, or null for ordinary sections. */
export function exerciseFocusOf(anchorId: string | null): ExerciseFocus | null {
if (!anchorId) return null;
const challenge = allChallenges.find((c) => c.id === anchorId);
if (challenge) {
return toFocus(
challenge.id,
challenge.title,
'challenge',
challenge.pool.map((entry) => entry.command)
);
}
const scenario = playgroundScenarios.find((s) => s.id === anchorId);
if (scenario) {
return toFocus(scenario.id, scenario.title, 'playground', scenario.suggestedCommands);
}
return null;
}

/** Does a cheat-sheet row's command column use any of the exercise's words? */
export function rowUsesWords(rowCommand: string, words: ReadonlySet<string>): boolean {
return commandWordsOf(rowCommand).some((word) => words.has(word));
}
20 changes: 12 additions & 8 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -274,33 +274,36 @@
}

// The three header panels are mutually exclusive: opening one closes
// the others. Agent/Playground additionally enter desktop "reading
// mode": the sidebar auto-collapses and the content reflows beside the
// panel — the sidebar's prior state is restored when both close.
// the others. Each enters desktop "reading mode": the sidebar
// auto-collapses and the content reflows beside the panel — the
// sidebar's prior state is restored when all of them are closed.
let sidebarBeforePanel = false;

/** Call BEFORE mutating any open flags when a side panel is opening. */
function enterReadingMode() {
if (!playgroundOpen && !agentOpen) {
if (!playgroundOpen && !agentOpen && !cheatSheetOpen) {
sidebarBeforePanel = sidebarOpen;
sidebarOpen = false;
}
}

/** Call AFTER mutating flags — restores the sidebar once both are closed. */
/** Call AFTER mutating flags — restores the sidebar once all are closed. */
function maybeLeaveReadingMode() {
if (!playgroundOpen && !agentOpen) {
if (!playgroundOpen && !agentOpen && !cheatSheetOpen) {
sidebarOpen = sidebarBeforePanel;
}
}

function toggleCheatSheet() {
if (!cheatSheetOpen) {
enterReadingMode();
playgroundOpen = false;
agentOpen = false;
cheatSheetOpen = true;
} else {
cheatSheetOpen = false;
maybeLeaveReadingMode();
}
cheatSheetOpen = !cheatSheetOpen;
}

function togglePlayground() {
Expand Down Expand Up @@ -431,7 +434,8 @@
<main
id="main-content"
class="main-content transition-[margin] duration-200 ease-out"
class:reading-mode={playgroundOpen || agentOpen}
class:reading-mode={playgroundOpen || agentOpen || cheatSheetOpen}
class:cheat-mode={cheatSheetOpen}
style="padding-top: var(--header-height); margin-left: {sidebarOpen
? 'var(--sidebar-width)'
: 'var(--sidebar-collapsed-width)'};"
Expand Down
Loading