SLOPSHOPPER

Virtual Garden

A mindful ASCII garden above the prompt that grows with green AI habits: prompt caching, lean context and /compact.

bandtoastprocess
A shopper browsing a rack in a slop shop
README

Virtual Garden 🌱

A small plant that grows when you use AI efficiently.

Every request to a model re-reads the whole conversation. How much compute, energy and water that costs depends a lot on two habits nobody sees:

  • Prompt caching. A prompt read from cache costs a fraction of a fresh one. Long, steady sessions cache well; restarts, model switches and long pauses don't.
  • Context size. A 150k-token conversation is re-read on every call. Pruning it with /compact, or starting a new session for a new task, makes every later call cheaper.

Virtual Garden turns those habits into something you can see: a plant above the prompt that gets sun from caching and fresh soil from a lean context, and grows with every answered turn. It rewards and never scolds. A careless turn only slows the plant down. Nothing dies, and there are no warnings.

It is the companion of Friendly Reminder, which shows the water and energy behind each response. Friendly Reminder shows the cost; Virtual Garden makes lowering it a habit.

🎍 Bonsai · Young plant (Lv 4/6) · 120/800 XP · ☀️ 92% · 🪴 Soil 81%

It works in Claude Code and Google Antigravity (agy), and both tend the same garden. It speaks English and Portuguese, needs only python3 3.8+, and has no dependencies.

Contents

How you use it

You don't do anything special: you work as usual and the garden reacts after each response.

flowchart LR
    A([You send a prompt]) --> B[The model answers]
    B --> C{"How did the turn go?"}
    C -->|"most of the prompt<br/>came from cache"| D["☀️ Sun rises<br/>+XP bonus"]
    C -->|"context is small<br/>(under 40k tokens)"| E["🪴 Soil recovers<br/>+XP bonus"]
    C -->|"context is large<br/>(over 40k tokens)"| F["🪴 Soil dries"]
    C -->|"you ran /compact<br/>before this turn"| G["🪴 Soil renewed<br/>+30 XP · ✂️ badge"]
    D & E & F & G --> H["🌱 The plant grows<br/>(at half speed if sun or soil is under 40%)"]
    H --> I["The band above the prompt shows<br/>the plant, level-ups, unlocks and a tip"]
    I --> J{"What does the garden ask for?"}
    J -->|"dry soil"| K(["/compact or a new session"])
    J -->|"weak sun"| L(["keep the session going,<br/>stick with one model"])
    J -->|"all good"| A
    K --> A
    L --> A

Over days and weeks, the plant moves through six stages and new species unlock as you build the habits:

flowchart LR
    S1[Seed] -->|100 XP| S2[Sprout] -->|300| S3[Sapling] -->|700| S4[Young plant] -->|1,500| S5[Blooming] -->|3,000| S6[Majestic]
    S3 -.->|"unlocks"| C["🌵 Cactus"]
    SUN["5 turns in a row<br/>with cache at 75%+"] -.->|"unlocks"| K["🌸 Sakura"]
    PR["a manual /compact, or 10 turns<br/>in a row under 40k context"] -.->|"unlocks"| I["🌼 Golden Ipê"]

What you see

After each response, Claude Code shows one line above the prompt, with three buttons:

ButtonWhat it does
View garden / CollapseSwitches between the line and the full garden
OptionsPicks the format, the plant and the language
HideHides the band until the session ends

The full garden:

┌──────────────────────────────────────────────┐
│ 🎍 Bonsai · Young plant               Lv 4/6 │
├──────────────────────────────────────────────┤
│                   .-~~~-.                    │
│                  ( ~ ~ ~ )                   │
│                   '-.|.-'.-.                 │
│                      |__(~ )                 │
│                      |   '-'                 │
│                      |                       │
│                  \=======/                   │
│                   \_____/                    │
├──────────────────────────────────────────────┤
│ Growth [██░░░░░░░░░░░░] 120/800 XP           │
│ ☀️ Sun 92%   🪴 Soil 81%   Turns 57          │
│ Garden 🎍 4/6  🌵 1/6  🔒  🔒                │
│ Badges ✂️ Pruner                             │
├──────────────────────────────────────────────┤
│ 💡 Ask for the change, not the whole file    │
│    back: fewer output tokens, less energy.   │
└──────────────────────────────────────────────┘
  • Header: the plant you're tending, its stage, and its level out of 6.
  • Growth: XP within the current stage.
  • Sun and soil: the two health bars, from 0 to 100%.
  • Garden: every species' level, with 🔒 for the ones still locked.
  • Badges: the badges you've earned.
  • News and tip: level-ups, unlocks and badges from this turn, then a tip about whatever the garden needs most.

