From 5bdc44339c2109517ed842b78d31547228e3f7f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:41:57 +0000 Subject: [PATCH 1/2] style: format with bun fmt One-time pass with bun fmt, configured in bunfig.toml to match the existing style (no semicolons, single quotes, printWidth 120). Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude-plugin/plugin.json | 8 +- .github/workflows/test.yml | 6 +- .prettierignore | 3 + README.md | 70 ++++----- bunfig.toml | 10 ++ hooks/codenames.ts | 288 ++++++++++++++++++++++++++++++++---- hooks/config.ts | 17 ++- hooks/log.ts | 42 ++++-- hooks/placeholders.ts | 2 +- hooks/redact.ts | 40 ++--- hooks/register.ts | 109 ++++++++++---- hooks/sidebar.tsx | 173 +++++++++++++++++----- tests/filter.test.ts | 38 +++-- tests/log.test.ts | 12 +- tests/register.test.ts | 290 ++++++++++++++++++++++++++++--------- 15 files changed, 846 insertions(+), 262 deletions(-) create mode 100644 .prettierignore create mode 100644 bunfig.toml diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 15675f7..8c13f9d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -12,13 +12,7 @@ "supportUrl": "https://github.com/lperezmo/topic-filter-mod/issues", "privacyPolicyUrl": "https://github.com/lperezmo/topic-filter-mod/blob/main/PRIVACY.md", "license": "GPL-3.0-only", - "keywords": [ - "mod", - "function-hooks", - "focus", - "placeholders", - "productivity" - ], + "keywords": ["mod", "function-hooks", "focus", "placeholders", "productivity"], "userConfig": { "hideAnthropology": { "type": "boolean", diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce2cb72..ab6830d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: # Weekly, so a new Claude Code release that breaks the mod shows up on its own. schedule: - - cron: "0 14 * * 1" + - cron: '0 14 * * 1' permissions: contents: read @@ -21,12 +21,12 @@ jobs: matrix: os: [ubuntu-latest] # 2.1.287 is the first release where mods load by default. - claude: ["2.1.287", "latest"] + claude: ['2.1.287', 'latest'] include: - os: windows-latest claude: latest env: - DISABLE_AUTOUPDATER: "1" + DISABLE_AUTOUPDATER: '1' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9460a9e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Built by the Python pack builder in tools/packs, which has its own ruff checks. +packs/ +tools/packs/ diff --git a/README.md b/README.md index 4fc8930..7a96bfa 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ disappear. Your repos, notes and memory stay as they are. ![A Claude Code session summarizing a demo project: its reply uses codenames in place of the hidden terms, while the sidebar lists each hidden term, its codename, and where the hits came from](images/live_filter.png) -*Claude answers in codenames; the sidebar, shown to you only, maps each one back +_Claude answers in codenames; the sidebar, shown to you only, maps each one back and counts where it was hidden. The dim `topic-filter: 50 hidden` sits in the -prompt footer.* +prompt footer._ ## Install @@ -46,15 +46,15 @@ plugin's rows. Enter or Space flips a switch or edits a field: ![The /config screen listing topic-filter's rows: one switch per built-in pack, other packs, the sidebar switches and the topics file](images/hide_config.png) -| Setting | Default | What it does | -| --- | --- | --- | -| Hide anthropology, biology, chemistry, genetics | off | One switch per [built-in pack](#topic-packs) | -| Other packs | empty | Names of [your own packs](#topic-packs), comma-separated | -| Start paused | off | Every session starts [paused](#pausing-it); `/topic-filter on` turns it on for that session | -| Sidebar | off | Opens [the sidebar](#the-sidebar) at every start | -| Sidebar: counts only | off | The sidebar shows counts by list, never a word or file name | -| Extra words to hide | empty | Comma-separated words or names; kept in secure storage, not in `settings.json` | -| Topics file (advanced) | empty | Where [the topics file](#the-topics-file) lives, if you want one | +| Setting | Default | What it does | +| ----------------------------------------------- | ------- | ------------------------------------------------------------------------------------------- | +| Hide anthropology, biology, chemistry, genetics | off | One switch per [built-in pack](#topic-packs) | +| Other packs | empty | Names of [your own packs](#topic-packs), comma-separated | +| Start paused | off | Every session starts [paused](#pausing-it); `/topic-filter on` turns it on for that session | +| Sidebar | off | Opens [the sidebar](#the-sidebar) at every start | +| Sidebar: counts only | off | The sidebar shows counts by list, never a word or file name | +| Extra words to hide | empty | Comma-separated words or names; kept in secure storage, not in `settings.json` | +| Topics file (advanced) | empty | Where [the topics file](#the-topics-file) lives, if you want one | **Extra words to hide** is kept secret, so `/config` does not list it: set it in `/plugin`: Installed, then `topic-filter`, then configure it. That screen @@ -254,17 +254,17 @@ What Claude read before the pause keeps its placeholders. } ``` -| Field | Default | Meaning | -| --- | --- | --- | -| `placeholder` | `codename` | `codename` gives each term a capitalized word and a number (`Bubblegum7`, `Kazoo12`); the number keeps it from ever matching a word you really write. `tag` gives `[hidden-3fa2c1]`. | -| `informModel` | `true` | Tell the model that placeholders exist and how to treat them. Without it the model tends to treat `Bubblegum` as a real name and go looking for it. | -| `lists[].name` | `list N` | A label for you. It never reaches the model. | -| `lists[].terms` | `[]` | The terms to hide. | -| `lists[].mode` | `replace` | `replace` swaps each term for its placeholder. `drop-line` removes every line that mentions a term; in JSON (`gh ... --json`, MCP results) the whole array item goes. A typed prompt always gets placeholders, never lost lines. | -| `lists[].match` | `word` | `word` matches whole words only. `substring` matches inside words too (`maya` in `Mayapan`). | -| `lists[].restore` | `false` | When the model uses this list's placeholder in a tool call, write the real term back instead of refusing. For your own names (contacts, client names) that a tool must write out exactly. Not allowed on a list that uses a `pack`. | -| `lists[].pack` | none | A [topic pack](#topic-packs) whose terms join the list. | -| `lists[].exclude` | `[]` | Terms to leave out of the list, whatever brought them in (a pack or `terms`). Matched the same forgiving way as terms. | +| Field | Default | Meaning | +| ----------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `placeholder` | `codename` | `codename` gives each term a capitalized word and a number (`Bubblegum7`, `Kazoo12`); the number keeps it from ever matching a word you really write. `tag` gives `[hidden-3fa2c1]`. | +| `informModel` | `true` | Tell the model that placeholders exist and how to treat them. Without it the model tends to treat `Bubblegum` as a real name and go looking for it. | +| `lists[].name` | `list N` | A label for you. It never reaches the model. | +| `lists[].terms` | `[]` | The terms to hide. | +| `lists[].mode` | `replace` | `replace` swaps each term for its placeholder. `drop-line` removes every line that mentions a term; in JSON (`gh ... --json`, MCP results) the whole array item goes. A typed prompt always gets placeholders, never lost lines. | +| `lists[].match` | `word` | `word` matches whole words only. `substring` matches inside words too (`maya` in `Mayapan`). | +| `lists[].restore` | `false` | When the model uses this list's placeholder in a tool call, write the real term back instead of refusing. For your own names (contacts, client names) that a tool must write out exactly. Not allowed on a list that uses a `pack`. | +| `lists[].pack` | none | A [topic pack](#topic-packs) whose terms join the list. | +| `lists[].exclude` | `[]` | Terms to leave out of the list, whatever brought them in (a pack or `terms`). Matched the same forgiving way as terms. | Matching ignores case and accents (`Teotihuacán` = `teotihuacan`), treats spaces, hyphens and underscores as one separator (`secret repo` also finds @@ -299,12 +299,12 @@ The session is told only that the settings need attention. **Built-in packs** ship in this repository's [`packs/`](packs) folder: -| Pack | Terms | Covers | -| --- | --- | --- | +| Pack | Terms | Covers | +| -------------- | ----- | --------------------------------------------------------------------------------------------------------------------------- | | `anthropology` | 2,167 | Ancient Mesoamerican, Andean and Egyptian sites, civilizations, rulers and deities; anthropology and archaeology vocabulary | -| `biology` | 270 | Molecular biology techniques, cellular processes, anatomical terms | -| `chemistry` | 940 | Chemical elements, named reactions, functional groups, laboratory equipment | -| `genetics` | 2,591 | Genetic disorders and syndromes; genetics and heredity vocabulary | +| `biology` | 270 | Molecular biology techniques, cellular processes, anatomical terms | +| `chemistry` | 940 | Chemical elements, named reactions, functional groups, laboratory equipment | +| `genetics` | 2,591 | Genetic disorders and syndromes; genetics and heredity vocabulary | They are built by the script in [`tools/packs/`](tools/packs) from Wikipedia and Wiktionary categories, word-frequency data and a dry run against ordinary @@ -349,14 +349,14 @@ Text reaches the model through many doors, and there is no single outgoing request to filter (`turn.step` carries a message count, not the messages). So every door but the system prompt is hooked: -| What the model reads | Event | -| --- | --- | -| Tool results: Bash, Read, Grep, Glob, WebFetch, MCP tools, subagent answers, errors | `tool.call`, on the way up | -| Your typed prompt | `prompt.submit` | -| CLAUDE.md and the other first-message context blocks | `prompt.context` | -| Mentioned and edited files, nested CLAUDE.md, queued prompts, context added by classic hooks and plugins (Claude Code's own reminders pass unchanged) | `prompt.attachment` | -| Skill text, MCP tool descriptions (built-in tools keep theirs), slash command output | `skill.prompt`, `tool.describe`, `command.run` | -| Remote Control and peer deliveries | `session.receive` | +| What the model reads | Event | +| ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| Tool results: Bash, Read, Grep, Glob, WebFetch, MCP tools, subagent answers, errors | `tool.call`, on the way up | +| Your typed prompt | `prompt.submit` | +| CLAUDE.md and the other first-message context blocks | `prompt.context` | +| Mentioned and edited files, nested CLAUDE.md, queued prompts, context added by classic hooks and plugins (Claude Code's own reminders pass unchanged) | `prompt.attachment` | +| Skill text, MCP tool descriptions (built-in tools keep theirs), slash command output | `skill.prompt`, `tool.describe`, `command.run` | +| Remote Control and peer deliveries | `session.receive` | Each of these hooks changes one thing: it replaces listed terms in the text with their placeholders (or drops the line, for a `drop-line` list) and diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..4feb931 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,10 @@ +[fmt] +# Matches the style the code already had: no semicolons, single quotes, long lines. +printWidth = 120 +semi = false +singleQuote = true + +[lint] + +[lint.rules] +"eqeqeq" = ["error", "smart"] diff --git a/hooks/codenames.ts b/hooks/codenames.ts index 34e77de..ad35eaf 100644 --- a/hooks/codenames.ts +++ b/hooks/codenames.ts @@ -6,36 +6,260 @@ // finds codenames by that shape. export const CODENAMES: readonly string[] = [ - 'Bubblegum', 'Marmalade', 'Pinwheel', 'Kazoo', 'Snorkel', 'Gumdrop', 'Waffle', 'Pretzel', - 'Teacup', 'Walrus', 'Platypus', 'Porcupine', 'Accordion', 'Trombone', 'Tambourine', 'Lollipop', - 'Marshmallow', 'Doughnut', 'Periwinkle', 'Buttercup', 'Dandelion', 'Huckleberry', 'Gooseberry', 'Kumquat', - 'Persimmon', 'Rhubarb', 'Parsnip', 'Rutabaga', 'Artichoke', 'Butterscotch', 'Nougat', 'Toffee', - 'Meringue', 'Crumpet', 'Scone', 'Strudel', 'Popover', 'Macaroon', 'Biscotti', 'Cannoli', - 'Churro', 'Dumpling', 'Pancake', 'Flapjack', 'Muffin', 'Custard', 'Sherbet', 'Sorbet', - 'Gelato', 'Tapioca', 'Licorice', 'Fudge', 'Truffle', 'Praline', 'Brioche', 'Baguette', - 'Croissant', 'Bagel', 'Pierogi', 'Tamale', 'Empanada', 'Gnocchi', 'Ravioli', 'Tortellini', - 'Anchovy', 'Barnacle', 'Pelican', 'Flamingo', 'Penguin', 'Puffin', 'Toucan', 'Cockatoo', - 'Parakeet', 'Hummingbird', 'Kingfisher', 'Sandpiper', 'Heron', 'Albatross', 'Cormorant', 'Starling', - 'Wombat', 'Koala', 'Kangaroo', 'Wallaby', 'Armadillo', 'Aardvark', 'Anteater', 'Hedgehog', - 'Chipmunk', 'Marmot', 'Beaver', 'Otter', 'Badger', 'Ferret', 'Weasel', 'Mongoose', - 'Meerkat', 'Lemur', 'Sloth', 'Tapir', 'Okapi', 'Narwhal', 'Manatee', 'Dugong', - 'Octopus', 'Squid', 'Cuttlefish', 'Seahorse', 'Starfish', 'Jellyfish', 'Lobster', 'Crawfish', - 'Tortoise', 'Iguana', 'Chameleon', 'Gecko', 'Salamander', 'Axolotl', 'Newt', 'Tadpole', - 'Bumblebee', 'Ladybug', 'Dragonfly', 'Firefly', 'Grasshopper', 'Cricket', 'Caterpillar', 'Butterfly', - 'Harmonica', 'Bagpipe', 'Banjo', 'Ukulele', 'Mandolin', 'Xylophone', 'Glockenspiel', 'Bassoon', - 'Clarinet', 'Oboe', 'Piccolo', 'Tuba', 'Cowbell', 'Maraca', 'Didgeridoo', 'Ocarina', - 'Teapot', 'Kettle', 'Colander', 'Ladle', 'Spatula', 'Whisk', 'Thimble', 'Doorknob', - 'Lampshade', 'Umbrella', 'Galosh', 'Mitten', 'Earmuff', 'Scarf', 'Bonnet', 'Beret', - 'Sombrero', 'Fedora', 'Bowtie', 'Suspender', 'Cufflink', 'Monocle', 'Pocketwatch', 'Hourglass', - 'Telescope', 'Periscope', 'Kaleidoscope', 'Gyroscope', 'Metronome', 'Sextant', 'Astrolabe', 'Compass', - 'Lighthouse', 'Windmill', 'Gazebo', 'Pergola', 'Treehouse', 'Igloo', 'Wigwam', 'Chalet', - 'Carousel', 'Ferris', 'Trampoline', 'Seesaw', 'Hopscotch', 'Yoyo', 'Frisbee', 'Boomerang', - 'Pogostick', 'Scooter', 'Tricycle', 'Unicycle', 'Rickshaw', 'Gondola', 'Zeppelin', 'Dirigible', - 'Canoe', 'Kayak', 'Dinghy', 'Schooner', 'Tugboat', 'Paddleboat', 'Hovercraft', 'Submarine', - 'Snowglobe', 'Pinecone', 'Acorn', 'Chestnut', 'Walnut', 'Hazelnut', 'Pistachio', 'Cashew', - 'Coconut', 'Pineapple', 'Mango', 'Papaya', 'Guava', 'Lychee', 'Rambutan', 'Durian', - 'Tangerine', 'Clementine', 'Grapefruit', 'Pomelo', 'Apricot', 'Nectarine', 'Plum', 'Quince', - 'Cranberry', 'Blueberry', 'Raspberry', 'Blackberry', 'Elderberry', 'Mulberry', 'Boysenberry', 'Cloudberry', - 'Paprika', 'Nutmeg', 'Cinnamon', 'Saffron', 'Cardamom', 'Turmeric', 'Oregano', 'Tarragon', - 'Pumpernickel', 'Sourdough', 'Focaccia', 'Ciabatta', 'Pita', 'Naan', 'Tortilla', 'Chapati', + 'Bubblegum', + 'Marmalade', + 'Pinwheel', + 'Kazoo', + 'Snorkel', + 'Gumdrop', + 'Waffle', + 'Pretzel', + 'Teacup', + 'Walrus', + 'Platypus', + 'Porcupine', + 'Accordion', + 'Trombone', + 'Tambourine', + 'Lollipop', + 'Marshmallow', + 'Doughnut', + 'Periwinkle', + 'Buttercup', + 'Dandelion', + 'Huckleberry', + 'Gooseberry', + 'Kumquat', + 'Persimmon', + 'Rhubarb', + 'Parsnip', + 'Rutabaga', + 'Artichoke', + 'Butterscotch', + 'Nougat', + 'Toffee', + 'Meringue', + 'Crumpet', + 'Scone', + 'Strudel', + 'Popover', + 'Macaroon', + 'Biscotti', + 'Cannoli', + 'Churro', + 'Dumpling', + 'Pancake', + 'Flapjack', + 'Muffin', + 'Custard', + 'Sherbet', + 'Sorbet', + 'Gelato', + 'Tapioca', + 'Licorice', + 'Fudge', + 'Truffle', + 'Praline', + 'Brioche', + 'Baguette', + 'Croissant', + 'Bagel', + 'Pierogi', + 'Tamale', + 'Empanada', + 'Gnocchi', + 'Ravioli', + 'Tortellini', + 'Anchovy', + 'Barnacle', + 'Pelican', + 'Flamingo', + 'Penguin', + 'Puffin', + 'Toucan', + 'Cockatoo', + 'Parakeet', + 'Hummingbird', + 'Kingfisher', + 'Sandpiper', + 'Heron', + 'Albatross', + 'Cormorant', + 'Starling', + 'Wombat', + 'Koala', + 'Kangaroo', + 'Wallaby', + 'Armadillo', + 'Aardvark', + 'Anteater', + 'Hedgehog', + 'Chipmunk', + 'Marmot', + 'Beaver', + 'Otter', + 'Badger', + 'Ferret', + 'Weasel', + 'Mongoose', + 'Meerkat', + 'Lemur', + 'Sloth', + 'Tapir', + 'Okapi', + 'Narwhal', + 'Manatee', + 'Dugong', + 'Octopus', + 'Squid', + 'Cuttlefish', + 'Seahorse', + 'Starfish', + 'Jellyfish', + 'Lobster', + 'Crawfish', + 'Tortoise', + 'Iguana', + 'Chameleon', + 'Gecko', + 'Salamander', + 'Axolotl', + 'Newt', + 'Tadpole', + 'Bumblebee', + 'Ladybug', + 'Dragonfly', + 'Firefly', + 'Grasshopper', + 'Cricket', + 'Caterpillar', + 'Butterfly', + 'Harmonica', + 'Bagpipe', + 'Banjo', + 'Ukulele', + 'Mandolin', + 'Xylophone', + 'Glockenspiel', + 'Bassoon', + 'Clarinet', + 'Oboe', + 'Piccolo', + 'Tuba', + 'Cowbell', + 'Maraca', + 'Didgeridoo', + 'Ocarina', + 'Teapot', + 'Kettle', + 'Colander', + 'Ladle', + 'Spatula', + 'Whisk', + 'Thimble', + 'Doorknob', + 'Lampshade', + 'Umbrella', + 'Galosh', + 'Mitten', + 'Earmuff', + 'Scarf', + 'Bonnet', + 'Beret', + 'Sombrero', + 'Fedora', + 'Bowtie', + 'Suspender', + 'Cufflink', + 'Monocle', + 'Pocketwatch', + 'Hourglass', + 'Telescope', + 'Periscope', + 'Kaleidoscope', + 'Gyroscope', + 'Metronome', + 'Sextant', + 'Astrolabe', + 'Compass', + 'Lighthouse', + 'Windmill', + 'Gazebo', + 'Pergola', + 'Treehouse', + 'Igloo', + 'Wigwam', + 'Chalet', + 'Carousel', + 'Ferris', + 'Trampoline', + 'Seesaw', + 'Hopscotch', + 'Yoyo', + 'Frisbee', + 'Boomerang', + 'Pogostick', + 'Scooter', + 'Tricycle', + 'Unicycle', + 'Rickshaw', + 'Gondola', + 'Zeppelin', + 'Dirigible', + 'Canoe', + 'Kayak', + 'Dinghy', + 'Schooner', + 'Tugboat', + 'Paddleboat', + 'Hovercraft', + 'Submarine', + 'Snowglobe', + 'Pinecone', + 'Acorn', + 'Chestnut', + 'Walnut', + 'Hazelnut', + 'Pistachio', + 'Cashew', + 'Coconut', + 'Pineapple', + 'Mango', + 'Papaya', + 'Guava', + 'Lychee', + 'Rambutan', + 'Durian', + 'Tangerine', + 'Clementine', + 'Grapefruit', + 'Pomelo', + 'Apricot', + 'Nectarine', + 'Plum', + 'Quince', + 'Cranberry', + 'Blueberry', + 'Raspberry', + 'Blackberry', + 'Elderberry', + 'Mulberry', + 'Boysenberry', + 'Cloudberry', + 'Paprika', + 'Nutmeg', + 'Cinnamon', + 'Saffron', + 'Cardamom', + 'Turmeric', + 'Oregano', + 'Tarragon', + 'Pumpernickel', + 'Sourdough', + 'Focaccia', + 'Ciabatta', + 'Pita', + 'Naan', + 'Tortilla', + 'Chapati', ] diff --git a/hooks/config.ts b/hooks/config.ts index f990da8..482bdd5 100644 --- a/hooks/config.ts +++ b/hooks/config.ts @@ -30,8 +30,7 @@ export type Config = { export class ConfigError extends Error {} -const isRecord = (v: unknown): v is Record => - typeof v === 'object' && v !== null && !Array.isArray(v) +const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v) function oneOf(value: unknown, allowed: readonly T[], fallback: T, where: string): T { if (value === undefined) return fallback @@ -104,13 +103,19 @@ export const PACK_OPTIONS: Readonly> = { /** A comma-separated option (or a list option) as its items. */ function items(value: unknown): string[] { const parts = typeof value === 'string' ? value.split(',') : Array.isArray(value) ? value : [] - return parts.filter((s): s is string => typeof s === 'string').map(s => s.trim()).filter(s => s !== '') + return parts + .filter((s): s is string => typeof s === 'string') + .map((s) => s.trim()) + .filter((s) => s !== '') } function slugs(value: unknown, setting: string, what: string): string[] { - const names = items(value).map(s => s.toLowerCase()) - const bad = names.findIndex(name => !SLUG.test(name)) - if (bad >= 0) throw new ConfigError(`The ${setting} setting's item ${bad + 1} is not a ${what} (lowercase letters, digits, hyphens).`) + const names = items(value).map((s) => s.toLowerCase()) + const bad = names.findIndex((name) => !SLUG.test(name)) + if (bad >= 0) + throw new ConfigError( + `The ${setting} setting's item ${bad + 1} is not a ${what} (lowercase letters, digits, hyphens).`, + ) return [...new Set(names)] } diff --git a/hooks/log.ts b/hooks/log.ts index e4b69e5..cb9a3f9 100644 --- a/hooks/log.ts +++ b/hooks/log.ts @@ -118,7 +118,14 @@ export class HiddenLog { * sources apart when their labels could match (a long path cut short), and * `agent` names the subagent whose loop read it. */ - record(source: string, tally: Tally, mode: LogMode = 'add', slot: string = source, agent?: string, kind: SourceKind = 'tools'): void { + record( + source: string, + tally: Tally, + mode: LogMode = 'add', + slot: string = source, + agent?: string, + kind: SourceKind = 'tools', + ): void { const entries = mode === 'standing' ? this.standing : this.passing slot = agent === undefined ? slot : `${agent}\u0000${slot}` const previous = entries.get(slot) @@ -131,7 +138,12 @@ export class HiddenLog { for (const [term, hit] of tally.hits) { const had = hits.get(term) // The newest placeholder and list name stand: settings may have changed since. - hits.set(term, had === undefined ? { ...hit } : { ...hit, replaced: had.replaced + hit.replaced, dropped: had.dropped + hit.dropped }) + hits.set( + term, + had === undefined + ? { ...hit } + : { ...hit, replaced: had.replaced + hit.replaced, dropped: had.dropped + hit.dropped }, + ) } // A pass that only repeats what a standing or replaced source already // hid (the same CLAUDE.md, every request) is not news: it keeps its @@ -152,7 +164,11 @@ export class HiddenLog { } private remember(source: string, tally: Tally, at: number, agent?: string): void { - const hits = [...tally.hits.values()].map(hit => ({ list: hit.list, term: hit.term, n: hit.replaced + hit.dropped })) + const hits = [...tally.hits.values()].map((hit) => ({ + list: hit.list, + term: hit.term, + n: hit.replaced + hit.dropped, + })) hits.sort((a, b) => b.n - a.n) const item: FeedItem = { at, label: source, replaced: tally.replaced, dropped: tally.dropped, hits } this.feed.push(agent === undefined ? item : { ...item, agent }) @@ -167,7 +183,7 @@ export class HiddenLog { } /** The log as the person reads it; `agentName` labels a subagent by its id. */ - lines(agentName: (agent: string) => string = agent => agent): string[] { + lines(agentName: (agent: string) => string = (agent) => agent): string[] { if (this.passing.size === 0 && this.standing.size === 0) return ['Nothing has been hidden this session yet.'] const lines: string[] = [] @@ -236,9 +252,14 @@ export class HiddenLog { for (const list of lists) list.hits.sort((a, b) => weight(b) - weight(a)) const passing = [...this.passing.values()].filter(inScope) - const agents = [...new Set([...this.passing.values()].flatMap(entry => (entry.agent === undefined ? [] : [entry.agent])))] - const lastAt = entries.reduce((at, entry) => (at === undefined || entry.at > at ? entry.at : at), undefined) - const feed = this.feed.filter(item => agent === undefined || item.agent === agent).reverse() + const agents = [ + ...new Set([...this.passing.values()].flatMap((entry) => (entry.agent === undefined ? [] : [entry.agent]))), + ] + const lastAt = entries.reduce( + (at, entry) => (at === undefined || entry.at > at ? entry.at : at), + undefined, + ) + const feed = this.feed.filter((item) => agent === undefined || item.agent === agent).reverse() return { replaced: lists.reduce((n, list) => n + list.replaced, 0), dropped: lists.reduce((n, list) => n + list.dropped, 0), @@ -259,11 +280,12 @@ function entryLines({ label, hits }: Entry): string[] { const lines = [` ${label}`] const all = [...hits.values()] - const replaced = all.filter(hit => hit.replaced > 0).sort((a, b) => b.replaced - a.replaced) + const replaced = all.filter((hit) => hit.replaced > 0).sort((a, b) => b.replaced - a.replaced) for (const hit of replaced.slice(0, MAX_TERMS_SHOWN)) { lines.push(` ${hit.term} -> ${hit.placeholder} (x${hit.replaced})`) } - if (replaced.length > MAX_TERMS_SHOWN) lines.push(` and ${plural(replaced.length - MAX_TERMS_SHOWN, 'more term')}`) + if (replaced.length > MAX_TERMS_SHOWN) + lines.push(` and ${plural(replaced.length - MAX_TERMS_SHOWN, 'more term')}`) const byList = new Map() for (const hit of all) if (hit.dropped > 0) byList.set(hit.list, [...(byList.get(hit.list) ?? []), hit]) @@ -272,7 +294,7 @@ function entryLines({ label, hits }: Entry): string[] { const total = dropped.reduce((n, hit) => n + hit.dropped, 0) const terms = dropped .slice(0, MAX_TERMS_SHOWN) - .map(hit => `${hit.term} (x${hit.dropped})`) + .map((hit) => `${hit.term} (x${hit.dropped})`) .join(', ') const more = dropped.length > MAX_TERMS_SHOWN ? `, and ${dropped.length - MAX_TERMS_SHOWN} more` : '' lines.push(` ${plural(total, 'line')} dropped by "${list}": ${terms}${more}`) diff --git a/hooks/placeholders.ts b/hooks/placeholders.ts index 15bad06..627eb51 100644 --- a/hooks/placeholders.ts +++ b/hooks/placeholders.ts @@ -69,7 +69,7 @@ function codenameFor( const round = Math.floor((first + c) / n) const name = `${CODENAMES[(first + c) % n]!}${round * 10 + digit}` const lower = name.toLowerCase() - if (taken.has(name) || avoid.equal.has(lower) || avoid.contain.some(t => lower.includes(t))) continue + if (taken.has(name) || avoid.equal.has(lower) || avoid.contain.some((t) => lower.includes(t))) continue taken.add(name) return name } diff --git a/hooks/redact.ts b/hooks/redact.ts index b13ee81..7cbbf62 100644 --- a/hooks/redact.ts +++ b/hooks/redact.ts @@ -88,13 +88,9 @@ export class Filter { * @param packTerms a pack's terms per list index, always matched as whole words: thousands of * terms matched inside words would hide text everywhere */ - constructor( - config: Config, - seed: string, - packTerms: ReadonlyMap = new Map(), - ) { + constructor(config: Config, seed: string, packTerms: ReadonlyMap = new Map()) { this.informModel = config.informModel - this.listNames = config.lists.map(list => list.name) + this.listNames = config.lists.map((list) => list.name) const refs: TermRef[] = [] let skipped = 0 @@ -121,10 +117,10 @@ export class Filter { for (const ref of refs) this.matcher.add(ref) this.termCount = this.matcher.size() - const folded = refs.map(r => r.folded) + const folded = refs.map((r) => r.folded) const names = assignPlaceholders(folded, config.placeholder, seed, { equal: new Set(folded), - contain: refs.filter(r => !r.isWordOnly).map(r => r.folded), + contain: refs.filter((r) => !r.isWordOnly).map((r) => r.folded), }) // A term listed twice keeps its first spelling for restores, and is @@ -154,11 +150,11 @@ export class Filter { if (matches.length === 0) return { value: text, changed: false, vanished: false } let out = text - if (allowDrop && matches.some(m => m.ref.mode === 'drop-line')) { + if (allowDrop && matches.some((m) => m.ref.mode === 'drop-line')) { // A literal `\n` ends a line too: some output prints its newlines as text. const lines = text.split(/(?<=\n|\\n)/) - const kept = lines.filter(line => { - const dropper = this.matcher.find(line).find(m => m.ref.mode === 'drop-line') + const kept = lines.filter((line) => { + const dropper = this.matcher.find(line).find((m) => m.ref.mode === 'drop-line') if (dropper !== undefined) this.hit(tally, dropper.ref).dropped += 1 return dropper === undefined }) @@ -192,7 +188,13 @@ export class Filter { private hit(tally: Tally, ref: TermRef): Hit { let hit = tally.hits.get(ref.folded) if (hit === undefined) { - hit = { term: ref.canonical, placeholder: this.placeholderOf.get(ref.folded)!, list: this.listNames[ref.list]!, replaced: 0, dropped: 0 } + hit = { + term: ref.canonical, + placeholder: this.placeholderOf.get(ref.folded)!, + list: this.listNames[ref.list]!, + replaced: 0, + dropped: 0, + } tally.hits.set(ref.folded, hit) } return hit @@ -262,7 +264,7 @@ export class Filter { /** The guarded placeholders `input` uses, the engine's own keys aside. */ guardedIn(input: Record): string[] { const found = new Set() - forEachString(input, s => { + forEachString(input, (s) => { for (const hit of findPlaceholders(s, this.guarded)) found.add(hit.name) }) return [...found] @@ -311,7 +313,7 @@ export function forEachString(input: Record, visit: (s: string) function mapStrings(v: unknown, f: (s: string) => string, depth = 0): unknown { if (depth > MAX_DEPTH) return v if (typeof v === 'string') return f(v) - if (Array.isArray(v)) return v.map(item => mapStrings(item, f, depth + 1)) + if (Array.isArray(v)) return v.map((item) => mapStrings(item, f, depth + 1)) if (isPlainObject(v)) { const out: Record = {} for (const [key, child] of Object.entries(v)) out[key] = mapStrings(child, f, depth + 1) @@ -330,13 +332,17 @@ export function noteFor(tally: Tally, restorable: ReadonlySet = new Set( const parts: string[] = [] if (tally.replaced > 0) { const names = [...tally.names].slice(0, 12).join(', ') - parts.push(`${tally.replaced} hidden term${tally.replaced === 1 ? ' was' : 's were'} replaced with placeholder names (${names})`) + parts.push( + `${tally.replaced} hidden term${tally.replaced === 1 ? ' was' : 's were'} replaced with placeholder names (${names})`, + ) } if (tally.dropped > 0) { - parts.push(`${tally.dropped} line${tally.dropped === 1 ? '' : 's'} mentioning hidden items ${tally.dropped === 1 ? 'was' : 'were'} removed`) + parts.push( + `${tally.dropped} line${tally.dropped === 1 ? '' : 's'} mentioning hidden items ${tally.dropped === 1 ? 'was' : 'were'} removed`, + ) } - const usable = [...tally.names].filter(name => restorable.has(name)) + const usable = [...tally.names].filter((name) => restorable.has(name)) const rule = usable.length === 0 ? 'Mentioning a placeholder in a reply is fine, but a tool call that uses one (a command, search or file edit) is refused.' diff --git a/hooks/register.ts b/hooks/register.ts index f284b70..362dcd5 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -20,7 +20,16 @@ import type { AgentInfo, EngineInterface, FsEntry, On, PluginOptions, ToolCallResult } from 'claude-code' -import { closestName, ConfigError, optionLists, parseConfig, parsePack, SLUG, type Config, type Pack } from './config.ts' +import { + closestName, + ConfigError, + optionLists, + parseConfig, + parsePack, + SLUG, + type Config, + type Pack, +} from './config.ts' import { HiddenLog, type LogMode, type SourceKind } from './log.ts' import { fnv1a } from './placeholders.ts' import { openRows, SIDEBAR_ID, sidebarView, type Depth, type SidebarView } from './sidebar.tsx' @@ -149,7 +158,7 @@ const agentLabel = (info: AgentInfo) => info.name ?? (info.description.trim() == /** Every subagent's label by id; empty when they cannot be listed. */ async function agentLabels($: EngineInterface): Promise> { try { - return new Map((await $.agent.list()).map(info => [info.id, agentLabel(info)])) + return new Map((await $.agent.list()).map((info) => [info.id, agentLabel(info)])) } catch { return new Map() } @@ -198,7 +207,7 @@ const filteredFiles = new Set() const pathKey = (path: string) => path.replace(/\\/g, '/').toLowerCase() /** Whether what the model read lacks something a whole-file rewrite would lose. */ -const hidesContent = (f: Filter, seen: Tally) => seen.dropped > 0 || [...seen.names].some(name => f.guarded.has(name)) +const hidesContent = (f: Filter, seen: Tally) => seen.dropped > 0 || [...seen.names].some((name) => f.guarded.has(name)) async function homeOf($: EngineInterface): Promise { // USERPROFILE first: on Windows it is where Claude Code keeps ~/.claude, @@ -264,7 +273,7 @@ async function loadPacks( try { text = await $.fs.read(builtIn) } catch { - const names = [...new Set((await findPacks($, home)).map(entry => entry.name))] + const names = [...new Set((await findPacks($, home)).map((entry) => entry.name))] const where = list.setting === undefined ? `lists[${i}]` : `the ${list.setting} setting` throw new ConfigError(missingPack(list.pack, where, names)) } @@ -286,7 +295,7 @@ function missingPack(name: string, where: string, available: readonly string[]): available.length === 0 ? 'No packs are installed.' : `Available: ${available.join(', ')}.`, `Run /topic-filter packs to see what each covers, or make your own at ~/${USER_PACKS}/${name}.json.`, ] - .filter(part => part !== '') + .filter((part) => part !== '') .join(' ') } @@ -362,7 +371,7 @@ async function packListing($: EngineInterface, l: Loaded): Promise { } if (entries.length === 0) lines.push(' none installed') - const names = new Set(entries.map(entry => entry.name)) + const names = new Set(entries.map((entry) => entry.name)) for (const [name, lists] of users) { if (!names.has(name)) lines.push(` ${name}: NOT FOUND, named by ${lists.join(', ')}`) } @@ -374,7 +383,7 @@ async function packListing($: EngineInterface, l: Loaded): Promise { async function seedOf($: EngineInterface): Promise { const stored = await $.store.get('seed') if (typeof stored === 'string' && stored.length >= 16) return stored - const seed = [...crypto.getRandomValues(new Uint8Array(16))].map(b => b.toString(16).padStart(2, '0')).join('') + const seed = [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, '0')).join('') await $.store.set('seed', seed) return seed } @@ -388,7 +397,7 @@ async function current($: EngineInterface): Promise { const path = await pathOf($) const topicsKey = await statKey($, path) const deps = loaded?.path === path ? loaded.deps : [] - const key = [topicsKey, ...(await Promise.all(deps.map(d => statKey($, d))))].join('\n') + const key = [topicsKey, ...(await Promise.all(deps.map((d) => statKey($, d))))].join('\n') checkedAt = Date.now() if (loaded?.key === key) return loaded @@ -433,7 +442,12 @@ function refilter($: EngineInterface): void { showStatus($) } -async function build($: EngineInterface, path: string, topicsKey: string, previous: Loaded | undefined): Promise { +async function build( + $: EngineInterface, + path: string, + topicsKey: string, + previous: Loaded | undefined, +): Promise { const isMissing = topicsKey.endsWith('|missing') let deps: string[] = previous?.deps ?? [] @@ -446,19 +460,24 @@ async function build($: EngineInterface, path: string, topicsKey: string, previo if (own === null && extra.length === 0) { return { key: topicsKey, path, deps: [], packsUsed: new Map(), config: null, latest: null, filter: null } } - const config: Config = { placeholder: 'codename', informModel: true, ...own, lists: [...(own?.lists ?? []), ...extra] } + const config: Config = { + placeholder: 'codename', + informModel: true, + ...own, + lists: [...(own?.lists ?? []), ...extra], + } latest = config // Both places of every named pack, found or not: creating or editing // either one is then noticed. const home = await homeOf($) - deps = config.lists.flatMap(list => (list.pack === undefined ? [] : packPaths(home, $.plugin.root, list.pack))) + deps = config.lists.flatMap((list) => (list.pack === undefined ? [] : packPaths(home, $.plugin.root, list.pack))) const packs = await loadPacks($, config, home) - const key = [topicsKey, ...(await Promise.all(deps.map(d => statKey($, d))))].join('\n') + const key = [topicsKey, ...(await Promise.all(deps.map((d) => statKey($, d))))].join('\n') const filter = new Filter(config, await seedOf($), packs.terms) return { key, path, deps, packsUsed: packs.used, config, latest, filter } } catch (error) { const message = error instanceof ConfigError ? error.message : 'the file could not be read' - const key = [topicsKey, ...(await Promise.all(deps.map(d => statKey($, d))))].join('\n') + const key = [topicsKey, ...(await Promise.all(deps.map((d) => statKey($, d))))].join('\n') return { key, path, @@ -481,9 +500,11 @@ function statusText(): string { return 'topic-filter: off, nothing chosen to hide. Switch on packs in /config.' } if (paused) return 'topic-filter: PAUSED, nothing is hidden. /topic-filter on resumes it.' - if (loaded.filter === null) return `topic-filter: BLOCKING tool calls. ${firstSentence(loaded.error ?? '')} Run /topic-filter.` + if (loaded.filter === null) + return `topic-filter: BLOCKING tool calls. ${firstSentence(loaded.error ?? '')} Run /topic-filter.` const base = `topic-filter: on, ${count(loaded.filter.termCount, 'term')}, ${hiddenCount} hidden` - if (loaded.error !== undefined) return `${base}. Using the last good settings: ${firstSentence(loaded.error)} Run /topic-filter.` + if (loaded.error !== undefined) + return `${base}. Using the last good settings: ${firstSentence(loaded.error)} Run /topic-filter.` return base } @@ -534,7 +555,7 @@ function counted( /** The overview `/topic-filter` shows the person: every list and where its terms come from. */ function describe(l: Loaded): string[] { const lines = [statusText().replace(/^topic-filter: /, 'topic-filter is '), `Topics file: ${l.path}`] - if (l.latest !== null && l.latest.lists.every(list => list.setting !== undefined)) { + if (l.latest !== null && l.latest.lists.every((list) => list.setting !== undefined)) { lines.push('(No topics file: everything below comes from the plugin settings in /config.)') } if (l.error !== undefined) lines.push(`Problem: ${l.error}`) @@ -596,7 +617,9 @@ export function register(on: On, options: PluginOptions) { // a file the model holds a filtered copy of cannot be overwritten whole. // A topics file that failed to load still holds the list. if ((f !== null || l.error !== undefined) && mentionsPath(input, l.path)) { - return { deny: 'topic-filter: that path holds the list of hidden topics, which this session may not read or change.' } + return { + deny: 'topic-filter: that path holds the list of hidden topics, which this session may not read or change.', + } } // Edit changes only the text it names, and fails if that text spans // something hidden, so it stays allowed. @@ -660,7 +683,7 @@ export function register(on: On, options: PluginOptions) { const tally = newTally() const text = f.text(e.text, tally, false) - const context = e.context?.map(c => f.text(c, tally).value).filter(c => c.length > 0) + const context = e.context?.map((c) => f.text(c, tally).value).filter((c) => c.length > 0) if (tally.replaced === 0 && tally.dropped === 0) return next(e) counted($, tally, 'Your prompt', 'prompts') @@ -678,13 +701,13 @@ export function register(on: On, options: PluginOptions) { // The claudeMd block is the instruction files' text: with the files at // hand, it is counted and logged by file, not a second time as a block. const byFile = r.instructionFiles !== undefined - const blocks = r.blocks.map(b => { + const blocks = r.blocks.map((b) => { const tally = newTally() const text = f.text(b.text, tally).value if (!(byFile && b.name === 'claudeMd')) counted($, tally, `Context block ${b.name}`, 'context', 'standing') return { ...b, text } }) - const instructionFiles = r.instructionFiles?.map(file => { + const instructionFiles = r.instructionFiles?.map((file) => { const tally = newTally() const content = f.text(file.content, tally).value counted($, tally, file.path, 'context', 'standing') @@ -702,7 +725,15 @@ export function register(on: On, options: PluginOptions) { const text = f.text(r.text, tally) // An attachment has no name of its own; its text tells one from another, // so one asked again (after a reload) replaces its entry. - counted($, tally, `Attachment (${e.type})`, 'context', 'replace', `attachment:${e.type}:${fnv1a(r.text)}`, e.agentId) + counted( + $, + tally, + `Attachment (${e.type})`, + 'context', + 'replace', + `attachment:${e.type}:${fnv1a(r.text)}`, + e.agentId, + ) if (!text.changed) return r return { text: text.vanished ? null : text.value } }).catch(($, e) => (filtersAttachment(e) ? { text: null } : undefined)) @@ -766,9 +797,11 @@ export function register(on: On, options: PluginOptions) { arg === 'packs' ? await packListing($, l) : arg === 'log' - ? hiddenLog.lines(agent => labels.get(agent) ?? agent) + ? hiddenLog.lines((agent) => labels.get(agent) ?? agent) : arg === 'log clear' - ? ['The log is cleared, except what Claude reads with every request. The status line count keeps its total.'] + ? [ + 'The log is cleared, except what Claude reads with every request. The status line count keeps its total.', + ] : arg === 'sidebar' ? await toggleSidebar($) : describe(l) @@ -866,7 +899,11 @@ const RESUME_ARGS = new Set(['on', 'resume', 'start']) * for the model when the state changed, so it knows whether placeholders * are still refused. */ -async function setPaused($: EngineInterface, pause: boolean, origin: string | undefined): Promise<{ lines: string[]; context?: string }> { +async function setPaused( + $: EngineInterface, + pause: boolean, + origin: string | undefined, +): Promise<{ lines: string[]; context?: string }> { if (pause && !MAY_PAUSE.has(origin ?? '')) { return { lines: ['Only you can pause topic-filter, by typing /topic-filter off. It stays on.'] } } @@ -883,7 +920,12 @@ async function setPaused($: EngineInterface, pause: boolean, origin: string | un 'topic-filter paused: nothing is hidden until /topic-filter on.', 'What Claude already read stays as it was. A restart, /clear or a reload turns it back on.', ], - ...(was ? {} : { context: 'The user paused topic-filter: tool output is no longer filtered, and placeholders are no longer refused.' }), + ...(was + ? {} + : { + context: + 'The user paused topic-filter: tool output is no longer filtered, and placeholders are no longer refused.', + }), } } @@ -893,13 +935,18 @@ async function setPaused($: EngineInterface, pause: boolean, origin: string | un : `Filter on: ${count(l.config?.lists.length ?? 0, 'list')}, ${count(l.filter.termCount, 'term')}.` return { lines: [was ? status : `${status} (It was not paused.)`], - ...(was ? { context: 'The user turned topic-filter back on: hidden items are filtered again, and placeholders are refused in tool calls.' } : {}), + ...(was + ? { + context: + 'The user turned topic-filter back on: hidden items are filtered again, and placeholders are refused in tool calls.', + } + : {}), } } /** Opens the sidebar, or closes it when it is open; says which, for the person. */ async function toggleSidebar($: EngineInterface): Promise { - const isOpen = (await $.ui.panes()).some(pane => pane.id === SIDEBAR_ID) + const isOpen = (await $.ui.panes()).some((pane) => pane.id === SIDEBAR_ID) if (isOpen) { await $.ui.close({ id: SIDEBAR_ID }) return ['Sidebar closed. /topic-filter sidebar opens it again.'] @@ -933,8 +980,8 @@ function filterResult(f: Filter, r: ToolCallResult, seen: Tally): ToolCallResult const record = f.value(r.result, newTally()) if (typeof r.text === 'string') f.text(r.text, seen) - const context = r.context?.map(c => f.text(c, seen)) - const contextChanged = context?.some(c => c.changed) ?? false + const context = r.context?.map((c) => f.text(c, seen)) + const contextChanged = context?.some((c) => c.changed) ?? false if (!record.changed && !contextChanged) { // A term in `text` that the record does not hold came from the mapper, @@ -944,7 +991,7 @@ function filterResult(f: Filter, r: ToolCallResult, seen: Tally): ToolCallResult return r } - const kept = (context ?? []).map(c => c.value).filter(c => c.length > 0) + const kept = (context ?? []).map((c) => c.value).filter((c) => c.length > 0) const note = f.informModel ? noteFor(seen, f.restorable) : undefined const all = note === undefined ? kept : [...kept, note] // Without `ref`, core maps the filtered record for the model afresh. @@ -976,7 +1023,7 @@ function mentionsPath(input: Record, path: string): boolean { const tail = full.split('/').slice(-2).join('/') let found = false const checked = Object.fromEntries(Object.entries(input).filter(([key]) => !WRITTEN_TEXT.has(key))) - forEachString(checked, s => { + forEachString(checked, (s) => { const n = norm(s) if (n.includes(full) || n.includes(tail)) found = true }) diff --git a/hooks/sidebar.tsx b/hooks/sidebar.tsx index ce322b4..22c487c 100644 --- a/hooks/sidebar.tsx +++ b/hooks/sidebar.tsx @@ -68,8 +68,11 @@ export function isOpen(view: SidebarView, summary: Summary, id: string): boolean /** Every row open in `view` now, so a press can switch from a depth to rows picked one by one. */ export function openRows(view: SidebarView, summary: Summary): Set { - const ids = summary.lists.flatMap(list => [listId(list.list), ...list.hits.map(hit => termId(list.list, hit.term))]) - return new Set(ids.filter(id => isOpen(view, summary, id))) + const ids = summary.lists.flatMap((list) => [ + listId(list.list), + ...list.hits.map((hit) => termId(list.list, hit.term)), + ]) + return new Set(ids.filter((id) => isOpen(view, summary, id))) } /** Short bars: the widest list's bar is this many cells. */ @@ -117,19 +120,33 @@ export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInpu } if (isPaused) { - add(top, {fit(' PAUSED: nothing is hidden. /topic-filter on', columns)}) + add( + top, + + {fit(' PAUSED: nothing is hidden. /topic-filter on', columns)} + , + ) } add( top, {` ${total.toLocaleString('en-US')} `} - {agent === undefined ? ' hidden this session' : ' hidden in this subagent'} + + {agent === undefined ? ' hidden this session' : ' hidden in this subagent'} + , ) add( top, - {isEmpty ? 'Nothing yet.' : [summary.replaced > 0 ? `${plural(summary.replaced, 'word')} replaced` : '', summary.dropped > 0 ? `${plural(summary.dropped, 'line')} dropped` : ''].filter(Boolean).join(', ')} + {isEmpty + ? 'Nothing yet.' + : [ + summary.replaced > 0 ? `${plural(summary.replaced, 'word')} replaced` : '', + summary.dropped > 0 ? `${plural(summary.dropped, 'line')} dropped` : '', + ] + .filter(Boolean) + .join(', ')} , ) @@ -137,10 +154,16 @@ export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInpu add( top, - - + + - {scope} + + {scope} + , 2, ) @@ -162,11 +185,16 @@ export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInpu function treeRows(): void { if (isEmpty) { - add(body, Hidden terms show here as Claude reads them.) + add( + body, + + Hidden terms show here as Claude reads them. + , + ) return } if (!countsOnly) depthControl() - const widest = Math.max(1, ...summary.lists.map(list => list.replaced + list.dropped)) + const widest = Math.max(1, ...summary.lists.map((list) => list.replaced + list.dropped)) for (const list of summary.lists) { const id = listId(list.list) const open = !countsOnly && isOpen(view, summary, id) @@ -178,12 +206,20 @@ export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInpu body, {countsOnly ? ( - {name} + + {name} + ) : ( - + )} - {bar} - {counts(list).padStart(COUNT_WIDTH)} + + {bar} + + + {counts(list).padStart(COUNT_WIDTH)} + , ) if (!open) continue @@ -195,16 +231,28 @@ export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInpu add( body, - + - 0 ? 'warning' : undefined} dimColor={hit.replaced === 0} wrap="truncate-end">{` ${shown}`} + 0 ? 'warning' : undefined} + dimColor={hit.replaced === 0} + wrap="truncate-end" + >{` ${shown}`} {` ${n}`} , ) if (!termOpen) continue for (const [i, source] of hit.sources.entries()) { - add(body, {` ${source.label} x${source.n}`}) + add( + body, + {` ${source.label} x${source.n}`}, + ) } } } @@ -220,19 +268,48 @@ export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInpu ] const fixed = 'Expand '.length + stops.reduce((n, [, label]) => n + label.length + 3, 0) const line = '─'.repeat(Math.max(2, Math.floor((columns - fixed) / 2))) - const parts: RenderElement[] = [Expand ] + const parts: RenderElement[] = [ + + Expand{' '} + , + ] for (const [n, label] of stops) { - if (n > 0) parts.push(= n ? 'claude' : undefined} dimColor={depth === null || depth < n}>{line}) + if (n > 0) + parts.push( + = n ? 'claude' : undefined} + dimColor={depth === null || depth < n} + > + {line} + , + ) parts.push( - , + , ) } - add(body, {parts}, 2) + add( + body, + + {parts} + , + 2, + ) } function feedRows(): void { if (summary.feed.length === 0) { - add(body, Each hit shows here as it happens, newest first.) + add( + body, + + Each hit shows here as it happens, newest first. + , + ) return } // The pane scrolls, but a feed longer than a screen or two is noise. @@ -241,38 +318,58 @@ export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInpu function feedItem(item: FeedItem, i: number): void { const who = agent === undefined && item.agent !== undefined ? 'Subagent: ' : '' - const what = countsOnly ? `${who}${item.hits.length === 1 ? '1 term' : `${item.hits.length} terms`}` : `${who}${item.label}` + const what = countsOnly + ? `${who}${item.hits.length === 1 ? '1 term' : `${item.hits.length} terms`}` + : `${who}${item.label}` const n = counts(item) add( body, {`${clock(item.at)} `} - {what} + + {what} + {` ${n}`} , ) - const lists = [...new Set(item.hits.map(hit => hit.list))] + const lists = [...new Set(item.hits.map((hit) => hit.list))] const detail = countsOnly ? lists.join(', ') - : lists.map(list => `${list}: ${item.hits.filter(hit => hit.list === list).map(hit => `${hit.term} x${hit.n}`).join(', ')}`).join('; ') + : lists + .map( + (list) => + `${list}: ${item.hits + .filter((hit) => hit.list === list) + .map((hit) => `${hit.term} x${hit.n}`) + .join(', ')}`, + ) + .join('; ') add(body, {` ${detail}`}) } function statsRows(): void { if (isEmpty) return - add(bottom, {'─'.repeat(columns)}, 2) + add( + bottom, + + {'─'.repeat(columns)} + , + 2, + ) add( bottom, - Where it came from + + Where it came from + {summary.lastAt === undefined ? null : {`last hit ${clock(summary.lastAt)}`}} , ) - const most = Math.max(1, ...summary.kinds.map(k => k.n)) + const most = Math.max(1, ...summary.kinds.map((k) => k.n)) const width = Math.max(4, columns - 12 - 5) for (const { kind, n } of summary.kinds) { const fill = Math.max(1, Math.round((n / most) * width)) @@ -280,9 +377,17 @@ export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInpu bottom, {fit(KIND_LABELS[kind], 12)} - {'█'.repeat(fill)} - {fill < width ? {'░'.repeat(width - fill)} : null} - {String(n).padStart(5)} + + {'█'.repeat(fill)} + + {fill < width ? ( + + {'░'.repeat(width - fill)} + + ) : null} + + {String(n).padStart(5)} + , ) } @@ -296,7 +401,9 @@ export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInpu bottom, {facts.map(([n, noun]) => ( - {plural(n, noun)} + + {plural(n, noun)} + ))} , 2, diff --git a/tests/filter.test.ts b/tests/filter.test.ts index 3d6c31a..24508d4 100644 --- a/tests/filter.test.ts +++ b/tests/filter.test.ts @@ -20,7 +20,7 @@ const matcherOf = (...refs: TermRef[]) => { return m } -const found = (m: Matcher, text: string) => m.find(text).map(x => text.slice(x.start, x.end)) +const found = (m: Matcher, text: string) => m.find(text).map((x) => text.slice(x.start, x.end)) describe('matcher', () => { test('ignores accents and case', () => { @@ -40,7 +40,11 @@ describe('matcher', () => { test('spaces, hyphens and underscores are one separator', () => { const m = matcherOf(ref('secret repo')) - expect(found(m, 'secret-repo secret_repo secret repo secretrepo')).toEqual(['secret-repo', 'secret_repo', 'secret repo']) + expect(found(m, 'secret-repo secret_repo secret repo secretrepo')).toEqual([ + 'secret-repo', + 'secret_repo', + 'secret repo', + ]) }) test('takes the longest term at a position', () => { @@ -55,7 +59,10 @@ describe('matcher', () => { test('finds a term inside a path', () => { const m = matcherOf(ref('secret-repo')) - expect(found(m, 'D:\\Python\\secret-repo\\main.py and /home/me/secret-repo/')).toEqual(['secret-repo', 'secret-repo']) + expect(found(m, 'D:\\Python\\secret-repo\\main.py and /home/me/secret-repo/')).toEqual([ + 'secret-repo', + 'secret-repo', + ]) }) }) @@ -83,7 +90,10 @@ describe('placeholders', () => { test('finds a placeholder in written text, plural included, not its plain or lower-case word', () => { const known = new Set(['Bubblegum7', 'Kazoo12']) const hits = findPlaceholders('cd Bubblegum7s && ls Kazoo12 bubblegum7, a Bubblegum and a Kazoo', known) - expect(hits.map(h => [h.name, h.suffix])).toEqual([['Bubblegum7', 's'], ['Kazoo12', '']]) + expect(hits.map((h) => [h.name, h.suffix])).toEqual([ + ['Bubblegum7', 's'], + ['Kazoo12', ''], + ]) }) }) @@ -103,7 +113,9 @@ const filter = () => new Filter(CONFIG, 'test-seed-0123456789') describe('config and packs', () => { test('a pack name must be a plain name, never a path', () => { expect(() => parseConfig('{"lists": [{"pack": "../secrets"}]}')).toThrow(/lists\[0\]\.pack must be a pack name/) - expect(() => parseConfig('{"lists": [{"pack": "demo", "restore": true}]}')).toThrow(/lists\[0\]\.restore is not allowed on a list that uses a pack/) + expect(() => parseConfig('{"lists": [{"pack": "demo", "restore": true}]}')).toThrow( + /lists\[0\]\.restore is not allowed on a list that uses a pack/, + ) }) test('pack errors name the pack and a position, never a term', () => { @@ -134,13 +146,9 @@ describe('config and packs', () => { otherPacks: 'my-topic, chemistry', extraWords: ' Teotihuacan ,, Giza ', }) - expect(lists.map(l => l.name)).toEqual([ - 'pack chemistry', - 'pack my-topic', - 'extra words', - ]) + expect(lists.map((l) => l.name)).toEqual(['pack chemistry', 'pack my-topic', 'extra words']) expect(lists.at(-1)!.terms).toEqual(['Teotihuacan', 'Giza']) - expect(lists.find(l => l.pack === 'my-topic')!.setting).toBe('Other packs') + expect(lists.find((l) => l.pack === 'my-topic')!.setting).toBe('Other packs') }) test('nothing chosen adds nothing, and the removed GitHub topic settings are ignored', () => { @@ -149,7 +157,9 @@ describe('config and packs', () => { }) test('a bad name in a setting names the setting and position, never the value', () => { - expect(() => optionLists({ otherPacks: 'ok, ../secrets' })).toThrow("The Other packs setting's item 2 is not a pack name") + expect(() => optionLists({ otherPacks: 'ok, ../secrets' })).toThrow( + "The Other packs setting's item 2 is not a pack name", + ) }) test('a pack without hints is fine', () => { @@ -157,7 +167,9 @@ describe('config and packs', () => { }) test('exclude takes a term out of its list, by folded form', () => { - const config = parseConfig(JSON.stringify({ lists: [{ terms: ['Teotihuacan', 'Giza'], exclude: ['TEOTIHUACÁN'] }] })) + const config = parseConfig( + JSON.stringify({ lists: [{ terms: ['Teotihuacan', 'Giza'], exclude: ['TEOTIHUACÁN'] }] }), + ) const f = new Filter(config, 'seed-0123456789abcdef') expect(f.termCount).toBe(1) expect(f.text('Teotihuacan and Giza', newTally()).value.startsWith('Teotihuacan and ')).toBe(true) diff --git a/tests/log.test.ts b/tests/log.test.ts index aa884ef..02b0901 100644 --- a/tests/log.test.ts +++ b/tests/log.test.ts @@ -26,7 +26,7 @@ function pass(text: string): Tally { describe('what a pass records', () => { test('each term with its placeholder, its list, and how often', () => { const tally = pass('Teotihuacan, then teotihuacán again, and the Pyramids of Giza.') - const hits = [...tally.hits.values()].map(h => [h.term, h.list, h.replaced, h.dropped]) + const hits = [...tally.hits.values()].map((h) => [h.term, h.list, h.replaced, h.dropped]) expect(hits).toEqual([ ['Teotihuacan', 'places', 2, 0], ['Pyramids of Giza', 'places', 1, 0], @@ -37,7 +37,7 @@ describe('what a pass records', () => { test('each dropped line once, under the first drop-line term on it', () => { const tally = pass('a/public\na/secret-repo and a/other-secret\na/other-secret\n') expect(tally.dropped).toBe(2) - const dropped = [...tally.hits.values()].map(h => [h.term, h.dropped]) + const dropped = [...tally.hits.values()].map((h) => [h.term, h.dropped]) expect(dropped).toEqual([ ['secret-repo', 1], ['other-secret', 1], @@ -68,7 +68,7 @@ describe('the log', () => { log.record('Read a.md', pass('Teotihuacan')) log.record('Read b.md', pass('Teotihuacan')) log.record('Read a.md', pass('Teotihuacan')) - const sources = log.lines().filter(line => /^ {2}\S/.test(line)) + const sources = log.lines().filter((line) => /^ {2}\S/.test(line)) expect(sources).toEqual([' Read b.md', ' Read a.md']) expect(log.lines().join('\n')).toMatch(/Read a\.md\n\s+Teotihuacan -> \S+ \(x2\)/) }) @@ -137,7 +137,7 @@ describe('what the sidebar reads', () => { now = 2000 log.record('Read trip.md', pass('Teotihuacan and Teotihuacan'), 'add', undefined, undefined, 'files') const { feed, lastAt } = log.summary() - expect(feed.map(item => [item.at, item.label, item.replaced, item.dropped])).toEqual([ + expect(feed.map((item) => [item.at, item.label, item.replaced, item.dropped])).toEqual([ [2000, 'Read trip.md', 2, 0], [1000, 'Bash gh repo list', 0, 1], ]) @@ -168,7 +168,7 @@ describe('what the sidebar reads', () => { { kind: 'commands', n: 1 }, ]) expect(s.sources).toBe(3) - const place = s.lists.find(list => list.list === 'places')!.hits[0]! + const place = s.lists.find((list) => list.list === 'places')!.hits[0]! expect(place.sources).toEqual([ { label: 'Read trip.md', n: 2 }, { label: 'Your prompt', n: 1 }, @@ -179,7 +179,7 @@ describe('what the sidebar reads', () => { const log = new HiddenLog(() => 0) log.record('Bash ls', pass('a/secret-repo\n')) log.record('Read plan.md', pass('Teotihuacan'), 'add', undefined, 'agent-7', 'files') - expect(log.summary('agent-7').feed.map(item => item.label)).toEqual(['Read plan.md']) + expect(log.summary('agent-7').feed.map((item) => item.label)).toEqual(['Read plan.md']) expect(log.summary().feed).toHaveLength(2) log.clear() expect(log.summary().feed).toEqual([]) diff --git a/tests/register.test.ts b/tests/register.test.ts index 72ab5c5..822db04 100644 --- a/tests/register.test.ts +++ b/tests/register.test.ts @@ -45,7 +45,8 @@ const TOPICS = JSON.stringify({ ], }) -const GH_LIST = 'lperezmo/public-repo\tA public one\tpublic\nlperezmo/secret-repo\tHidden\tprivate\nlperezmo/notes\tAbout Teotihuacan\tpublic\n' +const GH_LIST = + 'lperezmo/public-repo\tA public one\tpublic\nlperezmo/secret-repo\tHidden\tprivate\nlperezmo/notes\tAbout Teotihuacan\tpublic\n' function world(on: On, topics: string | null = TOPICS, packs: Packs = {}, stored: Record = {}) { mock.env(on, { HOME }) @@ -65,7 +66,7 @@ function world(on: On, topics: string | null = TOPICS, packs: Packs = {}, stored const names = filesIn(e.path, packs) return names === undefined ? { deny: `ENOENT: ${e.path}` } - : { value: names.map(n => ({ name: `${n}.json`, kind: 'file' as const, size: 1, isLink: false })) } + : { value: names.map((n) => ({ name: `${n}.json`, kind: 'file' as const, size: 1, isLink: false })) } }) // What the person sees, which never reaches the model. const shown = { statuses: [] as string[], logs: [] as string[], toasts: [] as string[] } @@ -159,7 +160,10 @@ describe('tool output', () => { }) test('a leftover githubTopic in the topics file is ignored', async ($, on) => { - const shown = world(on, '{"lists": [{"name": "repos", "mode": "drop-line", "githubTopic": "claude-hidden", "terms": ["Teotihuacan"]}]}') + const shown = world( + on, + '{"lists": [{"name": "repos", "mode": "drop-line", "githubTopic": "claude-hidden", "terms": ["Teotihuacan"]}]}', + ) tools(on) const r = await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) expect(r.deny).toBeUndefined() @@ -174,7 +178,7 @@ describe('tool output', () => { expect(r.deny?.includes('lists[0]')).toBe(false) expect(shown.toasts.at(-1)).toMatch(/lists\[0\]\.terms\[1\] must be a string/) expect(await labelOf($)).toBe('topic-filter: blocking tool calls, run /topic-filter') - expect(shown.statuses.filter(s => s !== '')).toEqual([]) + expect(shown.statuses.filter((s) => s !== '')).toEqual([]) expect(shown.toasts).toHaveLength(1) expect(ran).toEqual([]) }) @@ -187,10 +191,16 @@ describe('tool output', () => { on('tool.call', async ($, e) => { ran.push(e.tool) if (e.tool === 'Read') { - return { result: { type: 'text', file: { filePath: FILE, content, numLines: 3, startLine: 1, totalLines: 3 } }, text: content } as never + return { + result: { type: 'text', file: { filePath: FILE, content, numLines: 3, startLine: 1, totalLines: 3 } }, + text: content, + } as never } // An edit's record carries the file as it was; the model reads one line. - return { result: { filePath: FILE, originalFile: content }, text: `The file ${FILE} has been updated successfully.` } as never + return { + result: { filePath: FILE, originalFile: content }, + text: `The file ${FILE} has been updated successfully.`, + } as never }) const read = await $.tool.call({ tool: 'Read', file_path: FILE }) @@ -220,7 +230,11 @@ describe('context', () => { test("rewrites the person's prompt and tells the model", async ($, on) => { world(on) on('prompt.submit', async ($, e) => ({ text: e.text, context: e.context })) - const r = await $.prompt.submit({ text: 'what do we have on Teotihuacan?', wait: false, origin: { kind: 'composer' } }) + const r = await $.prompt.submit({ + text: 'what do we have on Teotihuacan?', + wait: false, + origin: { kind: 'composer' }, + }) expect(r.text?.includes('Teotihuacan')).toBe(false) expect(r.text?.startsWith('what do we have on ')).toBe(true) expect(r.context?.join('\n')).toMatch(/1 hidden term was replaced/) @@ -238,7 +252,7 @@ describe('context', () => { world(on) on('prompt.context', async ($, e) => ({ blocks: e.blocks })) const r = await $.prompt.context({ blocks: [{ name: 'claudeMd', text: 'Our site: the Pyramids of Giza.' }] }) - expect(r.blocks.map(b => b.name)).toEqual(['claudeMd', 'topicFilter']) + expect(r.blocks.map((b) => b.name)).toEqual(['claudeMd', 'topicFilter']) expect(r.blocks[0]!.text.includes('Giza')).toBe(false) }) @@ -252,7 +266,11 @@ describe('context', () => { test("rewrites a settings hook's context", async ($, on) => { world(on) on('prompt.attachment', async () => ({ text: 'Hook says: Teotihuacan' })) - const r = await $.prompt.attachment({ type: 'hook_additional_context', text: 'x', origin: { kind: 'hook', event: 'SessionStart' } as never }) + const r = await $.prompt.attachment({ + type: 'hook_additional_context', + text: 'x', + origin: { kind: 'hook', event: 'SessionStart' } as never, + }) expect(r.text?.includes('Teotihuacan')).toBe(false) }) @@ -288,7 +306,11 @@ describe('context', () => { }) let reason = '' try { - await $.tool.describe({ tool: 'mcp__notes__search', description: 'Search notes', provider: { plugin: 'mcp:notes', tier: 'user' } } as never) + await $.tool.describe({ + tool: 'mcp__notes__search', + description: 'Search notes', + provider: { plugin: 'mcp:notes', tier: 'user' }, + } as never) } catch (error) { reason = String(error) } @@ -298,9 +320,17 @@ describe('context', () => { test("filters MCP tool descriptions and leaves built-in tools' alone", async ($, on) => { world(on) on('tool.describe', async ($, e) => ({ description: e.description })) - const mcp = await $.tool.describe({ tool: 'mcp__notes__search', description: 'Search notes about Teotihuacan', provider: { plugin: 'mcp:notes', tier: 'user' } } as never) + const mcp = await $.tool.describe({ + tool: 'mcp__notes__search', + description: 'Search notes about Teotihuacan', + provider: { plugin: 'mcp:notes', tier: 'user' }, + } as never) expect(mcp.description.includes('Teotihuacan')).toBe(false) - const bash = await $.tool.describe({ tool: 'Bash', description: 'Runs a command. Teotihuacan', provider: { plugin: 'engine', tier: 'core' } } as never) + const bash = await $.tool.describe({ + tool: 'Bash', + description: 'Runs a command. Teotihuacan', + provider: { plugin: 'engine', tier: 'core' }, + } as never) expect(bash.description).toBe('Runs a command. Teotihuacan') }) }) @@ -329,7 +359,9 @@ describe('packs', () => { }) test('a list can leave terms out of its pack and add its own', async ($, on) => { - world(on, usesPack({ exclude: ['quindle'], terms: ['Vexmoor'] }), { builtIn: { demo: pack(['Zorblax', 'Quindle']) } }) + world(on, usesPack({ exclude: ['quindle'], terms: ['Vexmoor'] }), { + builtIn: { demo: pack(['Zorblax', 'Quindle']) }, + }) const out = await seen($, on, 'Zorblax met Quindle in Vexmoor.') expect(out.includes('Quindle')).toBe(true) expect(/Zorblax|Vexmoor/.test(out)).toBe(false) @@ -346,14 +378,18 @@ describe('packs', () => { expect(await labelOf($)).toBe('topic-filter: blocking tool calls, run /topic-filter') await $.command.run({ command: 'topic-filter', args: '' } as never) - const problem = shown.logs.find(line => line.startsWith('Problem: ')) + const problem = shown.logs.find((line) => line.startsWith('Problem: ')) expect(problem).toMatch(/Did you mean "paleontology"\? Available: paleontology, sports\./) }) test('/topic-filter shows where each list gets its terms, to the person only', async ($, on) => { - const shown = world(on, JSON.stringify({ lists: [{ name: 'dinos', pack: 'demo', terms: ['a1', 'b2'], exclude: ['x'] }] }), { - builtIn: { demo: pack(['Zorblax', 'Quindle']) }, - }) + const shown = world( + on, + JSON.stringify({ lists: [{ name: 'dinos', pack: 'demo', terms: ['a1', 'b2'], exclude: ['x'] }] }), + { + builtIn: { demo: pack(['Zorblax', 'Quindle']) }, + }, + ) tools(on) await $.tool.call({ tool: 'Bash', command: 'ls' }) // The engine stamps origin and presentation; a test's call leaves them out. @@ -364,13 +400,22 @@ describe('packs', () => { }) test('/topic-filter packs lists every pack, what it covers, and which lists use it', async ($, on) => { - const shown = world(on, JSON.stringify({ lists: [{ name: 'mine-list', pack: 'demo' }, { name: 'gone', pack: 'nope' }] }), { - mine: { demo: pack(['Quindle']), mine: pack(['A1b', 'C2d'], ['h']) }, - builtIn: { - demo: pack(['Zorblax']), - sports: JSON.stringify({ description: 'Leagues and teams.', terms: ['Q1', 'Q2', 'Q3'], hints: [] }), + const shown = world( + on, + JSON.stringify({ + lists: [ + { name: 'mine-list', pack: 'demo' }, + { name: 'gone', pack: 'nope' }, + ], + }), + { + mine: { demo: pack(['Quindle']), mine: pack(['A1b', 'C2d'], ['h']) }, + builtIn: { + demo: pack(['Zorblax']), + sports: JSON.stringify({ description: 'Leagues and teams.', terms: ['Q1', 'Q2', 'Q3'], hints: [] }), + }, }, - }) + ) const r = await $.command.run({ command: 'topic-filter', args: 'packs' } as never) expect(r.text).toBeUndefined() const text = shown.logs.join('\n') @@ -395,7 +440,9 @@ describe('/topic-filter log', () => { const r = await $.command.run({ command: 'topic-filter', args: 'log' } as never) expect(r.text).toBeUndefined() const text = shown.logs.join('\n') - expect(text).toMatch(/ {2}Bash gh repo list\n {6}Teotihuacan -> [A-Z][a-z]+\d* \(x1\)\n {6}1 line dropped by "repos": secret-repo \(x1\)/) + expect(text).toMatch( + / {2}Bash gh repo list\n {6}Teotihuacan -> [A-Z][a-z]+\d* \(x1\)\n {6}1 line dropped by "repos": secret-repo \(x1\)/, + ) // Filtered twice, listed once. expect(text).toMatch(/ {2}Context block claudeMd\n {6}Teotihuacan -> \S+ \(x1\)\n/) expect(text.includes('Hidden\tprivate')).toBe(false) @@ -419,7 +466,7 @@ describe('/topic-filter log', () => { expect(text.includes('Context block claudeMd')).toBe(false) expect(text).toMatch(/ {2}Attachment \(file\)\n {6}Teotihuacan -> \S+ \(x1\)/) // All is well, so the warning-styled status line stays clear. - expect(shown.statuses.filter(s => s !== '')).toEqual([]) + expect(shown.statuses.filter((s) => s !== '')).toEqual([]) }) test('log clear empties it', async ($, on) => { @@ -436,7 +483,13 @@ describe('/topic-filter log', () => { /** The panes beneath the plugin: which are open, and every open and close asked for. */ function panes(on: On) { const state = { open: new Set(), opened: [] as string[], closed: [] as string[] } - on('ui.panes', () => ({ value: [...state.open].map(id => ({ id, title: id, isShown: true, isFocused: false, isPlaced: true })) }) as never) + on( + 'ui.panes', + () => + ({ + value: [...state.open].map((id) => ({ id, title: id, isShown: true, isFocused: false, isPlaced: true })), + }) as never, + ) on('ui.open', ($, e) => { state.open.add(e.id) state.opened.push(e.id) @@ -455,7 +508,13 @@ const inAgent = (input: T): T => ({ ...input, agentId: 'agent- /** The subagents beneath the plugin, as `$.agent.list()` answers. */ function agents(on: On) { - on('agent.list', () => ({ value: [{ id: 'agent-7', description: 'Review the diff', type: 'general-purpose', status: 'running' }] }) as never) + on( + 'agent.list', + () => + ({ + value: [{ id: 'agent-7', description: 'Review the diff', type: 'general-purpose', status: 'running' }], + }) as never, + ) } /** The Pane props a surface hands the hook; `agentId` puts that subagent's transcript in view. */ @@ -470,18 +529,24 @@ const paneProps = (agentId?: string) => ({ /** Every button label a mounted drawing shows, in order. */ async function buttonsOf(ui: { findAll: (q: { type: string }) => Promise<{ text: string }[]> }): Promise { - return (await ui.findAll({ type: 'Button' })).map(t => t.text) + return (await ui.findAll({ type: 'Button' })).map((t) => t.text) } /** The footer label topic-filter draws now, as plain text, with no other modes. */ async function labelOf($: Engine): Promise { - const ui = await $.ui.mount({ plugin: 'topic-filter', surface: 'terminal', component: 'SessionMode', requestId: 'label', props: { modes: [] } }) - return (await ui.findAll({ type: 'Text' })).map(n => n.text).find(s => s.startsWith('topic-filter:')) ?? '' + const ui = await $.ui.mount({ + plugin: 'topic-filter', + surface: 'terminal', + component: 'SessionMode', + requestId: 'label', + props: { modes: [] }, + }) + return (await ui.findAll({ type: 'Text' })).map((n) => n.text).find((s) => s.startsWith('topic-filter:')) ?? '' } /** Every line of text a mounted drawing shows, in order. */ async function textOf(ui: { findAll: (q: { type: string }) => Promise<{ text: string }[]> }): Promise { - return (await ui.findAll({ type: 'Text' })).map(t => t.text) + return (await ui.findAll({ type: 'Text' })).map((t) => t.text) } const SURFACES = ['terminal', 'desktop'] as const @@ -497,15 +562,21 @@ describe('footer label', () => { return h(Text, {}, e.props.modes.join(' & ')) as RenderElement }) await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) - const ui = await $.ui.mount({ plugin: 'topic-filter', surface, component: 'SessionMode', requestId: 'modes', props: { modes: ['focus'] } }) + const ui = await $.ui.mount({ + plugin: 'topic-filter', + surface, + component: 'SessionMode', + requestId: 'modes', + props: { modes: ['focus'] }, + }) expect(await textOf(ui)).toEqual(['focus & topic-filter: 2 hidden']) - expect(shown.statuses.filter(s => s !== '')).toEqual([]) + expect(shown.statuses.filter((s) => s !== '')).toEqual([]) await $.command.run(typed('off')) await ui.redraw() expect((await textOf(ui)).join('')).toContain('focus & topic-filter: paused') - expect((await ui.findAll({ type: 'Text' })).some(n => n.props?.color === 'warning')).toBe(true) - expect(shown.statuses.filter(s => s !== '')).toEqual([]) + expect((await ui.findAll({ type: 'Text' })).some((n) => n.props?.color === 'warning')).toBe(true) + expect(shown.statuses.filter((s) => s !== '')).toEqual([]) }) } }) @@ -587,18 +658,29 @@ describe('sidebar', () => { world(on) tools(on) await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) - const ui = await $.ui.mount({ plugin: 'topic-filter', surface, component: 'Pane', requestId: SIDEBAR_ID, props: paneProps() }) + const ui = await $.ui.mount({ + plugin: 'topic-filter', + surface, + component: 'Pane', + requestId: SIDEBAR_ID, + props: paneProps(), + }) const lines = await textOf(ui) - expect(lines.slice(0, 4)).toEqual([' 2 ', ' hidden this session', '1 word replaced, 1 line dropped', 'whole session']) + expect(lines.slice(0, 4)).toEqual([ + ' 2 ', + ' hidden this session', + '1 word replaced, 1 line dropped', + 'whole session', + ]) const rows = await buttonsOf(ui) expect(rows.slice(0, 2)).toEqual(['Lists', 'Feed']) expect(rows).toContain('● lists') - expect(rows.some(row => /^▾ places +$/.test(row))).toBe(true) - expect(rows.some(row => /^▸ Teotihuacan +$/.test(row))).toBe(true) - expect(lines.some(line => /^ -> [A-Z][a-z]+\d*$/.test(line))).toBe(true) + expect(rows.some((row) => /^▾ places +$/.test(row))).toBe(true) + expect(rows.some((row) => /^▸ Teotihuacan +$/.test(row))).toBe(true) + expect(lines.some((line) => /^ -> [A-Z][a-z]+\d*$/.test(line))).toBe(true) expect(lines).toContain(' line dropped') // Sources show only once a term is open. - expect(lines.some(line => line.includes('Bash gh repo list'))).toBe(false) + expect(lines.some((line) => line.includes('Bash gh repo list'))).toBe(false) expect(lines).toContain('Where it came from') expect(lines).toContain('Commands ') expect(lines.slice(-3)).toEqual(['2 terms', '2 lists', '1 source']) @@ -608,25 +690,31 @@ describe('sidebar', () => { world(on) tools(on) await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) - const ui = await $.ui.mount({ plugin: 'topic-filter', surface, component: 'Pane', requestId: SIDEBAR_ID, props: paneProps() }) + const ui = await $.ui.mount({ + plugin: 'topic-filter', + surface, + component: 'Pane', + requestId: SIDEBAR_ID, + props: paneProps(), + }) await ui.press({ key: 'depth:2' }) await ui.redraw() expect(await buttonsOf(ui)).toContain('● all') - expect((await textOf(ui)).filter(line => line === ' Bash gh repo list x1')).toHaveLength(2) + expect((await textOf(ui)).filter((line) => line === ' Bash gh repo list x1')).toHaveLength(2) await ui.press({ key: 'depth:0' }) await ui.redraw() const closed = await buttonsOf(ui) - expect(closed.some(row => row.includes('Teotihuacan'))).toBe(false) + expect(closed.some((row) => row.includes('Teotihuacan'))).toBe(false) // One list opened by hand: no stop is lit, and only that list shows its terms. await ui.press({ key: 'row:l/places' }) await ui.redraw() const one = await buttonsOf(ui) - expect(one.filter(row => row.startsWith('●'))).toEqual([]) - expect(one.some(row => row.includes('Teotihuacan'))).toBe(true) - expect(one.some(row => row.includes('secret-repo'))).toBe(false) + expect(one.filter((row) => row.startsWith('●'))).toEqual([]) + expect(one.some((row) => row.includes('Teotihuacan'))).toBe(true) + expect(one.some((row) => row.includes('secret-repo'))).toBe(false) }) test(`the feed lists each hit, newest first (${surface})`, async ($, on) => { @@ -637,7 +725,13 @@ describe('sidebar', () => { }) await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) await $.tool.call({ tool: 'Read', file_path: 'C:/notes/trip.md' }) - const ui = await $.ui.mount({ plugin: 'topic-filter', surface, component: 'Pane', requestId: SIDEBAR_ID, props: paneProps() }) + const ui = await $.ui.mount({ + plugin: 'topic-filter', + surface, + component: 'Pane', + requestId: SIDEBAR_ID, + props: paneProps(), + }) await ui.press({ key: 'tab:feed' }) await ui.redraw() const lines = await textOf(ui) @@ -661,14 +755,20 @@ describe('sidebar', () => { await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) await $.tool.call(inAgent({ tool: 'Bash', command: 'cat plan.md' })) - const ui = await $.ui.mount({ plugin: 'topic-filter', surface, component: 'Pane', requestId: SIDEBAR_ID, props: paneProps() }) + const ui = await $.ui.mount({ + plugin: 'topic-filter', + surface, + component: 'Pane', + requestId: SIDEBAR_ID, + props: paneProps(), + }) expect((await textOf(ui)).slice(-4)).toEqual(['2 terms', '2 lists', '2 sources', '1 subagent']) // The person opens the subagent's transcript: the same pane, drawn for that view. await ui.redraw(paneProps('agent-7')) const one = await textOf(ui) expect(one.slice(0, 4)).toEqual([' 1 ', ' hidden in this subagent', '1 line dropped', 'subagent Review the diff']) - expect(one.some(line => line.includes('Teotihuacan'))).toBe(false) + expect(one.some((line) => line.includes('Teotihuacan'))).toBe(false) expect(one.slice(-3)).toEqual(['1 term', '1 list', '1 source']) }) @@ -676,7 +776,14 @@ describe('sidebar', () => { world(on) // A pane of another id reaches the hooks beneath, which draw the view // with a summary of their own: the settings cannot be changed in a test. - const hits = Array.from({ length: 30 }, (_, i) => ({ term: `Term${i}`, placeholder: `Name${i}`, list: 'pack demo', replaced: 1, dropped: 0, sources: [{ label: 'Read notes.md', n: 1 }] })) + const hits = Array.from({ length: 30 }, (_, i) => ({ + term: `Term${i}`, + placeholder: `Name${i}`, + list: 'pack demo', + replaced: 1, + dropped: 0, + sources: [{ label: 'Read notes.md', n: 1 }], + })) const summary: Summary = { replaced: 30, dropped: 0, @@ -687,31 +794,48 @@ describe('sidebar', () => { kinds: [{ kind: 'files', n: 30 }], sources: 1, lastAt: 0, - feed: [{ at: 0, label: 'Read notes.md', replaced: 30, dropped: 0, hits: hits.map(hit => ({ list: hit.list, term: hit.term, n: 1 })) }], + feed: [ + { + at: 0, + label: 'Read notes.md', + replaced: 30, + dropped: 0, + hits: hits.map((hit) => ({ list: hit.list, term: hit.term, n: 1 })), + }, + ], } let countsOnly = false let tab: 'lists' | 'feed' = 'lists' on('ui.render', { component: 'Pane' }, async ($, e) => { const { Box, Text, Button } = await $.ui.resolve(e) const view = { tab, open: new Set() } - return sidebarView({ Box, Text, Button }, { summary, countsOnly, view, columns: 48, rows: 30, onPress: () => {} }) + return sidebarView( + { Box, Text, Button }, + { summary, countsOnly, view, columns: 48, rows: 30, onPress: () => {} }, + ) }) - const ui = await $.ui.mount({ plugin: 'topic-filter', surface, component: 'Pane', requestId: 'other', props: paneProps() }) + const ui = await $.ui.mount({ + plugin: 'topic-filter', + surface, + component: 'Pane', + requestId: 'other', + props: paneProps(), + }) const collapsed = await buttonsOf(ui) expect(collapsed).toContain('● none') - expect(collapsed.some(row => row.includes('Term1'))).toBe(false) + expect(collapsed.some((row) => row.includes('Term1'))).toBe(false) countsOnly = true await ui.redraw() const bare = [...(await textOf(ui)), ...(await buttonsOf(ui))] - expect(bare.some(line => /Term|Name|notes/.test(line))).toBe(false) - expect(bare.some(line => line.includes('pack demo'))).toBe(true) + expect(bare.some((line) => /Term|Name|notes/.test(line))).toBe(false) + expect(bare.some((line) => line.includes('pack demo'))).toBe(true) tab = 'feed' await ui.redraw() const feed = [...(await textOf(ui)), ...(await buttonsOf(ui))] - expect(feed.some(line => /Term|Name|notes/.test(line))).toBe(false) + expect(feed.some((line) => /Term|Name|notes/.test(line))).toBe(false) expect(feed).toContain('30 terms') }) } @@ -730,7 +854,7 @@ describe('pause', () => { const off = await $.command.run(typed('off')) expect(shown.logs[0]).toBe('topic-filter paused: nothing is hidden until /topic-filter on.') expect(off.context?.[0]).toMatch(/^The user paused topic-filter/) - expect(shown.statuses.filter(s => s !== '')).toEqual([]) + expect(shown.statuses.filter((s) => s !== '')).toEqual([]) const open = await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) expect((open.result as { stdout: string }).stdout).toBe(GH_LIST) @@ -743,7 +867,10 @@ describe('pause', () => { expect(back.context?.[0]).toMatch(/^The user turned topic-filter back on/) const hidden = await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) expect((hidden.result as { stdout: string }).stdout.includes('secret-repo')).toBe(false) - const refused = await $.tool.call({ tool: 'Bash', command: `grep ${placeholderIn((hidden.result as { stdout: string }).stdout)} notes.md` }) + const refused = await $.tool.call({ + tool: 'Bash', + command: `grep ${placeholderIn((hidden.result as { stdout: string }).stdout)} notes.md`, + }) expect(refused.deny).toMatch(/is a placeholder for a hidden item/) expect(ran).toEqual(['Bash', 'Bash', 'Bash', 'Bash']) }) @@ -751,22 +878,37 @@ describe('pause', () => { test('pause and stop are off too, and resume and start are on', async ($, on) => { const shown = world(on) tools(on) - for (const [arg, log] of [['pause', /^topic-filter paused/], ['resume', /^Filter on/], ['stop', /^topic-filter paused/], ['start', /^Filter on/]] as const) { + for (const [arg, log] of [ + ['pause', /^topic-filter paused/], + ['resume', /^Filter on/], + ['stop', /^topic-filter paused/], + ['start', /^Filter on/], + ] as const) { shown.logs.length = 0 await $.command.run(typed(arg)) expect(shown.logs.join('\n')).toMatch(log) - expect(shown.statuses.filter(s => s !== '')).toEqual([]) + expect(shown.statuses.filter((s) => s !== '')).toEqual([]) } }) test('only the person can pause: a command from anywhere else leaves the filter on', async ($, on) => { const shown = world(on) tools(on) - for (const origin of [undefined, { kind: 'plugin', name: 'other' }, { kind: 'task-notification' }, { kind: 'peer' }, { kind: 'bridge' }]) { - const r = await $.command.run({ command: 'topic-filter', args: 'off', ...(origin === undefined ? {} : { origin }) } as never) + for (const origin of [ + undefined, + { kind: 'plugin', name: 'other' }, + { kind: 'task-notification' }, + { kind: 'peer' }, + { kind: 'bridge' }, + ]) { + const r = await $.command.run({ + command: 'topic-filter', + args: 'off', + ...(origin === undefined ? {} : { origin }), + } as never) expect(r.context).toBeUndefined() } - expect(shown.logs.filter(line => line.startsWith('Only you can pause'))).toHaveLength(5) + expect(shown.logs.filter((line) => line.startsWith('Only you can pause'))).toHaveLength(5) const r = await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) expect((r.result as { stdout: string }).stdout.includes('secret-repo')).toBe(false) }) @@ -788,7 +930,10 @@ describe('pause', () => { on('tool.call', async ($, e) => { ran.push(e.tool) if (e.tool === 'Read') { - return { result: { type: 'text', file: { filePath: FILE, content, numLines: 3, startLine: 1, totalLines: 3 } }, text: content } as never + return { + result: { type: 'text', file: { filePath: FILE, content, numLines: 3, startLine: 1, totalLines: 3 } }, + text: content, + } as never } return { result: { filePath: FILE }, text: 'written' } as never }) @@ -823,7 +968,10 @@ describe('pause', () => { on('tool.call', async ($, e) => { ran.push(e.tool) if (e.tool === 'Read') { - return { result: { type: 'text', file: { filePath: FILE, content, numLines: 2, startLine: 1, totalLines: 5000 } }, text: content } as never + return { + result: { type: 'text', file: { filePath: FILE, content, numLines: 2, startLine: 1, totalLines: 5000 } }, + text: content, + } as never } return { result: { filePath: FILE }, text: 'written' } as never }) @@ -851,7 +999,13 @@ describe('pause', () => { tools(on) await $.tool.call({ tool: 'Bash', command: 'gh repo list' }) await $.command.run(typed('off')) - const ui = await $.ui.mount({ plugin: 'topic-filter', surface, component: 'Pane', requestId: SIDEBAR_ID, props: paneProps() }) + const ui = await $.ui.mount({ + plugin: 'topic-filter', + surface, + component: 'Pane', + requestId: SIDEBAR_ID, + props: paneProps(), + }) const lines = await textOf(ui) expect(lines[0]).toMatch(/^ PAUSED: nothing is hidden\. \/topic-filter on +$/) expect(lines.slice(1, 3)).toEqual([' 2 ', ' hidden this session']) From b68e566ba74c14d0e35e93c65815a48542729680 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:46:06 +0000 Subject: [PATCH 2/2] ci: check formatting and lint with bun canary Adds a job that runs bun fmt --check and bun lint --max-warnings 0 on bun canary (neither is in a stable release yet), turns on eqeqeq (smart), and clears the warnings the linter found. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/test.yml | 12 ++++++++++++ tests/register.test.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab6830d..b3b36dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,3 +56,15 @@ jobs: with: src: tools/packs - run: uv run pytest -q + + format-and-lint: + name: bun fmt and lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + # bun fmt and bun lint are canary-only until they reach a stable release. + - uses: oven-sh/setup-bun@v2 + with: + bun-version: canary + - run: bun fmt --check . + - run: bun lint --max-warnings 0 --format github diff --git a/tests/register.test.ts b/tests/register.test.ts index 822db04..c326509 100644 --- a/tests/register.test.ts +++ b/tests/register.test.ts @@ -924,7 +924,7 @@ describe('pause', () => { test('a file read whole while paused may be overwritten; one read in part may not', async ($, on) => { world(on) - const FILE = 'C:\work\notes.md' + const FILE = 'C:\\work\\notes.md' const content = '# Notes\nsecret-repo has the photos.\nGroceries\n' const ran: string[] = [] on('tool.call', async ($, e) => {