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

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:
/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.
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ê"]
After each response, Claude Code shows one line above the prompt, with three buttons:
| Button | What it does |
|---|---|
| View garden / Collapse | Switches between the line and the full garden |
| Options | Picks the format, the plant and the language |
| Hide | Hides 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. │
└──────────────────────────────────────────────┘
/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.
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.
Each answered turn is applied once: redrawing the band, changing a setting or running the script again never counts it twice.
The share of the turn's prompt that was read from cache. Tokens written to the cache count as not cached.
| Cache hit rate | XP bonus | Sun |
|---|---|---|
| 90% or more | +20 | +8 |
| 75% or more | +10 | +4 |
| 40% or more | +5 | no 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.
How full the context window was on the turn's last call.
| Context | Soil | Never below |
|---|---|---|
| over 120k tokens | −15 | 20% |
| over 80k tokens | −8 | 40% |
| over 40k tokens | −3 | 60% |
| 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.
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 🐌.
| Stage | Starts at |
|---|---|
| Seed | 0 XP |
| Sprout | 100 |
| Sapling | 300 |
| Young plant | 700 |
| Blooming | 1,500 |
| Majestic | 3,000 |
Each species keeps its own growth, so switching plants never loses any.
| Species | Unlocked by | Teaches | |
|---|---|---|---|
| 🎍 | Bonsai | from the start | |
| 🌵 | Cactus (Mandacaru) | growing any plant to Sapling | consistency |
| 🌸 | 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 context | a lean context |
| Badge | Earned by |
|---|---|
| ✂️ Pruner | a manual /compact |
| ☀️ Sunny | 5 turns in a row with the cache at 75%+ |
| 🍃 Light | 10 turns in a row under 40k tokens of context |
| 🏆 Master | a plant reaching Majestic |
| 🧺 Collector | every species unlocked |
Set them in the band's Options, in /config, or with /plugin configure virtual-garden@green-ai.
| Setting | Values | Default |
|---|---|---|
format | short (one line), full (the whole garden), off (keeps growing, shows nothing) | short |
species | current (the plant you're growing), bonsai, cactus, sakura, ipe | current |
language | auto (Claude Code's language setting, then the system locale), en, pt | auto |
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.
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.
| Command | What it does |
|---|---|
scripts/virtual-garden --text | The one-line summary (Claude Code side: reads the garden, changes nothing) |
scripts/virtual-garden --full | The whole garden |
scripts/virtual-garden --switch SPECIES | Tend another unlocked species |
scripts/virtual-garden-agy --text / --full | Same, after applying your latest agy turn if the hook hasn't |
scripts/virtual-garden-agy --transcript PATH | Use a specific agy conversation |
scripts/virtual-garden-agy --install-hook | Register the agy Stop hook |
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.
| Symptom | What to check |
|---|---|
| No band after a response | Run /reload-plugins, and check format isn't off and that you didn't press Hide this session. |
| The garden didn't change | Look for error.log in the garden folder: the hook logs every failure there instead of interrupting you. |
| Wrong language | language is auto and follows Claude Code's language setting, then the system locale. Set en or pt. |
| agy turns don't count | Run ./scripts/virtual-garden-agy --install-hook and check ~/.gemini/config/hooks.json. |
MIT
hooks/register.tsx 195 lines1// 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}
195types/index.d.ts 16 lines1export 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