Install

Claude Code

/plugin marketplace add leon-lourenco/green-ai
/plugin install virtual-garden@green-ai

Then run /reload-plugins, or restart Claude Code. The band appears after the next response. You can also just ask Claude ("show my garden", "switch to the cactus"): the plugin's garden skill runs the script for you.

Google Antigravity (agy)

Register the hook once, from the plugin folder:

./scripts/virtual-garden-agy --install-hook

This adds a Stop hook with the script's absolute path to ~/.gemini/config/hooks.json, and keeps any hooks already there. Inside this repository, .agents/hooks.json does the same for the workspace.

After each turn, the hook grows the garden without adding anything to the conversation. Antigravity hooks can't show text, so you look at the garden yourself:

./scripts/virtual-garden-agy --text    # one line
./scripts/virtual-garden-agy --full    # the whole garden

Inside this repository, the workspace skill .agents/skills/virtual-garden lets the agent run it for you.

How it grows

Each answered turn is applied once: redrawing the band, changing a setting or running the script again never counts it twice.

Sun ☀️ — prompt caching

The share of the turn's prompt that was read from cache. Tokens written to the cache count as not cached.

Cache hit rateXP bonusSun
90% or more+20+8
75% or more+10+4
40% or more+5no change
less than 40%none−6, never below 20%

The first turn of a session is not judged, since nothing is cached yet; it earns +5. A break of an hour or more restores 10% of sun.

Soil 🪴 — context size

How full the context window was on the turn's last call.

ContextSoilNever below
over 120k tokens−1520%
over 80k tokens−840%
over 40k tokens−360%
40k or less+3, and +5 XP

/compact or a new session brings the soil back to 100%. Only a /compact you run yourself earns the +30 XP and the ✂️ Pruner badge; an automatic compaction renews the soil and nothing more. agy writes no compaction marker, so there the soil renews with a new conversation.

XP 🌱

Every turn earns 10 XP, plus the sun and soil bonuses above. While sun or soil is below 40%, the turn's XP is halved and the garden shows 🐌.

StageStarts at
Seed0 XP
Sprout100
Sapling300
Young plant700
Blooming1,500
Majestic3,000

Collection

Each species keeps its own growth, so switching plants never loses any.

SpeciesUnlocked byTeaches
🎍Bonsaifrom the start
🌵Cactus (Mandacaru)growing any plant to Saplingconsistency
🌸Sakura (Cerejeira)5 turns in a row with the cache at 75%+caching
🌼Golden Ipê (Ipê-amarelo)a manual /compact, or 10 turns in a row under 40k tokens of contexta lean context

Badges

BadgeEarned by
✂️ Prunera manual /compact
☀️ Sunny5 turns in a row with the cache at 75%+
🍃 Light10 turns in a row under 40k tokens of context
🏆 Mastera plant reaching Majestic
🧺 Collectorevery species unlocked

Settings

Set them in the band's Options, in /config, or with /plugin configure virtual-garden@green-ai.

