diff --git a/README.md b/README.md index ad5214c..a24d18f 100644 --- a/README.md +++ b/README.md @@ -21,25 +21,72 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil ## Using it -### Keyboard shortcuts +### Shortcuts and tips + + + +#### Keyboard shortcuts + +**Layout** | Key | Action | |---|---| -| `Ctrl+K` | open the jump-to-pane palette (fuzzy match over label / distro / cwd; `↑`/`↓` to move, `Enter` to focus, `Esc` to close) | -| `Ctrl+Shift+E` | split active pane to the right | -| `Ctrl+Shift+O` | split active pane downward | -| `Ctrl+Shift+W` | close active pane | -| `Ctrl+Shift+P` | promote active pane out one level — turns a nested pane into a full row/column (e.g. nested-right `c` becomes a full-width bottom row). Self-inverse. | -| `Ctrl+Shift+B` | toggle broadcast on active pane | -| `Ctrl+Shift+Alt+B` | toggle broadcast on ALL panes (same as the titlebar 📡 button) | -| `Ctrl+Shift+←` / `→` / `↑` / `↓` | focus neighbour pane in that direction | -| `Ctrl+=` / `Ctrl+-` / `Ctrl+0` | zoom the active pane in / out / back to default | -| `Ctrl+Shift+=` / `Ctrl+Shift+-` / `Ctrl+Shift+0` | same, applied to **every** pane (shift = "to all") | +| `Ctrl+Shift+E` | Split active pane to the right | +| `Ctrl+Shift+O` | Split active pane downward | +| `Ctrl+Shift+W` | Close active pane | +| `Ctrl+Shift+P` | Promote active pane out one level (turns a nested pane into a full row/column; self-inverse) | + +**Navigation** + +| Key | Action | +|---|---| +| `Ctrl+K` | Open jump-to-pane palette | +| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction | + +**Broadcast** + +| Key | Action | +|---|---| +| `Ctrl+Shift+B` | Toggle broadcast on active pane | +| `Ctrl+Shift+Alt+B` | Toggle broadcast on ALL panes (same as titlebar 📡) | + +**Font size** + +| Key | Action | +|---|---| +| `Ctrl+= / Ctrl+- / Ctrl+0` | Zoom active pane in / out / reset | +| `Ctrl+Shift+= / Ctrl+Shift+- / Ctrl+Shift+0` | Same, applied to every pane | + +**Terminal** + +| Key | Action | +|---|---| +| `Ctrl+Shift+C / Ctrl+Shift+V` | Copy selection / paste in terminal | + +**Help** + +| Key | Action | +|---|---| +| `F1` | Show this help overlay | + +#### Tips + +- **Per-pane shell picker** — Click the distro chip in any pane's toolbar to switch between WSL distros, PowerShell, or a saved SSH host. The pane respawns with the new shell. +- **SSH host manager** — Titlebar 🔑 SSH hosts opens the manager. Add hostname / user / port / identity file / jump host / extra ssh args. Saved hosts appear in every pane's dropdown. +- **Saved passwords** — Optionally save a host's password — stored in Windows Credential Manager (DPAPI-encrypted), never written to hosts.json. When ssh prompts on connect it's typed automatically. Hosts with a saved password show 🔒 in the list. +- **Clickable links** — http and https URLs in terminal output get underlined and open in your default browser on click. +- **Drag pane headers to swap** — Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard. +- **Workspace persistence** — Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch. +- **MCP server (let Claude drive the workspace)** — Titlebar 🤖 opens the MCP control panel — start the server and paste the snippet into your Claude Code .mcp.json. The snippet uses npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead; the shim proxies the HTTP endpoint with the bearer baked in. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP. Read-only in v1 (no spawn or write yet). + + Shortcuts work while a terminal is focused — we capture the key before xterm.js sees it. They don't fire while you're typing into a label edit or the palette input, so those still work normally. `Ctrl` and `⌘` (Cmd) are interchangeable. Font size persists per pane in `workspace.json`, so a zoomed pane stays zoomed across restarts. +> The shortcut tables and tips above are generated from `src/lib/shortcuts.ts` (the single source of truth shared with the in-app help overlay). To change them, edit that file and run `pnpm gen:readme`. + ### Mouse + toolbar - **Split panes** — `⇥` in the pane toolbar splits right, `⇣` splits down. The new pane inherits the parent's distro; the cwd defaults to `~` in the WSL distro. diff --git a/memory.md b/memory.md index 38e831e..38c65eb 100644 --- a/memory.md +++ b/memory.md @@ -90,6 +90,21 @@ Open follow-ups specific to this session: - **Probe latency-as-jitter.** First idle tick after 5s silence triggers a 100-300ms `wsl.exe` shell-out. The user sees the red border flicker on for ~one tick before the probe resolves and clears it. Not visually obvious in practice (the red is already a transient signal), but could pre-warm the cache on a slower interval if it bites. - **PowerShell idle filter.** PS has no `ps` equivalent we can probe cheaply; closest is `Get-Process` + a watched-list mapping (`claude` doesn't exist on Windows, but `cargo`, `npm`, `python` do). Defer until someone actually runs a long-running CLI in PS and complains. - **Workspace-edit migration of the `LeafPane.svelte` mention** in the open-question section about the 5000ms threshold — file says `.svelte` but we're React now. Drive-by, not done here ("don't refactor unrelated code"). +### 2026-05-26 — README shortcut table now generated from `shortcuts.ts` + +The keyboard-shortcut table in README and the in-app help overlay used to be hand-mirrored copies maintained by "keep in sync" comments. They drifted (most recently the navigation/font-size entries diverged). Now `src/lib/shortcuts.ts` is the single source of truth and README's section is generated from it. + +**Marker shape:** plain HTML comments — `` and ``. Markdown viewers render them as nothing (zero visual noise); the generator finds them by literal string match. They live under the new `### Shortcuts and tips` heading in `Using it`, with explanatory prose + a footer pointer below for readers who reach for the file. + +**Script:** `scripts/gen-readme-shortcuts.mjs`. Sibling to `pr4-verify.mjs` / `release.sh` / `make-icon.py`. Plain Node + `fs` only — no tsx/esbuild dep. Trick: shortcuts.ts is pure data (no React, no value imports), so the script reads it as text, strips `export interface { ... }` blocks with a brace-walker, drops the `: SomeType[]` annotations on the `export const` declarations, writes the result to a temp `.mjs` file in `os.tmpdir()`, and dynamic-imports it. Cleaner than a regex parser of the array literal because any future shape change in shortcuts.ts (adding a new field, reshuffling sections) Just Works. + +**Render style:** mirrors the existing README table — `| Key | Action |` two-column, keys backticked. The TS data is grouped by section, so each section gets a `**Title**` subheading + its own table. TIPS render as a `**Title** — body` bulleted list. Pipes in cell text are escaped to `\|`; newlines collapse to spaces. + +**Pnpm script:** `pnpm gen:readme`. Also supports `--check` mode (`node scripts/gen-readme-shortcuts.mjs --check`) which exits 1 if the README would change — wire it into CI later if drift starts mattering again. + +**To add or change a shortcut/tip:** edit `src/lib/shortcuts.ts`, run `pnpm gen:readme`. The help overlay updates automatically (it already imports from there); the README marker block updates from the same source. Don't hand-edit anything between the marker comments — your changes will be wiped on the next regen. + +**Verified:** ran twice, second run reports "already up to date" with empty `git diff`. `pnpm check` clean (tsc --noEmit, exit 0). ### 2026-05-26 — Hard-deny: PowerShell patterns + label list de-duplicated diff --git a/package.json b/package.json index d86eeb1..5b72388 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "vitest run", "test:watch": "vitest", "check": "tsc --noEmit", + "gen:readme": "node scripts/gen-readme-shortcuts.mjs", "tauri": "tauri" }, "dependencies": { diff --git a/scripts/gen-readme-shortcuts.mjs b/scripts/gen-readme-shortcuts.mjs new file mode 100644 index 0000000..d353e0c --- /dev/null +++ b/scripts/gen-readme-shortcuts.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +// gen-readme-shortcuts.mjs — regenerate README.md's shortcut + tips section +// from src/lib/shortcuts.ts (the single source of truth used by the in-app +// help overlay). +// +// Usage: +// node scripts/gen-readme-shortcuts.mjs # rewrite README +// node scripts/gen-readme-shortcuts.mjs --check # exit 1 if README would change +// +// To extend: add or edit entries in src/lib/shortcuts.ts, then run this +// script. The README marker block ... +// is replaced atomically; the rest of the README is left alone. + +import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, ".."); +const SHORTCUTS_TS = join(REPO_ROOT, "src", "lib", "shortcuts.ts"); +const README_PATH = join(REPO_ROOT, "README.md"); + +const START_MARKER = ""; +const END_MARKER = ""; + +const CHECK_MODE = process.argv.includes("--check"); + +// ---------------------------------------------------------------------------- +// Load shortcuts.ts as data. The file is pure data exports — no React, no +// runtime imports — so we can strip TypeScript-only syntax with regex, drop +// the result into a temp .mjs file, and dynamically import it. Cheaper than +// pulling in tsx/esbuild as a devDep just for this one script. +// ---------------------------------------------------------------------------- +async function loadShortcutsModule() { + const src = await readFile(SHORTCUTS_TS, "utf8"); + // Strip `export interface { ... }` blocks (handles nested braces by + // walking; the file has flat interfaces today so a brace-counter is enough). + const stripped = stripInterfaceDecls(src) + // Drop `: TypeAnnotation` on the export declarations + // (e.g. `export const SHORTCUT_SECTIONS: ShortcutSection[] = [...]`). + .replace(/^(export\s+const\s+\w+)\s*:\s*[^=]+?=/gm, "$1 ="); + + const dir = await mkdtemp(join(tmpdir(), "tiletopia-genreadme-")); + const tmpFile = join(dir, "shortcuts.mjs"); + try { + await writeFile(tmpFile, stripped, "utf8"); + return await import(pathToFileURL(tmpFile).href); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function stripInterfaceDecls(src) { + let out = ""; + let i = 0; + while (i < src.length) { + const match = src.slice(i).match(/^export\s+interface\s+\w+\s*\{/m); + if (!match) { + out += src.slice(i); + break; + } + const localStart = src.indexOf(match[0], i); + out += src.slice(i, localStart); + // Walk braces to find the end of the interface block. + let depth = 0; + let j = localStart + match[0].length - 1; // points at the opening `{` + for (; j < src.length; j++) { + const c = src[j]; + if (c === "{") depth++; + else if (c === "}") { + depth--; + if (depth === 0) { + j++; + break; + } + } + } + i = j; + } + return out; +} + +// ---------------------------------------------------------------------------- +// Render the markdown block. Mirrors the README's existing table style: +// - 2-column `| Key | Action |` table +// - keys wrapped in backticks +// - description in plain prose +// Tips render as a `#### Title` heading plus a paragraph. +// ---------------------------------------------------------------------------- +function renderBlock({ SHORTCUT_SECTIONS, TIPS }) { + const lines = []; + lines.push(""); + lines.push("#### Keyboard shortcuts"); + lines.push(""); + for (const section of SHORTCUT_SECTIONS) { + lines.push(`**${section.title}**`); + lines.push(""); + lines.push("| Key | Action |"); + lines.push("|---|---|"); + for (const item of section.items) { + lines.push(`| \`${escapeCell(item.keys)}\` | ${escapeCell(item.description)} |`); + } + lines.push(""); + } + lines.push("#### Tips"); + lines.push(""); + for (const tip of TIPS) { + lines.push(`- **${escapeInline(tip.title)}** — ${escapeInline(tip.body)}`); + } + lines.push(""); + return lines.join("\n"); +} + +// Cell values must not contain raw pipes (would break the table) or newlines. +function escapeCell(s) { + return s.replace(/\|/g, "\\|").replace(/\n/g, " "); +} + +// Body text gets newlines collapsed but pipes kept (lists, not tables). +function escapeInline(s) { + return s.replace(/\n/g, " "); +} + +// ---------------------------------------------------------------------------- +// Splice the generated block into the README between the markers. +// ---------------------------------------------------------------------------- +function spliceReadme(readme, block) { + const startIdx = readme.indexOf(START_MARKER); + const endIdx = readme.indexOf(END_MARKER); + if (startIdx === -1 || endIdx === -1) { + throw new Error( + `README.md is missing one of the markers (${START_MARKER} / ${END_MARKER}). ` + + "Add them around the section you want regenerated.", + ); + } + if (endIdx < startIdx) { + throw new Error(`${END_MARKER} appears before ${START_MARKER} in README.md`); + } + const before = readme.slice(0, startIdx + START_MARKER.length); + const after = readme.slice(endIdx); + return `${before}\n${block}\n${after}`; +} + +// ---------------------------------------------------------------------------- +// Main. +// ---------------------------------------------------------------------------- +const mod = await loadShortcutsModule(); +const block = renderBlock(mod); +const readme = await readFile(README_PATH, "utf8"); +const next = spliceReadme(readme, block); + +if (CHECK_MODE) { + if (next !== readme) { + process.stderr.write( + "README.md is out of sync with src/lib/shortcuts.ts. " + + "Run `pnpm gen:readme` to regenerate.\n", + ); + process.exit(1); + } + process.stdout.write("README.md is in sync with src/lib/shortcuts.ts.\n"); + process.exit(0); +} + +if (next === readme) { + process.stdout.write("README.md already up to date.\n"); +} else { + await writeFile(README_PATH, next, "utf8"); + process.stdout.write("README.md regenerated.\n"); +}