A per-session spend budget (% of the 5h limit or $): notice at 80%, Continue anyway / Stop at 100% on a new prompt

Sets a spending budget for the current session, warns you at 80%, and stops to ask before you go past 100%.
/plugin marketplace add partalemre/MODZ
/plugin install session-budget@modz
Or in one step: /plugin install session-budget --marketplace partalemre/MODZ.
Budget 80% · $4.00 / $5.00 · full in ≈25m at this pace, in amber. The desktop app draws it as a picture; the terminal as ━━━━━━━━┃─ plus text. It closes after 20 seconds or with the close mark.Session budget, Budget is full, spent against the budget). The options are Stop (recommended, first) and Continue anyway. Stop drops the prompt and shows a red row: Stopped: budget is full · $5.10 / $5.00 · raise it with /budget. The prompt you typed is not lost: if the box is empty it is put back, and after /budget one Enter sends it. Continue anyway raises the budget by one step ($1, or a fifth of a percent budget) and lets the prompt through; when that step fills, it asks again.Only prompts you type are held (terminal, Remote Control, or the desktop app's Code tab). Prompts from plugins, peers, schedules and task notifications always go through. If nobody can answer (a -p run) or the question is dismissed, the prompt goes through too. A budget never blocks a turn it can't ask about.
| Command | Effect | |
|---|---|---|
/budget 20% (or /budget 20) | A budget of 20 points of the 5-hour rate limit, counted from now | |
/budget $5 (or 5usd) | A budget of about $5 of session cost, counted from now | |
/budget or /budget status | Shows the spend so far | |
/budget off | Clears the budget | |
| `/session-budget [on\ | off]` | Turns the warning and the check on or off; the choice is saved across sessions |
There are no plugin options.
$ budget.Uses no model tokens: it reads the same usage figures the status line shows.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
hooks/register.tsx 211 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PromptOrigin, Register } from 'claude-code'
3
4import { WARN_RATIO, advance, describe, parseCommand, raise, ratioOf, readingOf, spendText, startBudget, stepText } from './budget'
5import { fullCard, isLive, noticeAbove, stoppedRow, warnRow } from './notice'
6import { setLang } from './lang'
7import { pinned } from './pin'
8import { words } from './words'
9import type { Budget, BudgetNotice } from '../types'
10
11const BUDGET = 'budget'
12const TOGGLE = 'session-budget'
13const STORE_KEY = 'session-budget.enabled'
14const PRIORITY = 1
15const DISMISS_AFTER_MS = 20_000
16// After "Dur" the dropped prompt goes back in the box once the dialog has given it back.
17const RESTORE_AFTER_MS = 300
18// Prompts a person typed; a plugin's, a peer's or a scheduled one is never held back.
19const TYPED_ORIGINS = new Set<PromptOrigin['kind']>(['composer', 'bridge'])
20
21const budgetAtom = atom({ plugin: 'session-budget', key: 'budget' } as const, null)
22const readingAtom = atom({ plugin: 'session-budget', key: 'reading' } as const, { percent: null, resetsAt: null, usd: null })
23const noticeAtom = atom({ plugin: 'session-budget', key: 'notice' } as const, null)
24const enabledAtom = atom({ plugin: 'session-budget', key: 'isEnabled' } as const, true)
25// The shared notice line: every one of these outranks this mod's line.
26const cacheTaxNotice = { plugin: 'cache-tax', key: 'notice' } as const
27const knowNotice = { plugin: 'you-should-know', key: 'notice' } as const
28const reflectNotice = { plugin: 'reflect', key: 'notice' } as const
29
30const run = { nextId: 0 }
31
32async function notify($: EngineInterface, kind: BudgetNotice['kind'], text: string): Promise<void> {
33 run.nextId += 1
34 const id = run.nextId
35 const expiresAt = (await $.clock.now()) + DISMISS_AFTER_MS
36 await update($, noticeAtom, () => ({ id, kind, text, isAlert: kind === 'stopped', priority: PRIORITY, expiresAt }))
37 $.clock.after(DISMISS_AFTER_MS, () => {
38 void update($, noticeAtom, current => (current !== null && current.id === id ? null : current))
39 })
40}
41
42async function checkWarn($: EngineInterface, budget: Budget): Promise<void> {
43 if (budget.hasWarned || ratioOf(budget) < WARN_RATIO || ratioOf(budget) >= 1) return
44 await update($, budgetAtom, current => (current === null ? null : { ...current, hasWarned: true }))
45 await notify($, 'warn', words().warned(describe(budget)))
46}
47
48async function setBudget($: EngineInterface, args: string): Promise<string> {
49 const command = parseCommand(args)
50 if (command.kind === 'error') return command.text
51 if (command.kind === 'off') {
52 await update($, budgetAtom, () => null)
53
54 return words().removed
55 }
56 if (command.kind === 'status') {
57 const budget = await read($, budgetAtom)
58
59 return budget === null ? words().none : words().status(describe(budget))
60 }
61 const reading = await read($, readingAtom)
62 const { kind, limit } = command.budget
63 const hasData = kind === 'percent' ? reading.percent !== null : reading.usd !== null
64 const setAt = await $.clock.now()
65 await update($, budgetAtom, () => ({ ...startBudget(kind, limit, reading), setAt }))
66 const unit = kind === 'percent' ? words().percentUnit(limit) : `$${limit}`
67
68 return words().set(unit, hasData ? '' : words().waitForData)
69}
70
71// "Dur" first, as the main way; "Devam et (+$1)" raises the budget a step and lets the prompt in.
72async function confirm($: EngineInterface, budget: Budget): Promise<boolean> {
73 const says = words()
74 const goOn = `${says.goOn} (${stepText(budget)})`
75 let answer: string | null = null
76 try {
77 // The question's chip is its header; the card above it is drawn for that header alone.
78 answer = await $.ui.ask(says.question(describe(budget)), { options: [says.stop, goOn], header: says.header })
79 } catch {
80 // Nobody to ask (a -p run) or dismissed: a budget never blocks a turn it cannot ask about.
81 $.ui.log('session-budget: confirm dismissed; prompt let through', { to: 'debug' })
82 }
83 if (answer === says.stop) return false
84 const accept = (current: Budget) => (answer === null ? { ...current, isAccepted: true } : raise(current))
85 await update($, budgetAtom, current => (current === null ? null : accept(current)))
86
87 return true
88}
89
90// The typed prompt is not lost: it returns to the empty box, for the person to send after /budget.
91function restoreDraft($: EngineInterface, text: string): void {
92 $.clock.after(RESTORE_AFTER_MS, () => {
93 void (async () => {
94 const box = await $.prompt.read()
95 if (box.text.trim() === '' && text.trim() !== '') await $.prompt.fill({ text })
96 })().catch(() => $.ui.log('session-budget: could not put the prompt back', { to: 'debug' }))
97 })
98}
99
100async function isPersonTyped($: EngineInterface, origin: PromptOrigin): Promise<boolean> {
101 if (TYPED_ORIGINS.has(origin.kind)) return true
102
103 // The desktop app's Code tab hosts the session through the SDK: its typed prompts arrive as `sdk`.
104 return origin.kind === 'sdk' && (await $.session.surfaces()).length > 0
105}
106
107async function isOutranked($: EngineInterface): Promise<boolean> {
108 const nowMs = await $.clock.now()
109 const cacheTax: { value?: unknown } = await $.state.get(cacheTaxNotice as never)
110 const know: { value?: unknown } = await $.state.get(knowNotice as never)
111 const reflect: { value?: unknown } = await $.state.get(reflectNotice as never)
112
113 return [cacheTax.value, know.value, reflect.value].some(value => isLive(value, nowMs))
114}
115
116function registerSetup(on: On) {
117 on('session.start', async ($, e, next) => {
118 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
119 try {
120 const saved = await $.store.get(STORE_KEY)
121 await update($, enabledAtom, () => saved !== false)
122 const usage = await $.session.usage()
123 await update($, readingAtom, () => readingOf(usage.rateLimits, usage.cost?.usd))
124 await $.command.register({ name: BUDGET, description: words().budgetHelp, argumentHint: '<20% | $5 | off>' })
125 await $.command.register({ name: TOGGLE, description: words().toggleHelp, argumentHint: '[on|off]' })
126 } catch {
127 $.ui.log('session-budget: could not load settings', { to: 'debug' })
128 }
129
130 return next(e)
131 })
132
133 on('command.run', { command: BUDGET }, async ($, e) => ({ text: await setBudget($, e.args) }))
134
135 on('command.run', { command: TOGGLE }, async ($, e) => {
136 const arg = e.args.trim().toLowerCase()
137 const isOn = arg === 'on' || (arg !== 'off' && !(await read($, enabledAtom)))
138 await update($, enabledAtom, () => isOn)
139 await $.store.set(STORE_KEY, isOn)
140 if (!isOn) await update($, noticeAtom, () => null)
141
142 return { text: words().switched(isOn) }
143 })
144}
145
146function registerFlow(on: On) {
147 on('session.measure', async ($, e, next) => {
148 try {
149 const reading = readingOf(e.rateLimits, e.cost?.usd)
150 await update($, readingAtom, () => reading)
151 const budget = await read($, budgetAtom)
152 if (budget !== null) {
153 const advanced = advance(budget, reading)
154 await update($, budgetAtom, () => advanced)
155 if (await read($, enabledAtom)) await checkWarn($, advanced)
156 }
157 } catch {
158 $.ui.log('session-budget: could not read usage', { to: 'debug' })
159 }
160
161 return next(e)
162 })
163
164 on('prompt.submit', async ($, e, next) => {
165 const budget = await read($, budgetAtom)
166 const isOver = budget !== null && !budget.isAccepted && ratioOf(budget) >= 1
167 if (!isOver || !(await read($, enabledAtom)) || !(await isPersonTyped($, e.origin))) return next(e)
168 if (await confirm($, budget)) return next(e)
169 await notify($, 'stopped', words().stoppedNotice(spendText(budget)))
170 restoreDraft($, e.text)
171
172 return { drop: words().dropped(describe(budget)) }
173 }).catch(($, e, next) => next(e))
174}
175
176function registerBand(on: On) {
177 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
178 const theirs = await next(e)
179 const notice = await read($, noticeAtom)
180 if (notice === null || e.props.hasSurvey || !isLive(notice, await $.clock.now())) return theirs
181 if (await isOutranked($)) return theirs
182 const ui = $.ui.resolve(e)
183 const budget = await read($, budgetAtom)
184 const dismiss = () => void update($, noticeAtom, () => null)
185 if (notice.kind === 'stopped') {
186 const spend = budget === null ? '' : spendText(budget)
187
188 return pinned(e, noticeAbove(ui, stoppedRow(ui, { text: notice.text, spend, surface: e.surface, columns: e.props.bodyColumns }), { theirs, dismiss }))
189 }
190 if (budget === null) return theirs
191
192 return pinned(e, noticeAbove(ui, warnRow(ui, { budget, nowMs: await $.clock.now(), surface: e.surface, columns: e.props.bodyColumns }), { theirs, dismiss }))
193 })
194
195 // The full-budget question keeps Claude Code's own options; the card above says what is full.
196 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
197 const theirs = await next(e)
198 const first = e.props.questions[0] as { header?: unknown } | undefined
199 const budget = await read($, budgetAtom)
200 if (first?.header !== words().header || budget === null) return theirs
201
202 return pinned(e, fullCard($.ui.resolve(e), { budget, columns: e.viewport?.columns ?? 100, surface: e.surface, theirs }))
203 })
204}
205
206export const register: Register = on => {
207 registerSetup(on)
208 registerFlow(on)
209 registerBand(on)
210}
211hooks/budget.ts 129 lines1import type { SessionRateLimit } from 'claude-code'
2
3import type { Budget, BudgetKind, BudgetReading } from '../types'
4import { words } from './words'
5
6export type BudgetCommand =
7 | { kind: 'status' }
8 | { kind: 'off' }
9 | { kind: 'set'; budget: { kind: BudgetKind; limit: number } }
10 | { kind: 'error'; text: string }
11
12export const WARN_RATIO = 0.8
13export const FIVE_HOUR = 'five_hour'
14const MAX_PERCENT = 100
15const PERCENT = 100
16const MS_PER_MINUTE = 60_000
17const MINUTES_PER_HOUR = 60
18// A pace needs a minute of history before "bu hızla" says anything.
19const MIN_PACE_MS = 60_000
20// "Devam et" adds $1, or a fifth of a 5h budget (at least one point).
21const USD_STEP = 1
22const PERCENT_STEP_SHARE = 5
23
24export function parseCommand(raw: string): BudgetCommand {
25 const arg = raw.trim().toLowerCase().replace(/\s+/g, '')
26 if (arg === '' || arg === 'status') return { kind: 'status' }
27 if (arg === 'off' || arg === 'clear') return { kind: 'off' }
28 const usd = /^(?:\$(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)(?:\$|usd))$/.exec(arg)
29 if (usd !== null) return positive('usd', Number(usd[1] ?? usd[2]))
30 const percent = /^(\d+(?:\.\d+)?)%?$/.exec(arg)
31 if (percent !== null) {
32 const limit = Number(percent[1])
33 if (limit > MAX_PERCENT) return { kind: 'error', text: words().tooMuch }
34
35 return positive('percent', limit)
36 }
37
38 return { kind: 'error', text: words().usage }
39}
40
41function positive(kind: BudgetKind, limit: number): BudgetCommand {
42 return limit > 0 ? { kind: 'set', budget: { kind, limit } } : { kind: 'error', text: words().notPositive }
43}
44
45export function readingOf(rateLimits: readonly SessionRateLimit[], usd: number | undefined): BudgetReading {
46 const window = rateLimits.find(limit => limit.kind === FIVE_HOUR)
47
48 return { percent: window?.percentUsed ?? null, resetsAt: window?.resetsAt ?? null, usd: usd ?? null }
49}
50
51export function valueOf(kind: BudgetKind, reading: BudgetReading): number | null {
52 return kind === 'percent' ? reading.percent : reading.usd
53}
54
55export function startBudget(kind: BudgetKind, limit: number, reading: BudgetReading): Budget {
56 const value = valueOf(kind, reading)
57
58 return { kind, limit, base: value, carried: 0, last: value, window: reading.resetsAt, hasWarned: false, isAccepted: false }
59}
60
61// Folds a new reading in. A 5h window that reset starts counting again from 0.
62export function advance(budget: Budget, reading: BudgetReading): Budget {
63 const value = valueOf(budget.kind, reading)
64 if (value === null) return budget
65 if (budget.base === null) return { ...budget, base: value, last: value, window: reading.resetsAt }
66 const hasReset = budget.kind === 'percent' && reading.resetsAt !== null && budget.window !== null && reading.resetsAt !== budget.window
67 if (!hasReset) return { ...budget, last: value, window: reading.resetsAt ?? budget.window }
68 const before = Math.max(0, (budget.last ?? budget.base) - budget.base)
69
70 return { ...budget, base: 0, carried: budget.carried + before, last: value, window: reading.resetsAt }
71}
72
73export function spentOf(budget: Budget): number {
74 if (budget.base === null || budget.last === null) return budget.carried
75
76 return budget.carried + Math.max(0, budget.last - budget.base)
77}
78
79export function ratioOf(budget: Budget): number {
80 return spentOf(budget) / budget.limit
81}
82
83// What "Devam et" adds to the budget.
84export function stepOf(budget: Budget): number {
85 return budget.kind === 'usd' ? USD_STEP : Math.max(1, Math.round(budget.limit / PERCENT_STEP_SHARE))
86}
87
88// Going on past a full budget: the step counts from what is spent, so the next ask comes a step later.
89export function raise(budget: Budget): Budget {
90 return { ...budget, limit: Math.max(budget.limit, spentOf(budget)) + stepOf(budget) }
91}
92
93const decimal = (value: number, digits: number): string => words().decimal(value.toFixed(digits))
94const trim = (value: number): string => words().decimal(value.toFixed(1).replace(/\.0$/, ''))
95
96export const amountOf = (budget: Budget, value: number): string =>
97 budget.kind === 'usd' ? `$${decimal(value, 2)}` : words().percent(trim(value))
98
99export const stepText = (budget: Budget): string => (budget.kind === 'usd' ? `+$${stepOf(budget)}` : `+${words().percent(String(stepOf(budget)))}`)
100
101export const percentOf = (budget: Budget): string => words().percent(String(Math.round(ratioOf(budget) * PERCENT)))
102
103// "$4,00 / $5,00", or "5s %8,5 / %10" for a share of the 5h limit.
104export function spendText(budget: Budget): string {
105 const pair = `${amountOf(budget, spentOf(budget))} / ${amountOf(budget, budget.limit)}`
106
107 return budget.kind === 'usd' ? pair : `${words().fiveHour} ${pair}`
108}
109
110export function describe(budget: Budget): string {
111 return `${spendText(budget)} (${percentOf(budget)})`
112}
113
114// Minutes until the budget runs out at the pace since it was set; null without an honest pace.
115export function etaMinutes(budget: Budget, nowMs: number): number | null {
116 const spent = spentOf(budget)
117 const elapsed = budget.setAt === undefined ? 0 : nowMs - budget.setAt
118 if (spent <= 0 || elapsed < MIN_PACE_MS || spent >= budget.limit) return null
119
120 return Math.max(1, Math.round(((budget.limit - spent) / spent) * (elapsed / MS_PER_MINUTE)))
121}
122
123export function etaText(minutes: number | null): string {
124 if (minutes === null) return ''
125 if (minutes < MINUTES_PER_HOUR) return words().etaMinutes(minutes)
126
127 return words().etaHours(Math.floor(minutes / MINUTES_PER_HOUR), minutes % MINUTES_PER_HOUR)
128}
129hooks/notice.tsx 128 lines1import { etaMinutes, etaText, percentOf, ratioOf, spendText, stepText } from './budget'
2import { fullCardSvg, stoppedRowSvg, warnRowSvg } from './svg-rows'
3import type { SvgDoc } from './svg'
4import { upper } from './lang'
5import { words } from './words'
6import type { Budget } from '../types'
7
8// The notices of this mod in the shared MODZ family: a band row above `theirs` (never swallowed)
9// with a ✕, and the full-budget card above the question. Svg on the desktop, text on the terminal.
10type Ui = Record<string, any>
11
12// About 8 px a reported column on the desktop; a card's border and padding take 6 of them,
13// the ✕ beside a band row about as many.
14const PX_PER_COLUMN = 8
15const CARD_CHROME = 6
16const DISMISS_RESERVE = 6
17// The full card is one Svg with its frame drawn in: as wide as a Box card's content plus the
18// 16 px padding each side and the 1 px border that Box would add.
19const BOX_FRAME_PX = 34
20const CARD_MAX_PX = 560
21const CARD_MIN_PX = 300
22const ROW_MIN_PX = 240
23const TERMINAL_CELLS = 10
24const TERMINAL_MARK = 8
25
26const rowRoom = (columns: number): number => Math.max(ROW_MIN_PX, (columns - DISMISS_RESERVE) * PX_PER_COLUMN)
27
28export const isDesktop = (ui: Ui, surface: string): boolean => surface !== 'terminal' && 'Svg' in ui
29
30const svgOf = (ui: Ui, drawn: SvgDoc) => {
31 const { Svg } = ui
32
33 return <Svg source={drawn.source} alt={drawn.alt} width={drawn.width} height={drawn.height} />
34}
35
36export function noticeAbove(ui: Ui, row: unknown, spec: { theirs: unknown; dismiss: () => void }) {
37 const { Box, Button } = ui
38
39 return (
40 <Box flexDirection="column">
41 <Box flexDirection="row" alignItems="center" columnGap={1}>
42 {row}
43 <Button key="sb-dismiss" plain dimColor role="dismiss" onPress={spec.dismiss}>{'✕'}</Button>
44 </Box>
45 {spec.theirs}
46 </Box>
47 )
48}
49
50function cellAt(index: number, filled: number): string {
51 if (index === TERMINAL_MARK) return '┃'
52
53 return index < filled ? '━' : '─'
54}
55
56// The terminal's meter: spent in warning, the budget as a ┃ tidemark, room past it dim.
57function terminalMeter(ui: Ui, ratio: number) {
58 const { Text } = ui
59 const filled = Math.min(TERMINAL_CELLS, Math.max(1, Math.round(ratio * TERMINAL_MARK)))
60 const cells = Array.from({ length: TERMINAL_CELLS }, (_, index) => cellAt(index, filled))
61
62 return (
63 <Text>
64 <Text color="warning">{cells.slice(0, filled).join('')}</Text>
65 <Text dimColor>{cells.slice(filled).join('')}</Text>
66 </Text>
67 )
68}
69
70export function warnRow(ui: Ui, spec: { budget: Budget; nowMs: number; surface: string; columns: number }) {
71 const { Box, Text } = ui
72 const { budget } = spec
73 const eta = etaText(etaMinutes(budget, spec.nowMs))
74 if (isDesktop(ui, spec.surface)) return svgOf(ui, warnRowSvg({ percent: percentOf(budget), spend: spendText(budget), eta, ratio: ratioOf(budget), room: rowRoom(spec.columns) }))
75 const text = [words().rowTitle(percentOf(budget)), spendText(budget), eta].filter(part => part !== '').join(' · ')
76
77 return (
78 <Box flexDirection="row" columnGap={1}>
79 <Text color="warning">!</Text>
80 {terminalMeter(ui, ratioOf(budget))}
81 <Text color="warning" wrap="truncate">{text}</Text>
82 </Box>
83 )
84}
85
86export function stoppedRow(ui: Ui, spec: { text: string; spend: string; surface: string; columns: number }) {
87 const { Box, Text } = ui
88 if (isDesktop(ui, spec.surface)) return svgOf(ui, stoppedRowSvg(spec.spend, rowRoom(spec.columns)))
89
90 return (
91 <Box flexDirection="row" columnGap={1}>
92 <Text color="error">✕</Text>
93 <Text color="error" wrap="truncate">{spec.text}</Text>
94 </Box>
95 )
96}
97
98// Above the question's own options ("Dur", "Devam et (+$1)"): what is full, and what going on costs.
99export function fullCard(ui: Ui, spec: { budget: Budget; columns: number; surface: string; theirs: unknown }) {
100 const { Box, Text } = ui
101 const { budget } = spec
102 const width = Math.max(CARD_MIN_PX, Math.min(CARD_MAX_PX, (spec.columns - CARD_CHROME) * PX_PER_COLUMN + BOX_FRAME_PX))
103 const card = isDesktop(ui, spec.surface) ? (
104 svgOf(ui, fullCardSvg({ spend: spendText(budget), percent: percentOf(budget), step: stepText(budget), width }))
105 ) : (
106 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
107 <Text color="warning" bold>{`● ${upper(words().eyebrow)}`}</Text>
108 <Text bold>{words().fullLine(spendText(budget), percentOf(budget))}</Text>
109 <Text dimColor>{words().held(stepText(budget))}</Text>
110 </Box>
111 )
112
113 return (
114 <Box flexDirection="column" rowGap={1}>
115 {card}
116 {spec.theirs}
117 </Box>
118 )
119}
120
121// A notice whose timer was lost (a hot reload drops timers) must not hold the line forever.
122export function isLive(notice: unknown, nowMs: number): boolean {
123 if (notice === undefined || notice === null) return false
124 const expiresAt = (notice as { expiresAt?: unknown }).expiresAt
125
126 return typeof expiresAt !== 'number' || expiresAt <= 0 || expiresAt > nowMs
127}
128hooks/lang.ts 24 lines1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12 current = langOf(value)
13
14 return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24hooks/pin.ts 48 lines1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18 let handle = pins.get(id)
19 if (handle === undefined) {
20 handle = FIRST_PINNED + pins.size
21 pins.set(id, handle)
22 }
23
24 return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28 if (typeof node !== 'object' || node === null) return node
29 const el = node as Node
30 const children = el.children?.map(child => pinNode(child, ctx))
31 const withKids = children === undefined ? el : { ...el, children }
32 // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33 if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34 const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35 const nth = ctx.seen.get(base) ?? 0
36 ctx.seen.set(base, nth + 1)
37
38 return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43 if (tree === null || tree === undefined) return tree
44 const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46 return pinNode(tree, ctx) as T
47}
48hooks/words.ts 88 lines1import { pick } from './lang'
2
3// Every word the budget's band rows, its card, its question and its commands show, in English and Turkish.
4// Read through `words()` when drawing or answering: the language is set at session.start.
5
6const TR = {
7 // Numbers: Turkish writes "%20" and "4,50"; English "20%" and "4.50".
8 percent: (value: string) => `%${value}`,
9 decimal: (fixed: string) => fixed.replace('.', ','),
10 fiveHour: '5s',
11 etaMinutes: (minutes: number) => `bu hızla ~${minutes} dk`,
12 etaHours: (hours: number, rest: number) => `bu hızla ~${hours} sa${rest === 0 ? '' : ` ${rest} dk`}`,
13 usage: 'Kullanım: /budget <5s limitinin yüzdesi, ör. 20%> | <$tutar, ör. $5> | off',
14 tooMuch: 'Bir 5s bütçesi en çok %100 olabilir.',
15 notPositive: 'Bütçe sıfırdan büyük olmalı.',
16 // The question and its options.
17 stop: 'Dur',
18 goOn: 'Devam et',
19 header: 'Bütçe',
20 question: (said: string) => `Oturum bütçesi doldu: ${said}. Yine de devam edilsin mi?`,
21 // Notices and command answers.
22 warned: (said: string) => `Bütçe uyarısı: ${said}`,
23 stoppedNotice: (spend: string) => `Durduruldu: bütçe doldu · ${spend} · /budget ile artır`,
24 dropped: (said: string) => `Oturum bütçesi doldu: ${said}. /budget ile artır ya da /budget off ile kaldır.`,
25 removed: 'Oturum bütçesi kaldırıldı.',
26 none: 'Oturum bütçesi yok. Kur: /budget 20% ya da /budget $5',
27 status: (said: string) => `Oturum bütçesi: ${said}`,
28 percentUnit: (limit: number) => `%${limit} (5 saatlik limit)`,
29 waitForData: ' Sayım ilk kullanım okumasıyla başlar (5s verisi abonelik ister).',
30 set: (unit: string, wait: string) => `Oturum bütçesi kuruldu: ${unit}.${wait}`,
31 switched: (isOn: boolean) => `session-budget ${isOn ? 'açık' : 'kapalı'}`,
32 budgetHelp: 'Oturum bütçesi kur: 5s limitinin yüzdesi ya da $tutar',
33 toggleHelp: 'Bütçe uyarısı ve onayını aç/kapat',
34 // The band rows and the full card.
35 budget: 'Bütçe',
36 rowTitle: (percent: string) => `Bütçe ${percent}`,
37 raiseHint: '/budget ile artır',
38 stoppedTitle: 'Durduruldu: bütçe doldu',
39 eyebrow: 'Oturum bütçesi',
40 full: 'Bütçe doldu',
41 fullLine: (spend: string, percent: string) => `Bütçe doldu · ${spend} (${percent})`,
42 held: (step: string) => `Mesajın bekletiliyor. Devam edersen bütçe ${step} artar.`,
43 // A no-break space keeps the step with its verb when the line wraps.
44 heldWrapped: (step: string) => `Mesajın bekletiliyor. Devam edersen bütçe ${step} artar.`,
45 fullAlt: (spend: string, percent: string, step: string) => `Oturum bütçesi doldu: ${spend} · ${percent}. Mesajın bekletiliyor; devam edersen bütçe ${step} artar.`,
46}
47
48type Words = typeof TR
49
50const EN: Words = {
51 percent: value => `${value}%`,
52 decimal: fixed => fixed,
53 fiveHour: '5h',
54 etaMinutes: minutes => `full in ≈${minutes}m at this pace`,
55 etaHours: (hours, rest) => `full in ≈${hours}h${rest === 0 ? '' : ` ${rest}m`} at this pace`,
56 usage: 'Usage: /budget <share of the 5h limit, e.g. 20%> | <$amount, e.g. $5> | off',
57 tooMuch: 'A 5h budget can be 100% at most.',
58 notPositive: 'A budget must be more than zero.',
59 stop: 'Stop',
60 goOn: 'Continue anyway',
61 header: 'Budget',
62 question: said => `Session budget is full: ${said}. Continue anyway?`,
63 warned: said => `Budget warning: ${said}`,
64 stoppedNotice: spend => `Stopped: budget is full · ${spend} · raise it with /budget`,
65 dropped: said => `Session budget is full: ${said}. Raise it with /budget, or remove it with /budget off.`,
66 removed: 'Session budget removed.',
67 none: 'No session budget. Set one with /budget 20% or /budget $5',
68 status: said => `Session budget: ${said}`,
69 percentUnit: limit => `${limit}% of the 5-hour limit`,
70 waitForData: ' Counting starts with the first usage reading (5h data needs a subscription).',
71 set: (unit, wait) => `Session budget set: ${unit}.${wait}`,
72 switched: isOn => `session-budget ${isOn ? 'on' : 'off'}`,
73 budgetHelp: 'Set a session budget: a share of the 5h limit, or a dollar amount',
74 toggleHelp: 'Turn the budget warning and check on or off',
75 budget: 'Budget',
76 rowTitle: percent => `Budget ${percent}`,
77 raiseHint: 'raise it with /budget',
78 stoppedTitle: 'Stopped: budget is full',
79 eyebrow: 'Session budget',
80 full: 'Budget is full',
81 fullLine: (spend, percent) => `Budget is full · ${spend} (${percent})`,
82 held: step => `Your message is on hold. Continuing raises the budget by ${step}.`,
83 heldWrapped: step => `Your message is on hold. Continuing raises the budget by ${step}.`,
84 fullAlt: (spend, percent, step) => `Session budget is full: ${spend} · ${percent}. Your message is on hold; continuing raises the budget by ${step}.`,
85}
86
87export const words = (): Words => pick({ en: EN, tr: TR })
88hooks/svg-rows.ts 139 lines1import { escape, eyebrow, eyebrowWidth, measure, svgDoc } from './svg'
2import type { SvgDoc } from './svg'
3import type { Role } from './theme'
4import { words } from './words'
5
6// The desktop drawings, in the MODZ notice family: a band row (24 px tile, the outcome in text.hi,
7// its facts in text.mid, the meter last) and the full-budget card (38 px tile, eyebrow, headline, body).
8
9const ROW = { h: 28, tile: 24, glyph: 13, radius: 6, textX: 34, baseline: 18.5 } as const
10const TITLE = { size: 13, weight: 650 } as const
11const DETAIL = 12.5
12const BOLD_SLACK = 1.06
13const SEP = ' · '
14const METER = { width: 72, height: 6, gap: 12, scale: 1.25 } as const
15const ICON_GRID = 24
16const ICON_STROKE = 1.75
17
18const GLYPHS = {
19 gauge: '<path d="M4.6 16.8a8 8 0 1 1 14.8 0"/><path d="M12 14.2l3.6-4.2"/><circle cx="12" cy="14.6" r="1.1"/>',
20 x: '<path d="M7 7l10 10M17 7L7 17"/>',
21} as const
22
23type Glyph = keyof typeof GLYPHS
24type Tile = { x: number; y: number; size: number; glyph: number; radius: number; role: Role; icon: Glyph }
25
26function tile(t: Tile): string {
27 const scale = t.glyph / ICON_GRID
28 const inset = (t.size - t.glyph) / 2
29
30 return `<rect class="f-${t.role}-t" x="${t.x}" y="${t.y}" width="${t.size}" height="${t.size}" rx="${t.radius}"/>`
31 + `<g class="s-${t.role}" transform="translate(${t.x + inset} ${t.y + inset}) scale(${scale})" fill="none" stroke-width="${ICON_STROKE / scale}" stroke-linecap="round" stroke-linejoin="round">${GLYPHS[t.icon]}</g>`
32}
33
34// `isKept`: the fact that tells the person what to do; a narrow row drops the others first.
35type Part = { text: string; isMono?: boolean; isKept?: boolean }
36
37const partWidth = (part: Part): number => measure(part.text, DETAIL, part.isMono)
38const detailWidth = (parts: readonly Part[]): number => parts.reduce((sum, part) => sum + measure(SEP, DETAIL) + partWidth(part), 0)
39
40function detailRuns(parts: readonly Part[]): string {
41 return parts.map(part => `<tspan class="f-lo">${SEP}</tspan><tspan class="f-mid${part.isMono === true ? ' num' : ''}">${escape(part.text)}</tspan>`).join('')
42}
43
44type RowSpec = { role: Role; icon: Glyph; title: string; parts: readonly Part[]; room: number; meter?: number; alt: string }
45
46// Facts drop from the end until the row fits its room whole: a fact is shown entire or not at all.
47function rowDoc(row: RowSpec): SvgDoc {
48 const titleW = measure(row.title, TITLE.size) * BOLD_SLACK
49 const meterW = row.meter === undefined ? 0 : METER.gap + METER.width
50 const parts = [...row.parts]
51 while (parts.length > 0 && ROW.textX + titleW + detailWidth(parts) + meterW > row.room) {
52 const loose = parts.map(part => part.isKept === true).lastIndexOf(false)
53 parts.splice(loose >= 0 ? loose : parts.length - 1, 1)
54 }
55 const textEnd = ROW.textX + titleW + detailWidth(parts)
56 const body = [
57 tile({ x: 0, y: (ROW.h - ROW.tile) / 2, size: ROW.tile, glyph: ROW.glyph, radius: ROW.radius, role: row.role, icon: row.icon }),
58 `<text x="${ROW.textX}" y="${ROW.baseline}" font-size="${DETAIL}"><tspan class="f-hi" font-size="${TITLE.size}" font-weight="${TITLE.weight}">${escape(row.title)}</tspan>${detailRuns(parts)}</text>`,
59 row.meter === undefined ? '' : tidemarkMeter(textEnd + METER.gap, row.meter),
60 ]
61
62 return svgDoc({ width: textEnd + meterW + 2, height: ROW.h, body: body.join(''), alt: row.alt })
63}
64
65// Spent against the budget: the track runs past it, the budget stands as the tidemark, and the
66// stretch beyond it is tinted so the overflow reads before it happens.
67function tidemarkMeter(x: number, ratio: number): string {
68 const y = (ROW.h - METER.height) / 2
69 const mark = METER.width / METER.scale
70 const fill = Math.min(METER.width, Math.max(METER.height, ratio * mark))
71 const r = METER.height / 2
72
73 return [
74 `<rect class="f-track" x="${x}" y="${y}" width="${METER.width}" height="${METER.height}" rx="${r}"/>`,
75 `<rect class="f-warn-t" x="${x + mark}" y="${y}" width="${METER.width - mark}" height="${METER.height}" rx="${r}"/>`,
76 `<rect class="f-warn" x="${x}" y="${y}" width="${fill.toFixed(1)}" height="${METER.height}" rx="${r}"/>`,
77 `<rect class="f-hi" x="${x + mark - 0.75}" y="${y - 4}" width="1.5" height="${METER.height + 8}" rx=".75"/>`,
78 ].join('')
79}
80
81export type WarnRow = { percent: string; spend: string; eta: string; ratio: number; room: number }
82
83export function warnRowSvg(row: WarnRow): SvgDoc {
84 const parts: Part[] = [{ text: row.spend, isMono: true }, ...(row.eta === '' ? [] : [{ text: row.eta }])]
85 const alt = [words().budget, row.percent, row.spend, row.eta].filter(part => part !== '').join(' · ')
86
87 return rowDoc({ role: 'warn', icon: 'gauge', title: words().rowTitle(row.percent), parts, room: row.room, meter: row.ratio, alt })
88}
89
90export function stoppedRowSvg(spend: string, room: number): SvgDoc {
91 const hint = words().raiseHint
92 const parts: Part[] = [...(spend === '' ? [] : [{ text: spend, isMono: true }]), { text: hint, isKept: true }]
93 const title = words().stoppedTitle
94
95 return rowDoc({ role: 'danger', icon: 'x', title, parts, room, alt: `${title} · ${spend} · ${hint}` })
96}
97
98export type FullCard = { spend: string; percent: string; step: string; width: number }
99
100// The card's frame matches a Box card (round, 16 px across, 8 px down) so it sits with the others.
101const CARD = { padX: 17, padTop: 9, padBottom: 10, radius: 10, tile: 38, glyph: 18, tileRadius: 10, textX: 52 } as const
102const CARD_Y = { eyebrow: 16, headline: 38, body: 59, step: 18 } as const
103const HEADLINE = 17
104const BODY = 13
105const META = 11.5
106const META_GAP = 24
107
108function wrapWords(text: string, width: number): string[] {
109 const lines: string[] = []
110 for (const word of text.split(' ')) {
111 const last = lines[lines.length - 1]
112 if (last !== undefined && measure(`${last} ${word}`, BODY) <= width) lines[lines.length - 1] = `${last} ${word}`
113 else lines.push(word)
114 }
115
116 return lines
117}
118
119export function fullCardSvg(card: FullCard): SvgDoc {
120 const inner = card.width - 2 * CARD.padX
121 const room = inner - CARD.textX
122 const says = words()
123 const meta = [`${card.spend} · ${card.percent}`, card.spend].find(text => eyebrowWidth(says.eyebrow) + measure(text, META, true) + META_GAP <= room) ?? ''
124 const lines = wrapWords(says.heldWrapped(card.step), room)
125 const height = CARD.padTop + CARD_Y.body + (lines.length - 1) * CARD_Y.step + CARD.padBottom
126 const x = (at: number): number => CARD.padX + at
127 const y = (at: number): number => CARD.padTop + at
128 const body = [
129 `<rect class="f-card s-warn-l" x=".5" y=".5" width="${card.width - 1}" height="${height - 1}" rx="${CARD.radius}"/>`,
130 tile({ x: x(0), y: y(4), size: CARD.tile, glyph: CARD.glyph, radius: CARD.tileRadius, role: 'warn', icon: 'gauge' }),
131 eyebrow(says.eyebrow, { x: x(CARD.textX), y: y(CARD_Y.eyebrow), cls: 'f-warn' }),
132 meta === '' ? '' : `<text class="f-lo num" x="${x(inner)}" y="${y(CARD_Y.eyebrow)}" font-size="${META}" text-anchor="end">${escape(meta)}</text>`,
133 `<text class="f-hi" x="${x(CARD.textX)}" y="${y(CARD_Y.headline)}" font-size="${HEADLINE}" font-weight="620">${escape(says.full)}</text>`,
134 ...lines.map((line, i) => `<text class="f-mid" x="${x(CARD.textX)}" y="${y(CARD_Y.body + i * CARD_Y.step)}" font-size="${BODY}">${escape(line)}</text>`),
135 ].join('')
136
137 return svgDoc({ width: card.width, height, body, alt: says.fullAlt(card.spend, card.percent, card.step) })
138}
139hooks/svg.ts 48 lines1import { upper } from './lang'
2import { svgStyle } from './theme'
3
4// Svg building blocks of the Tidemark kit: a document shell
5// and the eyebrow. Pure strings; colors come from classes.
6
7export type SvgDoc = { source: string; alt: string; width: number; height: number }
8
9const EYEBROW_SIZE = 10.5
10const EYEBROW_TRACKING = 1.05
11
12export const escape = (text: string): string =>
13 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
14
15const NARROW = 'iljtf.,:;|!\'` ·'
16const WIDE = 'mwMW@%'
17
18// Width of a text in px, close enough to lay out a line without a renderer.
19export function measure(text: string, size: number, isMono = false): number {
20 if (isMono) return [...text].length * size * 0.6
21
22 return [...text].reduce((sum, char) => {
23 if (NARROW.includes(char)) return sum + size * 0.3
24 if (WIDE.includes(char)) return sum + size * 0.82
25
26 return sum + (char >= 'A' && char <= 'Z' ? size * 0.66 : size * 0.55)
27 }, 0)
28}
29
30export function svgDoc(spec: { width: number; height: number; body: string; alt: string; css?: string }): SvgDoc {
31 const width = Math.ceil(spec.width)
32 const height = Math.ceil(spec.height)
33 const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
34
35 return { source: `${open}${svgStyle(spec.css)}${spec.body}</svg>`, alt: spec.alt, width, height }
36}
37
38// The upper-case label each card and tile opens with.
39export function eyebrow(text: string, at: { x: number; y: number; cls: string }): string {
40 const { x, y, cls } = at
41 const label = escape(upper(text))
42
43 return `<text class="${cls}" x="${x}" y="${y}" font-size="${EYEBROW_SIZE}" font-weight="700" letter-spacing="${EYEBROW_TRACKING}">${label}</text>`
44}
45
46export const eyebrowWidth = (text: string): number =>
47 measure(upper(text), EYEBROW_SIZE) + [...text].length * EYEBROW_TRACKING
48hooks/theme.ts 68 lines1// Tidemark tokens (DESIGN.md §2): every color this mod draws lives here, so the shared kit can
2// replace this file later. Svg gets both palettes through prefers-color-scheme; the terminal
3// gets theme keys, which follow the person's own theme.
4
5type Pair = { readonly dark: string; readonly light: string }
6type RoleColors = { readonly fg: Pair; readonly tint: Pair; readonly line: Pair }
7
8export type Role = 'read' | 'edit' | 'run' | 'ok' | 'warn' | 'danger' | 'plan'
9
10const NEUTRALS = {
11 sheet: { dark: '#191917', light: '#F4F2EC' },
12 card: { dark: '#1F1F1C', light: '#FFFFFF' },
13 inset: { dark: '#171715', light: '#F6F4EF' },
14 line: { dark: '#2E2E2A', light: '#E4E0D6' },
15 strong: { dark: '#45443F', light: '#CFC9BC' },
16 track: { dark: '#34332F', light: '#ECE8DF' },
17 hi: { dark: '#F0EEE8', light: '#1C1B19' },
18 mid: { dark: '#C2BEB4', light: '#4F4B44' },
19 lo: { dark: '#8E8A80', light: '#77726A' },
20} as const satisfies Record<string, Pair>
21
22const role = (fg: [string, string], tint: [string, string], line: [string, string]): RoleColors => ({
23 fg: { dark: fg[0], light: fg[1] },
24 tint: { dark: tint[0], light: tint[1] },
25 line: { dark: line[0], light: line[1] },
26})
27
28const ROLES: Readonly<Record<Role, RoleColors>> = {
29 read: role(['#7FB6E8', '#2464A0'], ['#1E2B37', '#E7F0F9'], ['#2F4A63', '#B9D3EC']),
30 edit: role(['#E58A5F', '#A94A26'], ['#38251D', '#FBEDE6'], ['#6A3E2D', '#EFC6B4']),
31 run: role(['#B19CF3', '#6243C4'], ['#29243D', '#F0ECFC'], ['#4A3F75', '#CFC3F3']),
32 ok: role(['#6FC593', '#1F6E43'], ['#1B2F25', '#E4F4EA'], ['#2F5A42', '#B5DEC4']),
33 warn: role(['#E9B949', '#8A6000'], ['#332D19', '#FBF2D9'], ['#66552A', '#EBD48F']),
34 danger: role(['#F2727F', '#C0263A'], ['#3B2023', '#FCE8EA'], ['#74343C', '#F1B9C0']),
35 plan: role(['#E07BC0', '#A8337F'], ['#351F2E', '#FBE9F4'], ['#6B3A5C', '#EDBFDC']),
36}
37
38export const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, system-ui, sans-serif`
39export const MONO = `ui-monospace, 'SF Mono', Menlo, monospace`
40
41type Scheme = keyof Pair
42
43// One class per use: `f-` fill, `s-` stroke; neutrals by name, roles as `<role>`, `<role>-t`, `<role>-l`.
44function rules(scheme: Scheme): string {
45 const neutral = Object.entries(NEUTRALS).map(([name, pair]) => `.f-${name}{fill:${pair[scheme]}}.s-${name}{stroke:${pair[scheme]}}`)
46 const roles = Object.entries(ROLES).map(([name, colors]) =>
47 [
48 `.f-${name}{fill:${colors.fg[scheme]}}.s-${name}{stroke:${colors.fg[scheme]}}`,
49 `.f-${name}-t{fill:${colors.tint[scheme]}}.s-${name}-l{stroke:${colors.line[scheme]}}`,
50 ].join(''),
51 )
52
53 return [...neutral, ...roles].join('')
54}
55
56// The style block every Svg of this mod opens with: dark by default, light where the page is.
57export function svgStyle(extra = ''): string {
58 return [
59 '<style>',
60 `text{font-family:${FONT}}.num{font-family:${MONO};font-variant-numeric:tabular-nums}`,
61 rules('dark'),
62 `@media (prefers-color-scheme: light){${rules('light')}}`,
63 extra,
64 '@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}',
65 '</style>',
66 ].join('')
67}
68types/index.d.ts 44 lines1export type BudgetKind = 'percent' | 'usd'
2
3export type Budget = {
4 kind: BudgetKind
5 // Percent of the 5h window, or US dollars.
6 limit: number
7 // The reading when the budget was set (or 0 after the 5h window reset); null until the first reading.
8 base: number | null
9 // Spend counted in earlier 5h windows, for a session that outlives a reset.
10 carried: number
11 // The last reading seen, and the window it belongs to (its reset time).
12 last: number | null
13 window: string | null
14 hasWarned: boolean
15 isAccepted: boolean
16 // Epoch ms the budget was set, for "bu hızla ~25 dk".
17 setAt?: number
18}
19
20export type BudgetReading = { percent: number | null; resetsAt: string | null; usd: number | null }
21
22export type BudgetNotice = {
23 id: number
24 // `warn` at 80% draws from the live budget; `stopped` after "Dur".
25 kind: 'warn' | 'stopped'
26 text: string
27 isAlert: boolean
28 // Shared notice line, top wins: cache-tax 4, you-should-know 3, reflect 2, session-budget 1.
29 priority: number
30 // Epoch ms after which the line clears itself.
31 expiresAt: number
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'session-budget': {
37 budget: Budget | null
38 reading: BudgetReading
39 notice: BudgetNotice | null
40 isEnabled: boolean
41 }
42 }
43}
44