SettingValuesDefault
formatshort (one line), full (the whole garden), off (keeps growing, shows nothing)short
speciescurrent (the plant you're growing), bonsai, cactus, sakura, ipecurrent
languageauto (Claude Code's language setting, then the system locale), en, ptauto

If you pick a locked species, the band tells you how to unlock it, and you keep tending the current one. The agy script reads the same settings from CLAUDE_PLUGIN_OPTION_FORMAT, CLAUDE_PLUGIN_OPTION_SPECIES and CLAUDE_PLUGIN_OPTION_LANGUAGE.

Commands

Both scripts run with any python3 3.8+ and never read stdin when given a flag, so they are safe to run from an agent's shell.

CommandWhat it does
scripts/virtual-garden --textThe one-line summary (Claude Code side: reads the garden, changes nothing)
scripts/virtual-garden --fullThe whole garden
scripts/virtual-garden --switch SPECIESTend another unlocked species
scripts/virtual-garden-agy --text / --fullSame, after applying your latest agy turn if the hook hasn't
scripts/virtual-garden-agy --transcript PATHUse a specific agy conversation
scripts/virtual-garden-agy --install-hookRegister the agy Stop hook

Where the garden lives

One file, garden-state.json, in ~/.claude/plugins/data/virtual-garden-green-ai/. VIRTUAL_GARDEN_DATA or CLAUDE_PLUGIN_DATA override the folder. Claude Code, agy and manual runs all read and write it under a lock, so sessions running at the same time never lose each other's growth. A 0.1.0 garden is read and carried over.

The plugin reads your local transcripts to find turns, token counts and compaction markers. The garden file keeps only counters and turn ids, nothing you wrote. It makes no network calls and never touches credentials.

Troubleshooting

SymptomWhat to check
No band after a responseRun /reload-plugins, and check format isn't off and that you didn't press Hide this session.
The garden didn't changeLook for error.log in the garden folder: the hook logs every failure there instead of interrupting you.
Wrong languagelanguage is auto and follows Claude Code's language setting, then the system locale. Set en or pt.
agy turns don't countRun ./scripts/virtual-garden-agy --install-hook and check ~/.gemini/config/hooks.json.

License

MIT

Source 2 files
hooks/register.tsx 195 lines
1// Virtual Garden as a band above the prompt.
2//
3// After each response the Python package (scripts/virtual-garden) reads the
4// transcript, grows the garden with the turn and returns two views of it: a
5// one-line summary and the full garden. This module keeps both and draws the
6// one the format asks for; "View garden" flips between them without running
7// Python again.
8//
9// Options opens pickers for the plugin's settings. A pick is written to the
10// /config row (which reloads this module with the new options) and the last
11// response is drawn again. Python applies a turn only once, so drawing the
12// same turn again never grows the plant twice.
13
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register } from 'claude-code'
16
17import type { GardenView } from '../types'
18
19const last = atom({ plugin: 'virtual-garden', key: 'last' } as const, null)
20const lastStop = atom({ plugin: 'virtual-garden', key: 'lastStop' } as const, null)
21const view = atom({ plugin: 'virtual-garden', key: 'view' } as const, null)
22const isHidden = atom({ plugin: 'virtual-garden', key: 'isHidden' } as const, false)
23const isEditing = atom({ plugin: 'virtual-garden', key: 'isEditing' } as const, false)
24
25type Words = Record<string, string>
26
27const HIDE: Words = { en: 'Hide', pt: 'Ocultar' }
28const OPTIONS: Words = { en: 'Options', pt: 'Opções' }
29const DONE: Words = { en: 'Done', pt: 'Concluir' }
30const SHOW_FULL: Words = { en: 'View garden', pt: 'Ver jardim' }
31const SHOW_SUMMARY: Words = { en: 'Collapse', pt: 'Recolher' }
32
33// The userConfig fields, in the order they are drawn.
34const CHOICES: { field: string; label: Words; options: { value: string; label: Words }[] }[] = [
35  {
36    field: 'format',
37    label: { en: 'Format', pt: 'Formato' },
38    options: [
39      { value: 'short', label: { en: 'one line', pt: 'uma linha' } },
40      { value: 'full', label: { en: 'full garden', pt: 'jardim completo' } },
41      { value: 'off', label: { en: 'off (keeps growing)', pt: 'desligado (continua crescendo)' } },
42    ],
43  },
44  {
45    field: 'species',
46    label: { en: 'Plant', pt: 'Planta' },
47    options: [
48      { value: 'current', label: { en: 'keep the current one', pt: 'manter a atual' } },
49      { value: 'bonsai', label: { en: '🎍 Bonsai', pt: '🎍 Bonsai' } },
50      { value: 'cactus', label: { en: '🌵 Cactus', pt: '🌵 Mandacaru' } },
51      { value: 'sakura', label: { en: '🌸 Sakura', pt: '🌸 Cerejeira' } },
52      { value: 'ipe', label: { en: '🌼 Golden Ipê', pt: '🌼 Ipê-amarelo' } },
53    ],
54  },
55  {
56    field: 'language',
57    label: { en: 'Language', pt: 'Idioma' },
58    options: [
59      { value: 'auto', label: { en: 'automatic', pt: 'automático' } },
60      { value: 'en', label: { en: 'English', pt: 'English' } },
61      { value: 'pt', label: { en: 'Português', pt: 'Português' } },
62    ],
63  },
64]
65
66const say = (words: Words, language: string) => words[language] ?? words.en!
67
68// The Python side reads its settings the way a command hook would.
69const toEnv = (options: Record<string, unknown>) => {
70  const env: Record<string, string> = {}
71  for (const [key, value] of Object.entries(options)) {
72    if (value !== undefined && value !== null) {
73      env[`CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}`] = String(value)
74    }
75  }
76  return env
77}
78
79async function tend($: EngineInterface, stop: unknown, env: Record<string, string>): Promise<GardenView | null> {
80  const { stdout } = await $.process.run(['python3', `${$.plugin.root}/scripts/virtual-garden`], {
81    stdin: JSON.stringify(stop),
82    env,
83    timeoutMs: 15000,
84  })
85  if (!stdout.trim()) return null
86  const out = JSON.parse(stdout) as Partial<GardenView>
87  return out.summary && out.full ? { summary: out.summary, full: out.full, language: out.language ?? 'en' } : null
88}
89
90async function choose($: EngineInterface, current: Record<string, unknown>, field: string, value: string) {
91  const rows = await $.config.list()
92  const row =
93    rows.find(r => r.key === `virtual-garden.${field}`) ??
94    rows.find(r => r.key.startsWith('virtual-garden') && r.key.endsWith(`.${field}`))
95  if (!row) return
96  const { deny } = await $.config.set({ key: row.key, value })
97  if (deny) {
98    $.ui.toast(deny)
99    return
100  }
101  if (field === 'format') await update($, view, () => null)
102  const stop = await read($, lastStop)
103  if (stop === null) return
104  try {
105    const garden = await tend($, stop, toEnv({ ...current, [field]: value }))
106    await update($, last, () => garden)
107  } catch {
108    // keep the band as it was
109  }
110}
111
112export const register: Register = (on, options) => {
113  const current = options as Record<string, unknown>
114
115  on('classic.Stop', async ($, e, next) => {
116    const result = await next(e)
117    try {
118      await update($, lastStop, () => e as unknown as Record<string, unknown>)
119      const garden = await tend($, e, toEnv(current))
120      await update($, last, () => garden)
121    } catch {
122      // the garden must never get in the way of the session
123    }
124    return result
125  }).catch(($, e, next) => next(e))
126
127  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
128    if (e.props.hasSurvey || e.props.isWorking || (await read($, isHidden))) {
129      return next(e)
130    }
131    const garden = await read($, last)
132    const editing = await read($, isEditing)
133    const off = current.format === 'off'
134    // With format "off" the garden is not drawn, but an open panel stays so the pick can be undone.
135    if ((garden === null || off) && !editing) {
136      return next(e)
137    }
138
139    const elements = $.ui.resolve(e)
140    const { Box, Button, Text } = elements
141    // The mobile app draws no Select yet, so it gets no Options button.
142    const Select = 'Select' in elements ? elements.Select : undefined
143    const language = garden?.language ?? (current.language === 'pt' ? 'pt' : 'en')
144    const shown = (await read($, view)) ?? (current.format === 'full' ? 'full' : 'summary')
145    const lines = garden && !off ? garden[shown] : []
146
147    return (
148      <Box flexDirection="column">
149        {lines.map((line, i) => (
150          <Text key={`line-${i}`} dimColor={shown === 'summary' && i > 0}>
151            {line}
152          </Text>
153        ))}
154        {editing && Select && (
155          <Box key="options" flexDirection="column" marginTop={1}>
156            {CHOICES.map(choice => (
157              <Select
158                key={`option-${choice.field}`}
159                label={`${say(choice.label, language)}: `}
160                value={String(current[choice.field] ?? choice.options[0]!.value)}
161                options={choice.options.map(o => ({ value: o.value, label: say(o.label, language) }))}
162                onSelect={(value: string) => choose($, current, choice.field, value)}
163              />
164            ))}
165          </Box>
166        )}
167        <Box key="actions" flexDirection="row" gap={1}>
168          {garden && !off && (
169            <Button
170              key="view"
171              label={say(shown === 'full' ? SHOW_SUMMARY : SHOW_FULL, language)}
172              onPress={() => update($, view, () => (shown === 'full' ? 'summary' : 'full'))}
173            />
174          )}
175          {Select && (
176            <Button
177              key="options"
178              label={say(editing ? DONE : OPTIONS, language)}
179              onPress={() => update($, isEditing, open => !open)}
180            />
181          )}
182          <Button
183            key="hide"
184            label={say(HIDE, language)}
185            onPress={async () => {
186              await update($, isEditing, () => false)
187              await update($, isHidden, () => true)
188            }}
189          />
190        </Box>
191      </Box>
192    )
193  })
194}
195
types/index.d.ts 16 lines
1export type GardenView = { summary: string[]; full: string[]; language: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'virtual-garden': {
6      last: GardenView | null
7      /** The last Stop payload, to draw that response again after a setting changes. */
8      lastStop: Record<string, unknown> | null
9      /** The view picked with "View garden"; null follows the format setting. */
10      view: 'summary' | 'full' | null
11      isHidden: boolean
12      isEditing: boolean
13    }
14  }
15}
16