Configurable band above the Claude Code prompt: context, cache, quota, model, git and more, with a details pane and an in-app config editor.

A Claude Code mod that draws a band of configurable widgets above the prompt: context fill, prompt cache, 5-hour and weekly quota, model, git. It leaves your statusLine and settings alone.
Requires Claude Code 2.1.288 or later. The band shows in the terminal and in the desktop app's Code tab.

Run in Claude Code:
/plugin marketplace add VizzleTF/claude-skills
/plugin install tidemark@vizzletf-skills
The band appears above the prompt right after the install. If it does not, run /reload-plugins.
| Command | Opens |
|---|---|
/tidemark | Details pane: context by source, cache and quota with a forecast to reset, the session and its subagents |
/tidemark-config | Config editor with a live preview |
/tidemark-goal [text] | Sets the session goal the goal widget shows |
/tidemark-project [text] | Sets the project name the project widget shows |
<img src="docs/images/tidemark-pane.png" width="420" alt="The /tidemark pane: context split by source, 5-hour and weekly quota with a forecast to reset">
The Default column marks the widgets of the band you get without a config file.
| Widget | Shows | Default |
|---|---|---|
context | ctx bar, percent, used/window, growth sparkline | ✓ |
cache | warm 38m, cold, rewrote 45k | ✓ |
quota5h | 5h 42% ↻ 2h34m | ✓ |
quota7d | 7d 63% ↻ 2d7h | ✓ |
model | opus 5.5 · high; a press switches the model or the effort | ✓ |
git | branch, * for uncommitted changes, sync with the upstream, +12 −3 | ✓ |
goal | text from /tidemark-goal; hidden until set | |
project | text from /tidemark-project; hidden until set | ✓ |
compact | a /compact button | ✓ |
actions | a ⚙ button that opens /tidemark-config | ✓ |
flex | nothing; splits the row into groups: left, middle, right | ✓ |
cost | ≈$1.84 (+$0.12): the session and the current turn | |
agents | a spinner per running subagent | |
cwd | project name, directory name or path | ✓ |
sessionTime | ⏱ 1h12m since the conversation started | ✓ |
compactions | ⇣2 | |
tokenSpeed | 42 t/s for the last turn | |
command | the first line of your command's output | |
claudeStatus | ● ok … ● critical from status.claude.com | |
gitPr | #123 ✓ for the branch's PR or MR; needs gh or glab |
The default band uses Nerd Font icons; without such a font in the terminal, set style.icons to text. Figures are coloured from blue to red by the used share. Options of each widget: Reference.
No config file is needed. To change the widgets, run /tidemark-config, edit the band in the preview, pick a file in the save to field and press Save:
save to | File |
|---|---|
global | ~/.config/tidemark/config.json |
project | .claude/tidemark.json in the session's directory; overrides global |
The editor also offers presets: minimal, classic, default, full (two rows) and powerline. Once the context reaches 70% of the window, the /compact button appears under the band; compact.at sets the threshold.
/plugin uninstall tidemark@vizzletf-skills
The config files and project notes stay. To remove them, delete ~/.config/tidemark/, .claude/tidemark.json and .claude/tidemark-project.txt.
tidemark has no telemetry and sends nothing to its author. It never changes Claude Code settings or the permission mode.
| Hook | What it does |
|---|---|
| Session events: start, each turn and model request, usage measurements, compaction, subagent starts, model switches, prompt submission | Reads what happened to keep the band current and passes the event on unchanged |
/config changes of theme and autoCompact | Same |
Built-in commands /clear, /resume, /branch, /model, /autocompact, /theme, /effort | Same |
command.describe on /effort | Reads the command's argument hint |
command.run on /tidemark, /tidemark-config, /tidemark-goal, /tidemark-project | Answers the four commands the mod adds |
ui.render | Draws the band and the four panes |
Only when you press a widget on the band, tidemark runs the built-in command the widget names: /context, /usage, /model, /effort or /compact.
Each runs in the session's directory.
| Program | Why | When |
|---|---|---|
git status, git diff --numstat HEAD | branch, changes and diff size for the git widget | after each turn, at most every 5 seconds |
git fetch --quiet --no-tags | ahead and behind counts; contacts your remote | every 5 minutes; git.fetch: 0 turns it off |
git rev-parse --show-toplevel | the project name for cwd with style: "project" | at most once a minute |
git remote get-url origin, then gh pr view or glab mr view | the pull request of the branch for gitPr; contacts GitHub or GitLab with those tools' own login | once its ttl has passed |
sh -c <command> | the command widget runs the command you configure, nothing else | once its ttl has passed |
Besides the programs above, tidemark makes one request itself: an HTTPS GET of https://status.claude.com/api/v2/summary.json for the claudeStatus widget, off by default.
| What | Holds | Read | Written |
|---|---|---|---|
~/.config/tidemark/config.json, under $XDG_CONFIG_HOME when set | global config | ✓ | on Save in /tidemark-config |
.claude/tidemark.json | project config | ✓ | on Save in /tidemark-config |
.claude/tidemark-project.txt | project name | ✓ | by /tidemark-project |
| Plugin store | last quota, effort levels each model accepted, goals of recent sessions | ✓ | as the session goes |
HOME, XDG_CONFIG_HOME | where the global config lives | ✓ | |
CLAUDE_CODE_PROMPT_CACHE_TTL, ENABLE_PROMPT_CACHING_1H, FORCE_PROMPT_CACHING_5M, setting promptCacheTtl | the prompt cache's lifetime | ✓ |
The files in .claude are tidemark's own, not Claude Code settings or instructions. Privacy and External runs give the details.
hooks/register.tsx 36 lines1// tidemark: a configurable band above the Claude Code prompt, a details pane (/tidemark) and a config
2// editor (/tidemark-config). This module only wires the others together; it never calls `$.ui.status` and
3// never writes settings.
4//
5// Engine rules that shape the modules: one hook per event (without a matcher) per plugin, and `$` is passed
6// only to functions declared in the same file as the hook. So each module registers its own hooks with the
7// `$` work beside them, and modules share values through atoms, never through functions taking `$`.
8import type { Register } from 'claude-code'
9
10import { registerBand } from './band'
11import { registerConfig } from './config'
12import { EDITOR, registerEditor } from './editor'
13import { PANE, registerPane } from './pane'
14import { registerNotes } from './notes'
15import { registerSnapshot } from './snapshot'
16
17export const register: Register = on => {
18 registerSnapshot(on)
19 registerConfig(on)
20 registerBand(on)
21 registerPane(on)
22 registerEditor(on)
23 registerNotes(on)
24
25 // No text: a command's text is a transcript row the model reads too.
26 on('command.run', { command: 'tidemark' }, async $ => {
27 await $.ui.open({ id: PANE, title: 'tidemark', rows: 24 })
28 return {}
29 })
30
31 on('command.run', { command: 'tidemark-config' }, async $ => {
32 await $.ui.open({ id: EDITOR, title: 'tidemark config', rows: 30 })
33 return {}
34 })
35}
36hooks/band.tsx 147 lines1// The band above the prompt: the config's lines drawn from the snapshot and the probes, with whatever the
2// engine and the plugins beneath draw (`next(e)`) kept below it.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On } from 'claude-code'
5
6import type { TidemarkConfig, TidemarkConfigState, TidemarkEffort, TidemarkTrack } from '../types'
7import { effectiveConfig } from './config-model'
8import { EFFORTS, autoCycle, nextEffort, nextModel, parseEfforts, usableEfforts } from './model-utils'
9import type { EffortSupport } from './model-utils'
10import { WIDGETS } from './widgets'
11import { opt } from './widgets/kit'
12import type { Press } from './widgets'
13import { drawBand } from './draw'
14import { buildLines } from './layout'
15import { EMPTY_PROBES } from './probes'
16import { EMPTY_SNAPSHOT, STORE_EFFORTS } from './snapshot'
17import { PANE } from './pane'
18import { EDITOR } from './editor'
19
20// The engine lists the values a module reads from atoms declared in that same file.
21const snapshotState = atom({ plugin: 'tidemark', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
22const configState = atom({ plugin: 'tidemark', key: 'config' } as const, null as TidemarkConfigState | null)
23const probesState = atom({ plugin: 'tidemark', key: 'probes' } as const, EMPTY_PROBES)
24
25// Compacts the main conversation as `/compact` does; the engine compacts only between turns.
26function compact($: EngineInterface) {
27 void $.session.compact().then(
28 r => { if (r.skip) void $.ui.toast(`tidemark: compact skipped: ${r.skip}`) },
29 () => { void $.ui.toast('tidemark: compact runs between turns') },
30 )
31}
32
33const trackState = atom({ plugin: 'tidemark', key: 'track' } as const, { turnCostBase: null, turnStartedAt: null, turnOutput: 0 } as TidemarkTrack)
34
35// The levels `/effort` takes, read from the argument hint Claude Code describes it with
36// (`<low|medium|high|xhigh|max|auto|ultracode [on|off]>`); `$.command.list()` has the engine describe it.
37let effortHint: string | undefined
38
39async function effortLevels($: EngineInterface): Promise<readonly string[]> {
40 if (effortHint === undefined) await $.command.list().catch(() => undefined)
41 const levels = parseEfforts(effortHint ?? '')
42 return levels.length > 0 ? levels : EFFORTS
43}
44
45// A press on the band: `context` and `usage` run that command, `cache` opens the details pane, `effort` and `model` step through their cycle with the command the person would
46// type, and show the new value before the next request does. Effort skips the levels a model's requests
47// were sent lower. In an interactive session Claude Code keeps an effort set this way as that model's own,
48// so a model switch brings back the level last set for the new model.
49async function press($: EngineInterface, what: Press, config: TidemarkConfig): Promise<void> {
50 if (what === 'context') {
51 await $.command.run({ command: 'context' })
52 return
53 }
54 if (what === 'usage') {
55 await $.command.run({ command: 'usage' })
56 return
57 }
58 if (what === 'cache') {
59 await $.ui.open({ id: PANE, title: 'tidemark', rows: 24 })
60 return
61 }
62 if (what === 'config') {
63 await $.ui.open({ id: EDITOR, title: 'tidemark config', rows: 30 })
64 return
65 }
66 if (what === 'compact') {
67 compact($)
68 return
69 }
70 const snap = await read($, snapshotState)
71 if (what === 'effort') {
72 const version = (await $.session.version().catch(() => undefined))?.version ?? ''
73 const support = await $.store.get(STORE_EFFORTS).catch(() => undefined) as EffortSupport | undefined
74 const effort = nextEffort(snap.effort, usableEfforts(await effortLevels($), support, version, snap.model))
75 await $.command.run({ command: 'effort', args: effort })
76 // The plugin's own command.run hooks skip this run, so the snapshot learns of it here.
77 await update($, trackState, t => ({ ...t, effortAsked: effort }))
78 await update($, snapshotState, s => ({ ...s, effort: effort as TidemarkEffort }))
79 return
80 }
81 const item = config.lines.flat().find(i => i.widget === 'model')
82 // An empty `cycle` takes the models Claude Code offers, so a new family joins without a config change.
83 let cycle = opt<string[]>(WIDGETS.model, item ?? {}, 'cycle')
84 if (cycle.length === 0) {
85 const row = (await $.config.list()).find(r => r.key === 'model')
86 cycle = autoCycle(row?.options ?? [], snap.model)
87 }
88 const target = nextModel(snap.model, cycle)
89 if (!target) return
90 const { text } = await $.command.run({ command: 'model', args: target })
91 const id = await $.session.model()
92 if (id === snap.model) $.ui.toast(`tidemark: ${text ?? `no switch to ${target}`}`)
93 // The new model's effort shows with its first request.
94 else await update($, snapshotState, s => ({ ...s, model: id, effort: null }))
95}
96
97export function registerBand(on: On): void {
98 on('command.describe', { command: 'effort' }, async ($, e, next) => {
99 effortHint = e.argumentHint ?? effortHint
100 return next(e)
101 })
102
103 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104 if (e.props.hasSurvey) return next(e)
105 const snap = await read($, snapshotState)
106 const { config, error } = effectiveConfig(await read($, configState))
107 const probes = await read($, probesState)
108 const env = {
109 cwd: await $.session.cwd().catch(() => undefined),
110 home: await $.env.get('HOME').catch(() => undefined),
111 agentId: e.props.view.agentId,
112 configError: error !== undefined,
113 }
114 const now = await $.clock.now()
115 const els = $.ui.resolve(e)
116 let drawn: ReturnType<typeof drawBand> | undefined
117 try {
118 // The band's own padding takes a cell at each side.
119 const lines = buildLines(config, snap, probes, e.props.bodyColumns - 2, now, env)
120 const act = (what: Press) => { void press($, what, config).catch((err: unknown) => $.ui.toast(`tidemark: ${err instanceof Error ? err.message : String(err)}`)) }
121 if (lines.length > 0) drawn = drawBand(els, e.surface, lines, config.style, snap.theme, config.style.buttons === false ? undefined : act)
122 } catch (err) {
123 // A band that cannot be drawn is left out; what is drawn below stays.
124 $.ui.log(`band: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
125 drawn = undefined
126 }
127 // From `compact.at` percent of the main conversation's window, offer `/compact`; the engine compacts
128 // only between turns, so the button waits while one runs.
129 const pct = snap.ctx?.percent
130 const offer = config.compact.enabled && !env.agentId && !e.props.isWorking && pct !== undefined && pct >= config.compact.at
131 const button = offer && (
132 <els.Box flexDirection="row" paddingX={1}>
133 <els.Button
134 key="tidemark-compact"
135 label={`/compact · context ${Math.round(pct)}%`}
136 hotkey="c"
137 variant="primary"
138 onPress={() => compact($)}
139 />
140 </els.Box>
141 )
142 const below = await next(e)
143 if (drawn === undefined && !button) return below
144 return <els.Box flexDirection="column">{drawn}{button}{below}</els.Box>
145 })
146}
147hooks/config.ts 95 lines1// The config's hooks: loading (global file, then the project's file over it) and picking up hand edits by
2// modification time. The schema, presets, validation and merging are in config-model.ts.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On, Timer } from 'claude-code'
5
6import type { TidemarkConfigState } from '../types'
7import { configPathsFrom, loadFromTexts } from './config-model'
8import type { ConfigPaths, LoadedConfig } from './config-model'
9
10async function configPaths($: EngineInterface): Promise<ConfigPaths> {
11 return configPathsFrom({
12 xdg: await $.env.get('XDG_CONFIG_HOME').catch(() => undefined),
13 home: await $.env.get('HOME').catch(() => undefined),
14 cwd: await $.session.cwd().catch(() => undefined),
15 })
16}
17
18const stat = ($: EngineInterface, path: string | null) =>
19 path ? $.fs.stat(path).then(s => s.mtimeMs, () => 0) : Promise.resolve(0)
20
21// The files' modification times, to tell whether a hand edit happened since the last load.
22async function stampOf($: EngineInterface): Promise<string> {
23 const { global, project } = await configPaths($)
24 return `${await stat($, global)}:${await stat($, project)}`
25}
26// Missing files are skipped and nothing is written; a broken file is never rewritten here.
27async function loadConfig($: EngineInterface): Promise<LoadedConfig> {
28 const { global, project } = await configPaths($)
29 const files: Parameters<typeof loadFromTexts>[0] = []
30 for (const path of [global, project]) {
31 if (!path || !(await $.fs.exists(path).catch(() => false))) continue
32 files.push(await $.fs.read(path).then(text => ({ path, text }), (err: unknown) => ({ path, error: err instanceof Error ? err.message : String(err) })))
33 }
34 return loadFromTexts(files)
35}
36
37// The config in effect. Another module reads it through its own `atom({ plugin: 'tidemark', key: 'config' })`
38// (the engine wants atoms declared in the reading file), then `effectiveConfig`.
39const configState = atom({ plugin: 'tidemark', key: 'config' } as const, null as TidemarkConfigState | null)
40
41// A failure the band survives goes to the debug log (`claude --debug`), led by the plugin's name.
42const logTo = ($: EngineInterface, where: string) => (err: unknown) =>
43 $.ui.log(`${where}: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
44
45async function refresh($: EngineInterface): Promise<void> {
46 const stamp = await stampOf($)
47 const loaded = await loadConfig($)
48 await update($, configState, () => ({ ...loaded, stamp }))
49}
50
51// Hand edits are picked up within this long, and at the start of each turn.
52const CHECK_MS = 30_000
53
54let tick: Timer | undefined
55
56async function check($: EngineInterface): Promise<void> {
57 const s = await read($, configState)
58 if (!s || s.stamp !== (await stampOf($))) await refresh($)
59}
60
61// Loads the config when the process starts, and picks up hand edits at each prompt and on a timer. The
62// engine takes one hook per event per plugin and the snapshot owns session.start and turn.start, so the
63// config rides `classic.SessionStart` (startup) and `prompt.submit`; until either runs, the default applies.
64export function registerConfig(on: On): void {
65 on('classic.SessionStart', { source: ['startup'] }, async ($, e, next) => {
66 try {
67 return await next(e)
68 } finally {
69 await ensureTick($)
70 await refresh($).catch(logTo($, 'config load'))
71 }
72 })
73
74 // After `/reload-plugins` neither of the above runs until the next prompt; the band's first draw starts
75 // the timer and schedules a check: a render may not write state, a timer may.
76 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
77 if (!tick) {
78 await ensureTick($)
79 $.clock.after(0, () => check($).catch(logTo($, 'config check')))
80 }
81 return next(e)
82 })
83
84 on('prompt.submit', async ($, e, next) => {
85 await ensureTick($)
86 await check($).catch(logTo($, 'config check'))
87 return next(e)
88 })
89}
90
91async function ensureTick($: EngineInterface) {
92 if (tick) return
93 tick = $.clock.every(CHECK_MS, () => check($).catch(logTo($, 'config check')))
94}
95hooks/editor.tsx 237 lines1// The /tidemark-config editor: a preview of the band at the pane's width, then the draft's lines, the
2// selected widget's fields, Add widget, Style, Pane sections, Alerts, Presets, and Save/Revert. Edits go
3// to a draft in `$.state`; Save writes the chosen file and loads the config into the session at once.
4// The edits themselves are the pure functions of `editor-model`.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, On } from 'claude-code'
7
8import type { TidemarkConfig, TidemarkConfigState, TidemarkEditor, TidemarkWidgetId } from '../types'
9import { ICONS, PRESETS, SEPARATORS, WIDGET_IDS, WIDGET_OPTIONS, configPathsFrom, configText, effectiveConfig, loadFromTexts, saveContent } from './config-model'
10import { drawBand, noFields } from './draw'
11import {
12 addItem, moveItem, optionText, raiseSection, removeItem, setAlert, setCompactAt, setLabel, setOption, setPriority, setStyle,
13 toggleItem, toggleSection,
14} from './editor-model'
15import type { Edit, Selection } from './editor-model'
16import { buildLines } from './layout'
17import { EMPTY_PROBES } from './probes'
18import { EMPTY_SNAPSHOT } from './snapshot'
19import { WIDGETS } from './widgets'
20
21export const EDITOR = 'tidemark-config'
22
23const EMPTY_EDITOR: TidemarkEditor = { draft: null, selected: null, target: 'global', errors: {}, confirm: false, notice: null }
24
25// The engine lists the values a module reads from atoms declared in that same file.
26const editorState = atom({ plugin: 'tidemark', key: 'editor' } as const, EMPTY_EDITOR)
27const configState = atom({ plugin: 'tidemark', key: 'config' } as const, null as TidemarkConfigState | null)
28const snapshotState = atom({ plugin: 'tidemark', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
29const probesState = atom({ plugin: 'tidemark', key: 'probes' } as const, EMPTY_PROBES)
30
31const onOff = [{ value: 'on' }, { value: 'off' }]
32
33// Applies an edit to the draft (the config in effect until the first one). A refused edit leaves the draft
34// and puts its error under `field`; an applied one clears it.
35async function edit($: EngineInterface, field: string, fn: (c: TidemarkConfig, s: Selection | null) => Edit) {
36 const base = effectiveConfig(await read($, configState)).config
37 await update($, editorState, s => {
38 const { [field]: _, ...errors } = s.errors
39 const r = fn(s.draft ?? base, s.selected)
40 if ('error' in r) return { ...s, errors: { ...errors, [field]: r.error } }
41 return { ...s, draft: r.config, selected: r.selected === undefined ? s.selected : r.selected, errors, confirm: false, notice: null }
42 })
43}
44
45const selected = (fn: (c: TidemarkConfig, s: Selection) => Edit) =>
46 (c: TidemarkConfig, s: Selection | null): Edit => (s ? fn(c, s) : { error: 'no widget selected' })
47
48async function paths($: EngineInterface) {
49 return configPathsFrom({
50 xdg: await $.env.get('XDG_CONFIG_HOME').catch(() => undefined),
51 home: await $.env.get('HOME').catch(() => undefined),
52 cwd: await $.session.cwd().catch(() => undefined),
53 })
54}
55
56type Files = Parameters<typeof loadFromTexts>[0]
57
58async function readFiles($: EngineInterface, list: (string | null)[]): Promise<Files> {
59 const files: Files = []
60 for (const p of list) {
61 if (!p || !(await $.fs.exists(p).catch(() => false))) continue
62 files.push(await $.fs.read(p).then(text => ({ path: p, text }), (err: unknown) => ({ path: p, error: String(err) })))
63 }
64 return files
65}
66
67// Writes the draft's share of the target file (`saveContent`), then loads both files into the session so
68// the band redraws with them. A target that does not load now is overwritten only on a second Save.
69async function save($: EngineInterface) {
70 const s = await read($, editorState)
71 const loaded = effectiveConfig(await read($, configState))
72 const { global, project } = await paths($)
73 const path = s.target === 'global' ? global : project
74 const note = (notice: string, confirm = false) => update($, editorState, e => ({ ...e, notice, confirm }))
75 if (!path) return note(`no ${s.target} config path`)
76 if (loaded.error?.includes(`${path}: `) && !s.confirm) return note(`${path} does not load; Save again to overwrite it`, true)
77 const before = await readFiles($, [global, project])
78 // The files as they are now: a hand edit since the session last loaded them is the base, never lost.
79 const onDisk = loadFromTexts(before)
80 const edited = JSON.stringify(onDisk.config) !== JSON.stringify(loaded.config)
81 if (edited && s.draft !== null && !s.confirm) return note('the config files changed since the editor loaded them; Revert to load them, or Save again to overwrite', true)
82 const globalOnly = loadFromTexts(before.filter(f => f.path === global)).config
83 const projectFile = before.find(f => f.path === project)
84 let projectKeys: string[] = []
85 try {
86 const raw: unknown = projectFile && 'text' in projectFile ? JSON.parse(projectFile.text) : undefined
87 if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) projectKeys = Object.keys(raw)
88 } catch {
89 // A project file that does not parse overrides nothing.
90 }
91 try {
92 await $.fs.write(path, configText(saveContent(s.draft ?? onDisk.config, onDisk.config, globalOnly, projectKeys, s.target)))
93 } catch (err) {
94 return note(`save failed: ${err instanceof Error ? err.message : String(err)}`)
95 }
96 // An empty stamp: the config module's next check reloads the same files and records their times.
97 const next = loadFromTexts(await readFiles($, [global, project]))
98 await update($, configState, () => ({ ...next, stamp: '' }))
99 await update($, editorState, e => ({ ...EMPTY_EDITOR, target: e.target, notice: `saved to ${path}` }))
100}
101
102export function registerEditor(on: On): void {
103 on('ui.render', { component: 'Pane', requestId: EDITOR }, async ($, e) => {
104 if (e.surface === 'mobile') return noFields($.ui.resolve(e))
105 const els = $.ui.resolve(e)
106 const { Box, Text, Button, Input, Select } = els
107 const loaded = effectiveConfig(await read($, configState))
108 const ed = await read($, editorState)
109 const draft = ed.draft ?? loaded.config
110 const snap = await read($, snapshotState)
111 const preview = buildLines(draft, snap, await read($, probesState), e.props.bodyColumns - 2, await $.clock.now(), {
112 cwd: await $.session.cwd().catch(() => undefined),
113 home: await $.env.get('HOME').catch(() => undefined),
114 })
115 const sel = ed.selected
116 const item = sel ? draft.lines[sel.line]?.[sel.index] : undefined
117
118 const err = (field: string) => ed.errors[field] !== undefined && <Box key={`err-${field}`}><Text color="red">{` ${ed.errors[field]}`}</Text></Box>
119 const head = (text: string) => <Box marginTop={1}><Text bold>{text}</Text></Box>
120 const field = (key: string, label: string, value: string, fn: (c: TidemarkConfig, s: Selection | null, text: string) => Edit) => (
121 <Box flexDirection="column">
122 <Input key={key} label={label} value={value} submitLabel="set" onSubmit={text => edit($, key, (c, s) => fn(c, s, text))} />
123 {err(key)}
124 </Box>
125 )
126 const pick = (key: string, label: string, value: string | undefined, options: { value: string; label?: string }[], fn: (v: string) => unknown) => (
127 <Box flexDirection="column">
128 <Select key={key} label={label} options={options} {...(value !== undefined && { value })} onSelect={v => fn(v)} />
129 {err(key)}
130 </Box>
131 )
132
133 return (
134 <Box flexDirection="column">
135 <Text dimColor>Preview</Text>
136 {preview.length > 0 ? drawBand(els, e.surface, preview, draft.style, snap.theme) : <Text dimColor> (nothing to show yet)</Text>}
137 <Text dimColor>{`sources: ${loaded.sources.length > 0 ? loaded.sources.join(' + ') : 'default'}`}</Text>
138 {loaded.error !== undefined && <Text color="red">{`error: ${loaded.error}`}</Text>}
139 {loaded.warnings.map(w => <Text dimColor>{`warning: ${w}`}</Text>)}
140
141 {head('Lines')}
142 {draft.lines.map((line, l) => (
143 <Box flexDirection="row" flexWrap="wrap" gap={1}>
144 <Text dimColor>{`${l + 1}`}</Text>
145 {line.map((it, i) => (
146 <Button
147 key={`w-${l}-${i}`}
148 label={it.enabled === false ? `${it.widget} (off)` : it.widget}
149 dimColor={it.enabled === false}
150 {...(sel?.line === l && sel.index === i && { variant: 'primary' as const })}
151 onPress={() => update($, editorState, s => ({ ...s, selected: { line: l, index: i } }))}
152 />
153 ))}
154 </Box>
155 ))}
156 {err('lines')}
157 {item && sel && (
158 <Box flexDirection="column">
159 <Text>{`${WIDGETS[item.widget].title} (${item.widget})`}</Text>
160 <Box flexDirection="row" flexWrap="wrap" gap={1}>
161 <Button key="left" label="←" onPress={() => edit($, 'lines', selected((c, s) => moveItem(c, s, 'left')))} />
162 <Button key="right" label="→" onPress={() => edit($, 'lines', selected((c, s) => moveItem(c, s, 'right')))} />
163 <Button key="up" label="line up" onPress={() => edit($, 'lines', selected((c, s) => moveItem(c, s, 'up')))} />
164 <Button key="down" label="line down" onPress={() => edit($, 'lines', selected((c, s) => moveItem(c, s, 'down')))} />
165 <Button key="toggle" label={item.enabled === false ? 'enable' : 'disable'} onPress={() => edit($, 'lines', selected(toggleItem))} />
166 <Button key="remove" label="remove" onPress={() => edit($, 'lines', selected(removeItem))} />
167 <Button key="label-none" label="no label" onPress={() => edit($, 'label', selected((c, s) => setLabel(c, s, null)))} />
168 </Box>
169 {field('priority', 'priority', String(item.priority ?? WIDGETS[item.widget].defaultPriority), selected2(setPriority))}
170 {field('label', item.label === null ? 'label (none)' : 'label', item.label ?? '', selected2((c, s, t) => setLabel(c, s, t === '' ? undefined : t)))}
171 {Object.entries(WIDGET_OPTIONS[item.widget]).map(([name, spec]) => {
172 const key = `opt-${name}`
173 const value = optionText(spec, item.options?.[name])
174 const set = selected2((c, s, t) => setOption(c, s, name, t))
175 if (spec.kind === 'bool') return pick(key, name, value, onOff, v => edit($, key, (c, s) => set(c, s, v)))
176 if (spec.kind === 'enum') return pick(key, name, value, spec.values.map(v => ({ value: v })), v => edit($, key, (c, s) => set(c, s, v)))
177 return field(key, name, value, set)
178 })}
179 </Box>
180 )}
181
182 {head('Add widget')}
183 {pick('add', 'add', undefined, WIDGET_IDS.map(id => ({ value: id, label: WIDGETS[id].title })), v =>
184 edit($, 'add', (c, s) => addItem(c, v as TidemarkWidgetId, s?.line ?? c.lines.length - 1)))}
185
186 {head('Style')}
187 {pick('separator', 'separator', draft.style.separator, SEPARATORS.map(value => ({ value })), v =>
188 edit($, 'separator', c => setStyle(c, { separator: v as TidemarkConfig['style']['separator'] })))}
189 {draft.style.separator === 'custom' && field('custom', 'custom separator', draft.style.custom ?? '', (c, _s, t) => setStyle(c, { custom: t }))}
190 {pick('icons', 'icons', draft.style.icons, ICONS.map(value => ({ value, ...(value === 'nerd' && { label: 'nerd font' }) })), v =>
191 edit($, 'icons', c => setStyle(c, { icons: v as TidemarkConfig['style']['icons'] })))}
192 {pick('buttons', 'buttons', draft.style.buttons === false ? 'off' : 'on', [{ value: 'on' }, { value: 'off' }], v =>
193 edit($, 'buttons', c => setStyle(c, { buttons: v === 'on' })))}
194
195 {head('Pane sections')}
196 {draft.pane.sections.map(s => (
197 <Box flexDirection="row" gap={1}>
198 <Text>{s.id}</Text>
199 <Button key={`sec-${s.id}`} label={s.enabled ? 'on' : 'off'} onPress={() => edit($, 'sections', c => toggleSection(c, s.id))} />
200 <Button key={`sec-${s.id}-up`} label="↑" onPress={() => edit($, 'sections', c => raiseSection(c, s.id))} />
201 </Box>
202 ))}
203
204 {head('Alerts')}
205 {pick('alerts-enabled', 'toasts', draft.alerts.enabled ? 'on' : 'off', onOff, v =>
206 edit($, 'alerts-enabled', c => ({ config: { ...c, alerts: { ...c.alerts, enabled: v === 'on' } } })))}
207 {([['context', 'context %'], ['quota5h', '5h quota %'], ['quota7d', '7d quota %'], ['cacheSeconds', 'cache cold in, s']] as const).map(([k, label]) =>
208 field(`alerts-${k}`, label, String(draft.alerts[k]), (c, _s, t) => setAlert(c, k, t)))}
209
210 {head('Compact button')}
211 {pick('compact-enabled', 'button', draft.compact.enabled ? 'on' : 'off', onOff, v =>
212 edit($, 'compact-enabled', c => ({ config: { ...c, compact: { ...c.compact, enabled: v === 'on' } } })))}
213 {field('compact-at', 'from context %', String(draft.compact.at), (c, _s, t) => setCompactAt(c, t))}
214
215 {head('Presets')}
216 {pick('preset', 'preset', undefined, Object.keys(PRESETS).map(value => ({ value })), v =>
217 edit($, 'preset', () => (PRESETS[v] ? { config: PRESETS[v]!, selected: null } : { error: `unknown preset ${v}` })))}
218
219 {head('Save')}
220 {pick('target', 'save to', ed.target, [{ value: 'global' }, { value: 'project' }], v =>
221 update($, editorState, (s): TidemarkEditor => ({ ...s, target: v === 'project' ? 'project' : 'global', confirm: false })))}
222 <Box flexDirection="row" gap={1}>
223 <Button key="save" label={ed.confirm ? 'Save (overwrite)' : 'Save'} variant="primary" onPress={() => save($)} />
224 <Button key="revert" label="Revert" onPress={() => update($, editorState, s => ({ ...EMPTY_EDITOR, target: s.target }))} />
225 </Box>
226 {ed.draft !== null && <Text dimColor>unsaved changes</Text>}
227 {ed.notice !== null && <Text>{ed.notice}</Text>}
228 </Box>
229 )
230 })
231}
232
233// A field edit that needs the selected widget.
234function selected2(fn: (c: TidemarkConfig, s: Selection, text: string) => Edit) {
235 return (c: TidemarkConfig, s: Selection | null, text: string): Edit => (s ? fn(c, s, text) : { error: 'no widget selected' })
236}
237hooks/pane.tsx 37 lines1// The /tidemark details pane: the sections `pane.sections` lists, in its order, drawn like the band.
2import { atom, read } from 'claude-code'
3import type { On } from 'claude-code'
4
5import type { TidemarkConfigState } from '../types'
6import { effectiveConfig } from './config-model'
7import { drawPaneLines } from './draw'
8import { paneRows } from './pane-rows'
9import { EMPTY_SNAPSHOT } from './snapshot'
10
11export const PANE = 'tidemark'
12// The snapshot's fields as `name: <json>` rows. No command opens it: it is the snapshot's test seam (a test
13// cannot read `$.state`, and the pane shows only part of the snapshot, formatted) and a debugging view.
14export const RAW_PANE = 'tidemark-raw'
15
16// The engine lists the values a module reads from atoms declared in that same file.
17const snapshotState = atom({ plugin: 'tidemark', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
18const configState = atom({ plugin: 'tidemark', key: 'config' } as const, null as TidemarkConfigState | null)
19
20export function registerPane(on: On): void {
21 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
22 const snap = await read($, snapshotState)
23 const { config } = effectiveConfig(await read($, configState))
24 return drawPaneLines($.ui.resolve(e), e.surface, paneRows(snap, config.pane.sections, await $.clock.now()), snap.theme)
25 })
26
27 on('ui.render', { component: 'Pane', requestId: RAW_PANE }, async ($, e) => {
28 const { Box, Text } = $.ui.resolve(e)
29 const snap = await read($, snapshotState)
30 return (
31 <Box flexDirection="column">
32 {Object.entries(snap).map(([k, v]) => <Text>{`${k}: ${JSON.stringify(v)}`}</Text>)}
33 </Box>
34 )
35 })
36}
37hooks/notes.tsx 68 lines1// /tidemark-goal and /tidemark-project: the session's goal and the project note the band shows. With text
2// the command sets it at once; without, it opens a small pane whose field holds the current text.
3// The goal is kept per session id in the plugin store, the note in the session directory.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On, RenderInput } from 'claude-code'
6
7import { EMPTY_SNAPSHOT, STORE_GOALS } from './snapshot'
8import { notePath, withGoal } from './snapshot-reducers'
9import { noFields } from './draw'
10
11export const GOAL = 'tidemark-goal'
12export const PROJECT = 'tidemark-project'
13
14const snapshotState = atom({ plugin: 'tidemark', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
15
16const logTo = ($: EngineInterface, where: string) => (err: unknown) =>
17 $.ui.log(`${where}: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
18
19// Blank text clears; the note's file is emptied rather than left stale.
20async function save($: EngineInterface, which: typeof GOAL | typeof PROJECT, raw: string) {
21 const text = raw.trim() || null
22 if (which === GOAL) {
23 await update($, snapshotState, s => ({ ...s, goal: text }))
24 const id = (await read($, snapshotState)).sessionId
25 if (!id) return
26 const goals = await $.store.get(STORE_GOALS).catch(() => undefined) as Record<string, string> | undefined
27 await $.store.set(STORE_GOALS, withGoal(goals, id, text)).catch(logTo($, 'goal'))
28 return
29 }
30 await update($, snapshotState, s => ({ ...s, project: text }))
31 const cwd = await $.session.cwd().catch(() => null)
32 if (cwd) await $.fs.write(notePath(cwd), text ?? '').catch(logTo($, 'project note'))
33}
34
35// The field pane of /tidemark-goal or /tidemark-project.
36async function drawField($: EngineInterface, e: RenderInput<'Pane'>, which: typeof GOAL | typeof PROJECT) {
37 if (e.surface === 'mobile') return noFields($.ui.resolve(e))
38 const { Box, Input, Text } = $.ui.resolve(e)
39 const snap = await read($, snapshotState)
40 const goal = which === GOAL
41 return (
42 <Box flexDirection="column">
43 <Input
44 key="text" autoFocus label={goal ? 'goal' : 'project'} value={(goal ? snap.goal : snap.project) ?? ''} submitLabel="set"
45 placeholder={goal ? 'what this session is for' : 'the project name, kept for this directory'}
46 onSubmit={async text => {
47 await save($, which, text)
48 await $.ui.close({ id: which })
49 }}
50 />
51 <Text dimColor>Enter sets it, an empty field clears it, Esc closes</Text>
52 </Box>
53 )
54}
55
56export function registerNotes(on: On): void {
57 // No text: a command's text is a transcript row the model reads too.
58 on('command.run', { command: ['tidemark-goal', 'tidemark-project'] }, async ($, e) => {
59 const which = e.command === GOAL ? GOAL : PROJECT
60 if (e.args.trim()) await save($, which, e.args)
61 else await $.ui.open({ id: which, title: which === GOAL ? 'tidemark goal' : 'tidemark project', rows: 3, focus: true, closeOnEscape: true })
62 return {}
63 })
64
65 on('ui.render', { component: 'Pane', requestId: 'tidemark-goal' }, ($, e) => drawField($, e, GOAL))
66 on('ui.render', { component: 'Pane', requestId: 'tidemark-project' }, ($, e) => drawField($, e, PROJECT))
67}
68hooks/snapshot.ts 424 lines1// The session snapshot: every figure the band, the pane and the alerts draw from, written to `$.state` by
2// the engine's events and only read by everything else. Ideas and cache/growth rules after ccOverhead
3// (MIT, shengyy); the code is tidemark's own.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On, SessionContextBreakdown, SessionContextUsage, SessionRateLimit, Timer } from 'claude-code'
6
7import type { TidemarkCacheStats, TidemarkConfigState, TidemarkLimit, TidemarkSnapshot, TidemarkTrack } from '../types'
8import { EMPTY_ALERT_MEMORY, evaluateAlerts } from './alerts'
9import { effectiveConfig } from './config-model'
10import { LONG_TTL_MS as CACHE_TTL_MS, SHORT_TTL_MS, baseModel, defaultTtl, learnEffort, validCost } from './model-utils'
11import type { EffortSupport } from './model-utils'
12import { EMPTY_PROBES, applyProbeResult, planProbes } from './probes'
13import type { ProbeOutcome, ProbeRequest } from './probes'
14import { addAgentStep, notePath, pick, reduceCompact, reduceContext, reduceModelSwitch, reduceResumed, reduceStep, themeOf } from './snapshot-reducers'
15
16export type Snapshot = TidemarkSnapshot
17
18// Prompt-cache lifetimes: the short one, and the hour main conversations were seen to get.
19export { SHORT_TTL_MS, LONG_TTL_MS as CACHE_TTL_MS } from './model-utils'
20const TICK_MS = 30_000
21const STORE_LIMITS = 'limits'
22// What requests showed of each model's effort levels (EffortSupport); the band reads it under the same key.
23export const STORE_EFFORTS = 'effortSupport'
24// Each session's goal by session id (withGoal); the notes module writes it under the same key.
25export const STORE_GOALS = 'goals'
26
27const NO_STATS: TidemarkCacheStats = { input: 0, output: 0, read: 0, write: 0, last: 0 }
28export const EMPTY_SNAPSHOT: Snapshot = {
29 ctx: null, history: [], compactions: 0, compactionLog: [], breakdown: null, limits: [], limitsLive: false,
30 limitsAt: null, cache: null, cacheTtl: null, ttlDefault: null, cacheStats: NO_STATS, model: null, effort: null, agents: [],
31 activeAgents: 0, cost: null, turnCost: null, sessionId: null, startedAt: null, speed: null, theme: 'dark',
32 goal: null, project: null,
33}
34const NO_TRACK: TidemarkTrack = { turnCostBase: null, turnStartedAt: null, turnOutput: 0 }
35
36// Another module reads the snapshot through its own `atom({ plugin: 'tidemark', key: 'snapshot' }, EMPTY_SNAPSHOT)`:
37// the engine wants atoms declared in the reading file and `$` never passed across an import.
38const snapshotState = atom({ plugin: 'tidemark', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
39const track = atom({ plugin: 'tidemark', key: 'track' } as const, NO_TRACK)
40const configState = atom({ plugin: 'tidemark', key: 'config' } as const, null as TidemarkConfigState | null)
41const probesState = atom({ plugin: 'tidemark', key: 'probes' } as const, EMPTY_PROBES)
42const alertsState = atom({ plugin: 'tidemark', key: 'alerts' } as const, EMPTY_ALERT_MEMORY)
43
44// The snapshot as it stands; read while drawing, it redraws on every change.
45export async function readSnapshot($: EngineInterface): Promise<Snapshot> {
46 return read($, snapshotState)
47}
48
49const set = ($: EngineInterface, fn: (s: Snapshot) => Partial<Snapshot>) => update($, snapshotState, s => ({ ...s, ...fn(s) }))
50const setTrack = ($: EngineInterface, fn: (t: TidemarkTrack) => Partial<TidemarkTrack>) => update($, track, t => ({ ...t, ...fn(t) }))
51
52// Bumped by a conversation reset, so reads that started before it are dropped.
53let revision = 0
54let tick: Timer | undefined
55let settling: Timer | undefined
56
57async function readTheme($: EngineInterface) {
58 const rows = await $.config.list().catch(() => [])
59 const theme = themeOf(rows.find(row => row.key === 'theme')?.value)
60 await set($, () => ({ theme }))
61}
62
63// A new conversation (/clear, /resume, /branch, another session id): its figures start over, the
64// account's quota and the theme stay.
65async function resetConversation($: EngineInterface, startedAt: number | null) {
66 revision++
67 await set($, s => ({ ...EMPTY_SNAPSHOT, limits: s.limits, theme: s.theme, sessionId: s.sessionId, model: s.model, startedAt, goal: s.goal, project: s.project }))
68 await update($, track, () => NO_TRACK)
69}
70
71async function syncSession($: EngineInterface): Promise<boolean> {
72 const r = revision
73 const id = await $.session.id().catch(() => null)
74 if (r !== revision || id === null) return false
75 const previous = (await read($, snapshotState)).sessionId
76 await set($, () => ({ sessionId: id }))
77 const changed = previous !== null && previous !== id
78 if (changed) await resetConversation($, await $.clock.now())
79 if (previous !== id) {
80 const goals = await $.store.get(STORE_GOALS).catch(() => undefined) as Record<string, string> | undefined
81 await set($, () => ({ goal: goals?.[id] ?? null }))
82 }
83 return changed
84}
85
86async function setModel($: EngineInterface, id: string) {
87 const at = await $.clock.now()
88 await set($, s => reduceModelSwitch(s, id, at))
89}
90
91async function readModel($: EngineInterface) {
92 const r = revision
93 const id = await $.session.model().catch(() => null)
94 if (r === revision && id) await setModel($, id)
95}
96
97async function takeCost($: EngineInterface, value: number | undefined) {
98 const s = await read($, snapshotState)
99 const previous = s.cost
100 await set($, () => ({ cost: validCost(value) ? value : null }))
101 if (!validCost(value) || (previous !== null && value < previous)) {
102 await set($, () => ({ turnCost: null }))
103 if (validCost(value)) await setTrack($, () => ({ turnCostBase: null }))
104 return
105 }
106 const base = (await read($, track)).turnCostBase
107 if (base !== null) await set($, () => ({ turnCost: value >= base ? value - base : null }))
108}
109
110// The session's own quota reading; shared through `$.store` only when its own figure changed, so an idle
111// session never overwrites a newer one. `withdrawn`: an empty reading means the windows went away.
112async function takeLimits($: EngineInterface, rateLimits: SessionRateLimit[] | undefined, withdrawn: boolean) {
113 const live = pick(rateLimits)
114 if (live.length === 0 && !withdrawn) return
115 const s = await read($, snapshotState)
116 const changed = !s.limitsLive || JSON.stringify(live) !== JSON.stringify(s.limits)
117 // `limitsAt` is the last reading, changed or not: a figure that holds still is still fresh.
118 const at = await $.clock.now()
119 await set($, () => ({ limits: live, limitsLive: true, limitsAt: at }))
120 if (changed) await $.store.set(STORE_LIMITS, live).catch(() => undefined)
121}
122
123async function localBreakdown($: EngineInterface): Promise<SessionContextBreakdown | undefined> {
124 try {
125 return (await $.session.usage({ breakdown: 'summary' })).context.breakdown
126 } catch {
127 return undefined
128 }
129}
130
131async function takeContext($: EngineInterface, context: SessionContextUsage | undefined, recordGrowth: boolean, r: number) {
132 const b = await localBreakdown($)
133 if (r !== revision) return
134 await set($, s => reduceContext(s, context, recordGrowth, b))
135}
136
137// The engine's figures now; quota from `$.store` until this session has its own.
138async function load($: EngineInterface, recordGrowth = false) {
139 await syncSession($)
140 const r = revision
141 const usage = await $.session.usage().catch(() => undefined)
142 if (!usage || r !== revision) return
143 await readModel($)
144 if (r !== revision) return
145 if (usage.startedAt > 0 && (await read($, snapshotState)).startedAt === null) await set($, () => ({ startedAt: usage.startedAt }))
146 await takeCost($, usage.cost?.usd)
147 await takeContext($, usage.context, recordGrowth, r)
148 if (r !== revision) return
149 await takeLimits($, usage.rateLimits, false)
150 await readTtlDefault($, pick(usage.rateLimits))
151 if (pick(usage.rateLimits).length === 0 && !(await read($, snapshotState)).limitsLive) {
152 const saved = await $.store.get(STORE_LIMITS).catch(() => undefined)
153 if (r === revision && Array.isArray(saved)) await set($, () => ({ limits: saved as TidemarkLimit[] }))
154 }
155}
156
157// What Claude Code asks for by its environment, settings and account; read on every load, cheap.
158async function readTtlDefault($: EngineInterface, limits: TidemarkLimit[]) {
159 const env = {
160 force5m: await $.env.get('FORCE_PROMPT_CACHING_5M').catch(() => undefined),
161 ttl: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(() => undefined),
162 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(() => undefined),
163 }
164 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
165 const ttlDefault = defaultTtl(env, (settings as Record<string, unknown>).promptCacheTtl, limits)
166 if ((await read($, snapshotState)).ttlDefault !== ttlDefault) await set($, () => ({ ttlDefault }))
167}
168
169async function readActiveAgents($: EngineInterface) {
170 const r = revision
171 const list = await $.agent.list().catch(() => [])
172 if (r !== revision) return
173 const count = list.filter(a => a.status === 'running').length
174 await set($, s => ({
175 activeAgents: count,
176 agents: s.agents.map(agent => {
177 const info = list.find(a => a.id === agent.id)
178 return info ? { ...agent, label: info.type, ...(info.description !== undefined && { description: info.description }) } : agent
179 }),
180 }))
181}
182
183// The engine installs readings after the hook returns; one coalesced read picks them up.
184function refreshSoon($: EngineInterface) {
185 settling?.cancel()
186 settling = $.clock.after(100, async () => {
187 await readActiveAgents($)
188 await load($).catch(logTo($, 'load'))
189 })
190}
191
192async function resumed($: EngineInterface, e: { context_tokens?: number; seconds_since_last_response?: number; prompt_cache_likely_expired?: boolean }) {
193 const { context_tokens: tokens, seconds_since_last_response: age, prompt_cache_likely_expired: expired } = e
194 const at = age !== undefined ? (await $.clock.now()) - age * 1000 : undefined
195 await set($, s => reduceResumed(s, { tokens, age, expired, at }))
196}
197
198// What a request sent against the level asked for, kept per Claude Code version in the plugin store.
199async function learnEfforts($: EngineInterface, model: string, asked: string, sent: string | number | undefined) {
200 const version = (await $.session.version()).version
201 const saved = await $.store.get(STORE_EFFORTS) as EffortSupport | undefined
202 const next = learnEffort(saved, version, model, asked, sent)
203 if (JSON.stringify(next) !== JSON.stringify(saved)) await $.store.set(STORE_EFFORTS, next)
204}
205
206// Probe keys with a request in flight: a key never runs twice at once.
207const running = new Set<string>()
208
209// A failure the band survives goes to the debug log (`claude --debug`), led by the plugin's name.
210const logTo = ($: EngineInterface, where: string) => (err: unknown) =>
211 $.ui.log(`${where}: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
212
213async function runProbe($: EngineInterface, req: ProbeRequest, cwd: string): Promise<ProbeOutcome> {
214 try {
215 if (req.kind === 'process') {
216 const r = await $.process.run(req.argv, { cwd, timeoutMs: req.timeoutMs, ...(req.stdin !== undefined && { stdin: req.stdin }), ...(req.env && { env: req.env }) })
217 return r.exitCode === 0 ? { ok: true, stdout: r.stdout } : { ok: false, error: r.stderr.trim() || `exit ${r.exitCode}` }
218 }
219 let timer: Timer | undefined
220 const timeout = new Promise<never>((_, reject) => { timer = $.clock.after(req.timeoutMs, () => reject(new Error('timeout'))) })
221 try {
222 // The one request tidemark makes itself; the address stays literal so a reader of the call sees the host.
223 const r = await Promise.race([$.http.fetch('https://status.claude.com/api/v2/summary.json'), timeout])
224 return { ok: true, text: r.text, status: r.status }
225 } finally {
226 timer?.cancel()
227 }
228 } catch (err) {
229 return { ok: false, error: err instanceof Error ? err.message : String(err) }
230 }
231}
232
233// Runs what the probes module plans, without holding the caller: each result lands in the `probes` atom.
234async function probe($: EngineInterface, trigger: 'tick' | 'turn') {
235 const { config } = effectiveConfig(await read($, configState))
236 const cwd = await $.session.cwd().catch(() => '')
237 const now = await $.clock.now()
238 const plan = planProbes(config, await read($, probesState), await read($, snapshotState), cwd, now, trigger)
239 for (const req of plan) {
240 if (running.has(req.key)) continue
241 running.add(req.key)
242 void runProbe($, req, cwd).then(async outcome => {
243 const at = await $.clock.now()
244 await update($, probesState, p => applyProbeResult(p, req.key, outcome, at))
245 }).catch(logTo($, `probe ${req.key}`)).finally(() => running.delete(req.key))
246 }
247}
248
249// Threshold toasts from the alerts module; its memory kept in the `alerts` atom.
250async function alert($: EngineInterface) {
251 const { config } = effectiveConfig(await read($, configState))
252 const now = await $.clock.now()
253 const { toasts, memory } = evaluateAlerts(await read($, snapshotState), config, await read($, alertsState), now)
254 await update($, alertsState, () => memory)
255 for (const text of toasts) $.ui.toast(text)
256}
257
258async function snapshotStart($: EngineInterface): Promise<void> {
259 tick?.cancel()
260 tick = $.clock.every(TICK_MS, async () => {
261 await load($).catch(logTo($, 'tick load'))
262 await readActiveAgents($).catch(logTo($, 'tick agents'))
263 await probe($, 'tick').catch(logTo($, 'tick probe'))
264 await alert($).catch(logTo($, 'tick alert'))
265 $.ui.invalidate('ui.render')
266 })
267 if ((await read($, snapshotState)).startedAt === null) {
268 const now = await $.clock.now()
269 await set($, () => ({ startedAt: now }))
270 }
271 const cwd = await $.session.cwd().catch(() => null)
272 const note = cwd ? (await $.fs.read(notePath(cwd)).catch(() => '')).trim() : ''
273 if (note) await set($, () => ({ project: note }))
274 await readTheme($)
275 await readActiveAgents($)
276 await load($)
277}
278
279async function snapshotTurnStart($: EngineInterface): Promise<void> {
280 await syncSession($)
281 const r = revision
282 const reading = await $.session.usage().catch(() => undefined)
283 const now = await $.clock.now()
284 if (r !== revision) return
285 await setTrack($, () => ({ turnCostBase: validCost(reading?.cost?.usd) ? reading.cost.usd : null, turnStartedAt: now, turnOutput: 0 }))
286 await set($, () => ({ turnCost: null }))
287}
288
289async function snapshotTurnComplete($: EngineInterface): Promise<void> {
290 const r = revision
291 await readActiveAgents($)
292 refreshSoon($)
293 const ledger = await $.session.usage().catch(() => undefined)
294 if (r === revision) await takeCost($, ledger?.cost?.usd)
295 await probe($, 'turn').catch(logTo($, 'turn probe'))
296}
297
298// The snapshot owns session.start, turn.start and turn.complete: the engine takes one hook per event per
299// plugin. The commands are registered here, at session start, for the modules that answer them; their names
300// stay literal so `claude plugin validate` sees each `command.run` hook answers a command of this mod.
301export function registerSnapshot(on: On): void {
302 on('session.start', async ($, e, next) => {
303 const result = await next(e)
304 // Session state outlives a reload of the mod: a snapshot an older version stored gets the newer fields.
305 await set($, s => ({ ...EMPTY_SNAPSHOT, ...s })).catch(logTo($, 'session.start state'))
306 await snapshotStart($).catch(logTo($, 'session.start'))
307 // A host that registers no commands still draws the band.
308 await $.command.register({ name: 'tidemark', description: 'Show tidemark details: context, cache, quota, agents' }).catch(logTo($, 'register /tidemark'))
309 await $.command.register({ name: 'tidemark-config', description: 'Edit the tidemark band: widgets, style, presets' }).catch(logTo($, 'register /tidemark-config'))
310 await $.command.register({ name: 'tidemark-goal', description: 'Set the goal the band shows for this session; without text opens a field' }).catch(logTo($, 'register /tidemark-goal'))
311 await $.command.register({ name: 'tidemark-project', description: 'Set the project name the band shows in this directory; without text opens a field' }).catch(logTo($, 'register /tidemark-project'))
312 return result
313 })
314
315 on('turn.start', async ($, e, next) => {
316 await snapshotTurnStart($).catch(logTo($, 'turn.start'))
317 return next(e)
318 })
319
320 on('turn.complete', async ($, e, next) => {
321 const result = await next(e)
322 await snapshotTurnComplete($).catch(logTo($, 'turn.complete'))
323 return result
324 })
325
326 // /clear, /resume and /branch start another conversation with no new session.start.
327 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
328 try {
329 return await next(e)
330 } finally {
331 await resetConversation($, await $.clock.now())
332 await load($)
333 if (e.source !== 'clear') await resumed($, e)
334 }
335 })
336
337 // After each main turn and when a quota window moves.
338 on('session.measure', async ($, e, next) => {
339 const r = revision
340 const result = await next(e)
341 if (r !== revision) return result
342 await syncSession($)
343 if (r !== revision) return result
344 await readModel($)
345 await takeCost($, e.cost?.usd)
346 await takeContext($, e.context, true, r)
347 if (r === revision) await takeLimits($, e.rateLimits, e.changed.includes('rateLimits'))
348 await alert($).catch(logTo($, 'alert'))
349 return result
350 })
351
352 // A main compaction: counted, growth starts over, the next request writes a new conversation.
353 on('session.compact', async ($, e, next) => {
354 const result = await next(e)
355 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
356 await set($, s => reduceCompact(s, result.tokensBefore, result.tokensAfter))
357 refreshSoon($)
358 }
359 return result
360 })
361
362 on('classic.PostModelSwitch', async ($, e, next) => {
363 const result = await next(e)
364 await setModel($, e.to_model)
365 await set($, () => ({ cacheTtl: e.cache_ttl === '5m' ? SHORT_TTL_MS : CACHE_TTL_MS }))
366 refreshSoon($)
367 return result
368 })
369
370 on('agent.spawn', async ($, e, next) => {
371 const result = await next(e)
372 await readActiveAgents($)
373 refreshSoon($)
374 return result
375 })
376
377 // Each request: main ones feed the cache, effort and speed; a subagent's feed its own entry.
378 on('turn.step', async function* ($, e, next) {
379 const r = revision
380 const started = await $.clock.now()
381 const result = yield* next(e)
382 if (r !== revision) return result
383 const u = result.usage
384 if (!u) return result
385 const effort = baseModel(e.model) === baseModel(u.model) ? e.effort : undefined
386 if (e.agentId === undefined) {
387 await syncSession($)
388 if (r !== revision) return result
389 await setModel($, u.model)
390 const now = await $.clock.now()
391 const t = await read($, track)
392 const output = t.turnOutput + (u.output_tokens ?? 0)
393 await setTrack($, () => ({ turnOutput: output, effortAsked: null }))
394 if (t.effortAsked) await learnEfforts($, u.model, t.effortAsked, effort).catch(logTo($, 'effort support'))
395 const seconds = t.turnStartedAt !== null ? (now - t.turnStartedAt) / 1000 : 0
396 await set($, s => reduceStep(s, u, e.turnId, started, effort ?? null, output, seconds))
397 refreshSoon($)
398 } else {
399 const id = e.agentId
400 await set($, s => ({ agents: addAgentStep(s.agents, id, u.model, u, effort) }))
401 }
402 await readActiveAgents($)
403 return result
404 })
405
406 on('command.run', { command: ['clear', 'resume', 'branch', 'model', 'autocompact', 'theme', 'effort'] }, async ($, e, next) => {
407 const result = await next(e)
408 // The level asked for, held until the next main request shows what the model was sent.
409 if (e.command === 'effort') await setTrack($, () => ({ effortAsked: e.args.trim() || null }))
410 if (e.command === 'theme') await readTheme($)
411 refreshSoon($)
412 return result
413 })
414
415 on('config.set', { key: ['theme', 'autoCompact'] }, async ($, e, next) => {
416 const result = await next(e)
417 if (result.deny === undefined) {
418 if (e.key === 'theme') await set($, () => ({ theme: themeOf(result.value) }))
419 else refreshSoon($)
420 }
421 return result
422 })
423}
424hooks/config-model.ts 280 lines1// The config model, pure: schema v1, the default, the presets, validation, merging the files' texts and
2// what Save writes. Loading, hand-edit checks and the atom live in the config hooks.
3import type { TidemarkConfig, TidemarkConfigState, TidemarkSectionId, TidemarkWidgetId, TidemarkWidgetItem } from '../types'
4
5export type Config = TidemarkConfig
6export type WidgetId = TidemarkWidgetId
7export type WidgetItem = TidemarkWidgetItem
8export type LoadedConfig = { config: Config; warnings: string[]; error?: string; sources: string[] }
9
10// What an option takes; `default` is what a widget uses when the option is absent.
11export type OptionSpec =
12 | { kind: 'bool'; default: boolean }
13 | { kind: 'enum'; values: readonly string[]; default: string }
14 | { kind: 'int'; min: number; max: number; default: number }
15 | { kind: 'string'; default: string }
16 | { kind: 'strings'; default: readonly string[] }
17
18const bool = (d: boolean): OptionSpec => ({ kind: 'bool', default: d })
19const int = (min: number, max: number, d: number): OptionSpec => ({ kind: 'int', min, max, default: d })
20const choice = (values: readonly string[], d: string): OptionSpec => ({ kind: 'enum', values, default: d })
21const MODE = choice(['used', 'left'], 'used')
22const QUOTA = { mode: MODE, showReset: bool(true), bar: bool(false) }
23
24// Each widget's own options. TTLs are in seconds, timeouts in milliseconds.
25export const WIDGET_OPTIONS: Record<WidgetId, Record<string, OptionSpec>> = {
26 context: { mode: MODE, barWidth: int(4, 20, 10), showTokens: bool(true), showGrowth: bool(true), showLast: bool(true) },
27 cache: { showRewrite: bool(true), showMinutes: bool(true), ttl: choice(['auto', '5m', '1h'], 'auto') },
28 quota5h: QUOTA,
29 quota7d: QUOTA,
30 cost: {},
31 agents: {},
32 goal: {},
33 project: {},
34 model: { showEffort: bool(true), format: choice(['short', 'full'], 'short'), cycle: { kind: 'strings', default: [] } },
35 git: { showDirty: bool(true), showDiff: bool(true), showSync: bool(true), maxLength: int(4, 200, 24), ttl: int(1, 3600, 5), fetch: int(0, 86_400, 300) },
36 cwd: { style: choice(['project', 'basename', 'short', 'full'], 'project') },
37 sessionTime: {},
38 compactions: { hideZero: bool(true) },
39 tokenSpeed: {},
40 command: { command: { kind: 'string', default: '' }, maxWidth: int(4, 200, 40), timeout: int(100, 30_000, 2000), ttl: int(1, 3600, 10) },
41 claudeStatus: { ttl: int(30, 3600, 300) },
42 gitPr: { githubHosts: { kind: 'strings', default: [] }, ttl: int(10, 3600, 120) },
43 flex: {},
44 compact: {},
45 actions: { config: bool(true) },
46}
47
48export const WIDGET_IDS = Object.keys(WIDGET_OPTIONS) as WidgetId[]
49const SECTION_IDS: TidemarkSectionId[] = ['context', 'cacheQuota', 'agents']
50export const SEPARATORS = ['pipe', 'space', 'dot', 'powerline', 'custom'] as const
51export const ICONS = ['text', 'nerd'] as const
52export const MAX_LINES = 3
53// The highest value of each alert threshold: percentages, and the cache warning in seconds.
54export const ALERT_MAX = { context: 100, quota5h: 100, quota7d: 100, cacheSeconds: 3600 } as const
55// The context share from which the band offers `/compact`: a heuristic, not a documented figure:
56// answers degrade as the window fills and auto-compaction waits until it is nearly full (967k of 1M).
57export const COMPACT_AT = 70
58
59// Where editors fetch `config.schema.json`, the JSON Schema of a config file; Save writes it as `$schema`.
60export const SCHEMA_URL = 'https://raw.githubusercontent.com/VizzleTF/claude-skills/main/plugins/tidemark/config.schema.json'
61
62const items = (...ids: WidgetId[]): WidgetItem[] => ids.map(widget => ({ widget }))
63
64export const DEFAULT_CONFIG: Config = {
65 version: 1,
66 lines: [items('project', 'context', 'compact', 'cache', 'flex', 'quota5h', 'quota7d', 'flex', 'sessionTime', 'git', 'model', 'cwd', 'actions')],
67 style: { separator: 'space', icons: 'nerd' },
68 pane: { sections: SECTION_IDS.map(id => ({ id, enabled: true })) },
69 alerts: { enabled: false, context: 80, quota5h: 90, quota7d: 90, cacheSeconds: 60 },
70 compact: { enabled: true, at: COMPACT_AT },
71}
72
73const preset = (lines: WidgetItem[][], style: Config['style'] = DEFAULT_CONFIG.style): Config => ({ ...DEFAULT_CONFIG, lines, style })
74
75export const PRESETS: Record<string, Config> = {
76 minimal: preset([items('context', 'quota5h')]),
77 classic: preset([items('context', 'cache', 'quota5h', 'quota7d')]),
78 default: DEFAULT_CONFIG,
79 full: preset([
80 items('project', 'goal', 'context', 'cache', 'quota5h', 'quota7d', 'cost'),
81 items('model', 'cwd', 'git', 'sessionTime', 'compactions', 'tokenSpeed', 'agents'),
82 ]),
83 powerline: preset(DEFAULT_CONFIG.lines, { separator: 'powerline', icons: 'nerd' }),
84}
85
86const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
87
88function optionOk(spec: OptionSpec, v: unknown): boolean {
89 switch (spec.kind) {
90 case 'bool': return typeof v === 'boolean'
91 case 'enum': return typeof v === 'string' && spec.values.includes(v)
92 case 'int': return Number.isInteger(v) && (v as number) >= spec.min && (v as number) <= spec.max
93 case 'string': return typeof v === 'string'
94 case 'strings': return Array.isArray(v) && v.every(s => typeof s === 'string')
95 }
96}
97
98function validItem(raw: unknown, where: string, warn: (m: string) => void): WidgetItem | undefined {
99 const obj = typeof raw === 'string' ? { widget: raw } : raw
100 if (!isObject(obj) || typeof obj.widget !== 'string' || !Object.hasOwn(WIDGET_OPTIONS, obj.widget)) {
101 warn(`${where}: unknown widget ${JSON.stringify(isObject(obj) ? obj.widget : obj)}`)
102 return undefined
103 }
104 const widget = obj.widget as WidgetId
105 const item: WidgetItem = { widget }
106 for (const [key, v] of Object.entries(obj)) {
107 if (key === 'widget') continue
108 if (key === 'enabled' && typeof v === 'boolean') item.enabled = v
109 else if (key === 'label' && (typeof v === 'string' || v === null)) item.label = v
110 else if (key === 'priority' && Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1000) item.priority = v as number
111 else if (key === 'options' && isObject(v)) {
112 const specs = WIDGET_OPTIONS[widget]
113 const options: Record<string, unknown> = {}
114 for (const [name, value] of Object.entries(v)) {
115 const spec = Object.hasOwn(specs, name) ? specs[name] : undefined
116 if (!spec) warn(`${where} ${widget}: unknown option ${name}`)
117 else if (!optionOk(spec, value)) warn(`${where} ${widget}: invalid ${name} ${JSON.stringify(value)}`)
118 else options[name] = value
119 }
120 if (Object.keys(options).length > 0) item.options = options
121 } else warn(`${where} ${widget}: invalid ${key} ${JSON.stringify(v)}`)
122 }
123 return item
124}
125
126// A raw value (parsed JSON) to a config: anything unknown or invalid is dropped with a warning and the
127// default stands in its place.
128export function validate(raw: unknown): { config: Config; warnings: string[] } {
129 const warnings: string[] = []
130 const warn = (m: string) => warnings.push(m)
131 const src = isObject(raw) ? raw : {}
132 if (!isObject(raw)) warn('config is not an object')
133 if (src.version !== undefined && src.version !== 1) warn(`unsupported version ${JSON.stringify(src.version)}`)
134
135 let lines = DEFAULT_CONFIG.lines
136 if (src.lines !== undefined) {
137 if (!Array.isArray(src.lines)) warn('lines: not a list')
138 else {
139 if (src.lines.length > MAX_LINES) warn(`lines: only the first ${MAX_LINES} are used`)
140 const got = src.lines.slice(0, MAX_LINES).map((line, i) => {
141 if (!Array.isArray(line)) {
142 warn(`line ${i + 1}: not a list`)
143 return []
144 }
145 return line.map(item => validItem(item, `line ${i + 1}`, warn)).filter(item => item !== undefined)
146 }).filter(line => line.length > 0)
147 if (got.length > 0) lines = got
148 else warn('lines: no widgets, the default is used')
149 }
150 }
151
152 const style = { ...DEFAULT_CONFIG.style }
153 if (src.style !== undefined) {
154 const s = isObject(src.style) ? src.style : {}
155 if (!isObject(src.style)) warn('style: not an object')
156 for (const [key, v] of Object.entries(s)) {
157 if (key === 'separator' && SEPARATORS.includes(v as never)) style.separator = v as Config['style']['separator']
158 else if (key === 'icons' && ICONS.includes(v as never)) style.icons = v as Config['style']['icons']
159 else if (key === 'custom' && typeof v === 'string') style.custom = v
160 else if (key === 'buttons' && typeof v === 'boolean') style.buttons = v
161 else warn(`style: invalid ${key} ${JSON.stringify(v)}`)
162 }
163 }
164
165 let pane = DEFAULT_CONFIG.pane
166 if (src.pane !== undefined) {
167 const sections = isObject(src.pane) && Array.isArray(src.pane.sections) ? src.pane.sections : undefined
168 if (!sections) warn('pane: sections must be a list')
169 else {
170 const seen = new Set<string>()
171 const got: Config['pane']['sections'] = []
172 for (const s of sections) {
173 if (isObject(s) && SECTION_IDS.includes(s.id as never) && !seen.has(s.id as string)) {
174 seen.add(s.id as string)
175 got.push({ id: s.id as TidemarkSectionId, enabled: s.enabled !== false })
176 } else warn(`pane: invalid section ${JSON.stringify(s)}`)
177 }
178 pane = { sections: got }
179 }
180 }
181
182 const alerts = { ...DEFAULT_CONFIG.alerts }
183 if (src.alerts !== undefined) {
184 const a = isObject(src.alerts) ? src.alerts : {}
185 if (!isObject(src.alerts)) warn('alerts: not an object')
186 for (const [key, v] of Object.entries(a)) {
187 const max = ALERT_MAX[key as keyof typeof ALERT_MAX]
188 if (key === 'enabled' && typeof v === 'boolean') alerts.enabled = v
189 else if (key !== 'enabled' && Object.hasOwn(alerts, key) && typeof v === 'number' && v >= 0 && v <= max) (alerts as Record<string, unknown>)[key] = v
190 else warn(`alerts: invalid ${key} ${JSON.stringify(v)}`)
191 }
192 }
193
194 const compact = { ...DEFAULT_CONFIG.compact }
195 if (src.compact !== undefined) {
196 const c = isObject(src.compact) ? src.compact : {}
197 if (!isObject(src.compact)) warn('compact: not an object')
198 for (const [key, v] of Object.entries(c)) {
199 if (key === 'enabled' && typeof v === 'boolean') compact.enabled = v
200 else if (key === 'at' && Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 100) compact.at = v as number
201 else warn(`compact: invalid ${key} ${JSON.stringify(v)}`)
202 }
203 }
204
205 for (const key of Object.keys(src)) {
206 if (!['$schema', 'version', 'lines', 'style', 'pane', 'alerts', 'compact'].includes(key)) warn(`unknown key ${key}`)
207 }
208 return { config: { version: 1, lines, style, pane, alerts, compact }, warnings }
209}
210
211// Where the files are: `$XDG_CONFIG_HOME/tidemark/config.json` (else `$HOME/.config/...`) and the session
212// directory's `.claude/tidemark.json`. Null where the place cannot be known.
213export function configPathsFrom(env: { xdg?: string; home?: string; cwd?: string }): ConfigPaths {
214 const { xdg, home, cwd } = env
215 const base = xdg ? xdg : home ? `${home.replace(/\/$/, '')}/.config` : undefined
216 return {
217 global: base ? `${base.replace(/\/$/, '')}/tidemark/config.json` : null,
218 project: cwd ? `${cwd.replace(/\/$/, '')}/.claude/tidemark.json` : null,
219 }
220}
221
222export type ConfigPaths = { global: string | null; project: string | null }
223
224// The files' texts (global first, a missing file left out) to the config in effect: the project file's
225// top-level keys over the global's, validated. A file that does not parse is skipped and named in `error`.
226export function loadFromTexts(files: ({ path: string; text: string } | { path: string; error: string })[]): LoadedConfig {
227 let merged: Record<string, unknown> = {}
228 const sources: string[] = []
229 const errors: string[] = []
230 for (const file of files) {
231 const { path } = file
232 let raw: unknown
233 try {
234 if ('error' in file) throw new Error(file.error)
235 raw = JSON.parse(file.text)
236 } catch (err) {
237 errors.push(`${path}: ${err instanceof Error ? err.message : String(err)}`)
238 continue
239 }
240 if (!isObject(raw)) {
241 errors.push(`${path}: not a JSON object`)
242 continue
243 }
244 if (raw.version !== undefined && raw.version !== 1) {
245 errors.push(`${path}: unsupported version ${JSON.stringify(raw.version)}`)
246 continue
247 }
248 merged = { ...merged, ...raw }
249 sources.push(path)
250 }
251 const { config, warnings } = validate(merged)
252 return { config, warnings, sources, ...(errors.length > 0 && { error: errors.join('; ') }) }
253}
254
255// What Save writes to `target`, so each file keeps to its own keys (the project file's over the global's):
256// to the global file, a top-level key the project file overrides and the draft left as loaded keeps the
257// global file's value; to the project file, only the keys it already has and those where the draft differs
258// from the global file. `globalOnly` is the global file alone, validated.
259export function saveContent(draft: Config, loaded: Config, globalOnly: Config, projectKeys: string[], target: 'global' | 'project'): Record<string, unknown> {
260 const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
261 const keys = ['lines', 'style', 'pane', 'alerts', 'compact'] as const
262 const out: Record<string, unknown> = { $schema: SCHEMA_URL, version: 1 }
263 for (const k of keys) {
264 if (target === 'global') out[k] = projectKeys.includes(k) && same(draft[k], loaded[k]) ? globalOnly[k] : draft[k]
265 else if (projectKeys.includes(k) || !same(draft[k], globalOnly[k])) out[k] = draft[k]
266 }
267 return out
268}
269
270// What a saved file holds.
271export const configText = (config: Config | Record<string, unknown>) => `${JSON.stringify(config, null, 2)}\n`
272
273// The config a `configState` value stands for: the default until the first load.
274export function effectiveConfig(s: TidemarkConfigState | null): LoadedConfig {
275 if (!s) return { config: DEFAULT_CONFIG, warnings: [], sources: [] }
276 const { stamp: _, ...loaded } = s
277 // Session state outlives a reload of the mod, so a config an older version stored lacks newer keys.
278 return { ...loaded, config: { ...DEFAULT_CONFIG, ...loaded.config } }
279}
280hooks/model-utils.ts 118 lines1// Pure helpers the snapshot and the widgets share. Nothing here calls `$`.
2
3// A model id without the window suffix (`claude-opus-5-5[1m]`).
4export const baseModel = (id: string | null | undefined) => (id ?? '').replace(/\[[^\]]*\]$/, '')
5
6// A cost the engine reports that can be shown: a finite, non-negative number.
7export const validCost = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0
8
9// The two prompt-cache lifetimes the API offers.
10export const SHORT_TTL_MS = 5 * 60_000
11export const LONG_TTL_MS = 60 * 60_000
12
13// The cache lifetime a main request proves, from the pause since the request before it: a cache read
14// back after more than five minutes lives an hour; one rewritten after five minutes to an hour lived
15// five. `prevLast` is what the request before sent (0 after a compaction, which proves nothing).
16export function inferTtl(gapMs: number, prevLast: number, read: number, write: number): number | null {
17 if (prevLast <= 0 || gapMs <= SHORT_TTL_MS) return null
18 if (read * 2 >= prevLast) return LONG_TTL_MS
19 if (write > 0 && gapMs < LONG_TTL_MS) return SHORT_TTL_MS
20 return null
21}
22
23// The lifetime to count down from: the cache widget's `ttl` option when pinned, else what the session knows.
24export const effectiveTtl = (known: number | null, option: unknown): number | null =>
25 option === '5m' ? SHORT_TTL_MS : option === '1h' ? LONG_TTL_MS : known
26
27const isOn = (v: string | undefined) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'
28const asTtl = (v: unknown) => (v === '5m' ? SHORT_TTL_MS : v === '1h' ? LONG_TTL_MS : undefined)
29
30// The lifetime Claude Code asks for when nothing else is known, by its documented order:
31// FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting,
32// ENABLE_PROMPT_CACHING_1H, then the account: a subscription (it reports a 5-hour or weekly window)
33// asks for an hour unless a window is spent and requests draw on usage credits; anything else, five minutes.
34export function defaultTtl(
35 env: { force5m?: string; ttl?: string; enable1h?: string },
36 setting: unknown,
37 limits: readonly { kind: string; percentUsed: number }[],
38): number {
39 if (isOn(env.force5m)) return SHORT_TTL_MS
40 const pinned = asTtl(env.ttl) ?? asTtl(setting)
41 if (pinned !== undefined) return pinned
42 if (isOn(env.enable1h)) return LONG_TTL_MS
43 const subscription = limits.some(l => l.kind === 'five_hour' || l.kind.startsWith('seven_day'))
44 if (subscription && !limits.some(l => l.percentUsed >= 100)) return LONG_TTL_MS
45 return SHORT_TTL_MS
46}
47
48// The levels when `/effort` does not say its own.
49export const EFFORTS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max']
50
51// The levels `/effort` lists, in its argument hint (`<low|medium|…|auto|ultracode [on|off]>`, or in square
52// brackets), its usage line or its answer to a wrong argument (`Valid options are: low, medium, …, auto, ultracode [on|off]`): `auto`
53// and a switch with arguments are no level. Empty when the text lists none.
54export function parseEfforts(text: string): string[] {
55 const list = /<([^>]*)>/.exec(text)?.[1] ?? /^\s*\[(.*)\]\s*$/.exec(text)?.[1] ?? /valid options are:\s*(.*)$/im.exec(text)?.[1]
56 if (!list) return []
57 const switches = [...list.matchAll(/([a-z]+)\s*\[[^\]]*\]/g)].map(m => m[1])
58 return list.replace(/\s*\[[^\]]*\]/g, '').split(/[|,]/).map(s => s.trim().replace(/\.$/, ''))
59 .filter(s => /^[a-z]+$/.test(s) && s !== 'auto' && !switches.includes(s))
60}
61
62// The effort after `current` in a press's cycle through `levels`, back to the first after the last; from
63// a number or none, `medium`, else the middle level.
64export function nextEffort(current: string | number | null, levels: readonly string[] = EFFORTS): string {
65 const i = levels.indexOf(current as never)
66 if (i >= 0) return levels[(i + 1) % levels.length]!
67 return levels.includes('medium') ? 'medium' : levels[Math.floor((levels.length - 1) / 2)]!
68}
69
70// The model after `current` in `cycle` (aliases as `/model` takes them: `opus[1m]`, `sonnet`): the entry
71// whose family and window suffix match the running model is the current one; none matching, the first.
72export function nextModel(current: string | null, cycle: readonly string[]): string | undefined {
73 if (cycle.length === 0) return undefined
74 const id = (current ?? '').toLowerCase()
75 const suffix = /\[[^\]]*\]$/.exec(id)?.[0] ?? ''
76 const at = (strict: boolean) => cycle.findIndex(m => {
77 const family = baseModel(m.toLowerCase()).replace(/^claude-/, '').split('-')[0]!
78 return id.includes(family) && (!strict || (/\[[^\]]*\]$/.exec(m.toLowerCase())?.[0] ?? '') === suffix)
79 })
80 const i = at(true) >= 0 ? at(true) : at(false)
81 return cycle[(i + 1) % cycle.length]
82}
83
84// The models Claude Code offers (the `/config` model row's options) as a cycle: without the settings that
85// name no one model (`default`, `best`, `opusplan`), and on the running model's side of the 1M window:
86// a family's `[1m]` alias in place of its plain one while the running model has it, the plain ones otherwise.
87export function autoCycle(offered: readonly string[], current: string | null): string[] {
88 const named = offered.filter(m => !['default', 'best', 'opusplan'].includes(m))
89 const long = /\[1m\]$/i.test(current ?? '')
90 return named.filter(m => {
91 const isLong = /\[1m\]$/i.test(m)
92 return long ? isLong || !named.includes(`${m}[1m]`) : !isLong
93 })
94}
95
96// What requests showed of each model's effort levels, for one Claude Code version: the levels asked for
97// that a model sent lower, by its base id.
98export type EffortSupport = { version: string; skip: Record<string, string[]> }
99
100// The record after a main request: `asked` the level `/effort` set, `sent` what the request carried. A level
101// sent as asked is taken off the model's skip list, one sent as another level put on it; a record of
102// another version starts over.
103export function learnEffort(saved: EffortSupport | undefined, version: string, model: string, asked: string, sent: string | number | undefined): EffortSupport {
104 const base = saved?.version === version ? saved : { version, skip: {} }
105 if (typeof sent !== 'string' || !/^[a-z]+$/.test(asked) || asked === 'auto') return base
106 const id = baseModel(model)
107 const skip = (base.skip[id] ?? []).filter(l => l !== asked)
108 if (sent !== asked) skip.push(asked)
109 return { version, skip: { ...base.skip, [id]: skip } }
110}
111
112// The levels a press steps through on `model`: `levels` without the ones its requests sent lower.
113export const usableEfforts = (levels: readonly string[], saved: EffortSupport | undefined, version: string, model: string | null) => {
114 const skip = saved?.version === version ? (saved.skip[baseModel(model)] ?? []) : []
115 const usable = levels.filter(l => !skip.includes(l))
116 return usable.length > 0 ? usable : levels
117}
118hooks/widgets/index.ts 32 lines1// The widget catalogue: every widget id to its definition. Each render is pure (snapshot and probes in,
2// variants out), so the band, the editor's preview and the tests share it.
3import type { TidemarkWidgetId } from '../../types'
4import type { WidgetDef } from './kit'
5import { agents } from './agents'
6import { cache } from './cache'
7import { claudeStatus } from './claudeStatus'
8import { command } from './command'
9import { actions } from './actions'
10import { compact } from './compact'
11import { compactions } from './compactions'
12import { context } from './context'
13import { cost } from './cost'
14import { cwd } from './cwd'
15import { flex } from './flex'
16import { git } from './git'
17import { gitPr } from './gitPr'
18import { model } from './model'
19import { goal, project } from './note'
20import { quota5h } from './quota5h'
21import { quota7d } from './quota7d'
22import { sessionTime } from './sessionTime'
23import { tokenSpeed } from './tokenSpeed'
24
25export type { BandEnv, Press, Role, Span, Variant, WidgetDef, WidgetInput } from './kit'
26export { gainTier, pctTier } from './kit'
27
28export const WIDGETS: Record<TidemarkWidgetId, WidgetDef> = {
29 context, cache, quota5h, quota7d, cost, agents, model, git, cwd, sessionTime, compactions, tokenSpeed, command, claudeStatus, gitPr, flex,
30 compact, actions, goal, project,
31}
32hooks/widgets/kit.ts 185 lines1// What every widget shares: the span and input types, the colour scale's tiers, number and time formats.
2// Scale and formulas after ccOverhead (MIT, shengyy).
3import type { TidemarkLimit, TidemarkProbes, TidemarkSnapshot, TidemarkWidgetId } from '../../types'
4import { WIDGET_OPTIONS } from '../config-model'
5import type { OptionSpec } from '../config-model'
6
7export type Role = 'label' | 'dim' | 'money' | 'activity'
8
9// What a press on a span does; the band runs it. `model` and `effort` cycle the setting, `context` and
10// `usage` run that command, `cache` opens the details pane, `compact` compacts, `config` opens the editor.
11export type Press = 'model' | 'effort' | 'context' | 'usage' | 'cache' | 'compact' | 'config'
12
13// One run of text. `tier` is its colour on the ten-tier scale (0–9); `bar` and `spark` mark a graphic the
14// terminal draws as glyphs (`text`) and the desktop as an Svg.
15export type Span = {
16 text: string
17 role?: Role
18 tier?: number
19 bar?: { percent: number; dim?: boolean }
20 // Running agents: drawn as that many animated spinners (up to three), the text's `+N` after them.
21 activity?: number
22 spark?: { values: number[]; tiers: number[] }
23 // Drawn as a borderless button that runs this.
24 press?: Press
25 // Drawn as a link that opens this URL.
26 href?: string
27}
28// A widget drawn one way; a widget gives its variants from the fullest to the narrowest.
29export type Variant = Span[]
30
31// What the band knows beside the snapshot: the session directory, the home directory, the subagent on screen.
32export type BandEnv = { cwd?: string; home?: string; agentId?: string }
33
34export type WidgetInput = {
35 snap: TidemarkSnapshot
36 probes: TidemarkProbes
37 options: Record<string, unknown>
38 // Absent: the widget's own label (text or nerd icon); null: no label.
39 label: string | null | undefined
40 icons: 'text' | 'nerd'
41 now: number
42 env: BandEnv
43}
44
45export type WidgetDef = {
46 id: TidemarkWidgetId
47 title: string
48 defaultPriority: number
49 options: Record<string, OptionSpec>
50 // Labels by icon style; null where the widget has none.
51 labels: { text: string | null; nerd: string | null }
52 // What a press on the widget's label does.
53 press?: Press
54 render(input: WidgetInput): Variant[]
55}
56
57// A widget definition with its options from the config schema.
58export const widget = (id: TidemarkWidgetId, def: Omit<WidgetDef, 'id' | 'options' | 'defaultPriority'> & { defaultPriority?: number }): WidgetDef =>
59 ({ id, options: WIDGET_OPTIONS[id], defaultPriority: 20, ...def })
60
61// An option's value, its default when absent. Takes a widget or a config item with its widget's options.
62export function opt<T>(def: { options: Record<string, OptionSpec> }, input: { options?: Record<string, unknown> }, name: string): T {
63 return (input.options?.[name] ?? def.options[name]?.default) as T
64}
65
66// The label in effect, as the first span; `glue` sets it right before the value without a space.
67export function labelled(def: WidgetDef, input: WidgetInput, spans: Span[], glue = false, override?: string): Span[] {
68 const label = input.label === undefined ? (override ?? def.labels[input.icons]) : input.label
69 if (!label || spans.length === 0) return spans
70 const [first, ...rest] = spans
71 return [{ text: label, role: 'label', ...(def.press && { press: def.press }) }, { ...first!, text: `${glue ? '' : ' '}${first!.text}` }, ...rest]
72}
73
74// Variants without the repeats a dropped detail that was not there leaves behind.
75export function distinct(vs: Variant[]): Variant[] {
76 const key = (v: Variant) => JSON.stringify(v)
77 return vs.filter((v, i) => v.length > 0 && (i === 0 || key(v) !== key(vs[i - 1]!)))
78}
79
80// A gain's tier: 0 below 0.1% of the window, one more per doubling, 9 from 25.6%.
81export function gainTier(gain: number, window: number): number {
82 let t = 0
83 while (t < 9 && gain * 1000 >= window * 2 ** t) t++
84 return t
85}
86
87// A used percentage's tier: 2 below 30%, one per 10% after, 9 from 90%.
88export const pctTier = (p: number) => Math.min(Math.max(Math.floor(p / 10), 2), 9)
89
90// Countdown: 2d7h, 2h34m, 41m (floored, never negative).
91export function dur(ms: number): string {
92 const s = Math.max(Math.floor(ms / 1000), 0)
93 if (s >= 86_400) return `${Math.floor(s / 86_400)}d${Math.floor((s % 86_400) / 3_600)}h`
94 if (s >= 3_600) return `${Math.floor(s / 3_600)}h${Math.floor((s % 3_600) / 60)}m`
95 return `${Math.floor(s / 60)}m`
96}
97
98// Whole k/M, floored: 271k, 1M.
99export function ktok(t: number): string {
100 if (t >= 1_000_000) return `${Math.floor(t / 1_000_000)}M`
101 if (t >= 1_000) return `${Math.floor(t / 1_000)}k`
102 return String(Math.floor(t))
103}
104
105// One decimal for deltas: 3.4k, 40k, 1.2M.
106export function kshort(t: number): string {
107 // From 999_950 a rounded k would read 1000k, so M starts there.
108 const [x, u] = t >= 999_950 ? [Math.round(t / 100_000), 'M'] : t >= 1_000 ? [Math.round(t / 100), 'k'] : [t, '']
109 if (!u) return String(Math.floor(x))
110 return x % 10 === 0 ? `${x / 10}${u}` : `${Math.floor(x / 10)}.${x % 10}${u}`
111}
112
113// `width` cells filled to the nearest one: ■■■□□□□□□□.
114export function bar(p: number, width: number): string {
115 const filled = Math.max(Math.min(Math.floor((p * width) / 100 + 0.5), width), 0)
116 return '■'.repeat(filled) + '□'.repeat(width - filled)
117}
118
119export const gains = (history: number[]) => history.slice(1).map((t, i) => Math.max(t - history[i]!, 0))
120
121export function sparkline(values: number[]): string {
122 const top = Math.max(...values, 1)
123 return values.map(v => '▁▂▃▄▅▆▇█'[Math.min(Math.floor((v * 7) / top), 7)]).join('')
124}
125
126// A quota window still open: its reset is ahead, or it reports none.
127export function openWindow(l: TidemarkLimit | undefined, now: number): { limit: TidemarkLimit; resets?: number } | undefined {
128 if (!l) return undefined
129 if (l.resetsAt === undefined) return { limit: l }
130 const resets = Date.parse(l.resetsAt)
131 return resets > now ? { limit: l, resets } : undefined
132}
133
134// At most `n` characters, the last one an ellipsis when cut.
135export const cut = (s: string, n: number) => ([...s].length > n ? `${[...s].slice(0, Math.max(n - 1, 0)).join('')}…` : s)
136
137// Spans one space apart.
138export const spaced = (spans: (Span | undefined | false)[]): Span[] =>
139 spans.filter((s): s is Span => !!s).map((s, i) => (i === 0 ? s : { ...s, text: ` ${s.text}` }))
140
141const FAMILIES = ['fable', 'opus', 'sonnet', 'haiku']
142
143// The family a model id names (`claude-fable-5-1` → `fable`).
144export const modelFamily = (model: string | null) => FAMILIES.find(f => (model ?? '').toLowerCase().includes(f))
145
146const HOUR = 3_600_000
147
148// A quota window by its kind: its label, its length when known, and `own`, the model's family when it is
149// that model's own week (a kind naming the family; for Fable a `scoped` one too, after ccOverhead). A week
150// naming another family is labelled by that family (`7d opus`).
151export function quotaWindow(kind: string, model: string | null): { label: string; ms?: number; own?: string } {
152 if (kind === 'five_hour') return { label: '5h', ms: 5 * HOUR }
153 if (kind === 'seven_day') return { label: '7d', ms: 7 * 24 * HOUR }
154 const k = kind.toLowerCase()
155 const family = modelFamily(model)
156 if (family !== undefined && (k.includes(family) || (family === 'fable' && k.includes('scoped')))) return { label: `7d ${family}`, ms: 7 * 24 * HOUR, own: family }
157 const named = FAMILIES.find(f => k.includes(f))
158 if (named !== undefined) return { label: `7d ${named}`, ms: 7 * 24 * HOUR }
159 return k.startsWith('seven_day') || k.includes('weekly') ? { label: kind, ms: 7 * 24 * HOUR } : { label: kind }
160}
161
162// The weekly window the band and the alerts follow: the model's own week (one naming the family wins over
163// Fable's `scoped` one), else the all-models week.
164export function weekLimit(limits: TidemarkLimit[], model: string | null): TidemarkLimit | undefined {
165 const family = modelFamily(model)
166 const owns = limits.filter(l => quotaWindow(l.kind, model).own)
167 return owns.find(l => l.kind.toLowerCase().includes(family!)) ?? owns[0] ?? limits.find(l => l.kind === 'seven_day')
168}
169
170// A quota window as a widget: percent (used or left, tier always by the share used), reset, a short bar.
171// A window whose reset passed is hidden, one without a reset shows none; a figure not yet this session's own is dim.
172export function quota(def: WidgetDef, input: WidgetInput, l: TidemarkLimit | undefined, label?: string): Variant[] {
173 const w = openWindow(l, input.now)
174 if (!w) return []
175 const p = Math.trunc(w.limit.percentUsed)
176 const left = opt<string>(def, input, 'mode') === 'left'
177 const shown = left ? 100 - p : p
178 const live = input.snap.limitsLive
179 const ink: Partial<Span> = live ? { tier: pctTier(p) } : { role: 'dim' }
180 const pct: Span = { text: `${shown}%${left ? ' left' : ''}`, ...ink }
181 const barSpan: Span | false = opt<boolean>(def, input, 'bar') && { text: bar(shown, 5), ...ink, bar: { percent: shown, ...(!live && { dim: true }) } }
182 const reset: Span | false = w.resets !== undefined && opt<boolean>(def, input, 'showReset') && { text: `↻ ${dur(w.resets - input.now)}`, role: 'dim' }
183 return distinct([spaced([barSpan, pct, reset]), spaced([barSpan, pct]), [pct]].map(v => labelled(def, input, v, false, label)))
184}
185hooks/draw.tsx 273 lines1// Lines of spans as element trees. The terminal draws monospace text: glyph bars, each sparkline glyph in
2// its own colour, powerline arrows. The desktop draws a proportional font: rows spaced by `gap`, bars and
3// sparklines as Svg, powerline segments as coloured Boxes. Palette after ccOverhead (MIT, shengyy).
4import type { Elements, RenderChildren } from 'claude-code'
5
6import type { TidemarkTheme } from '../types'
7import type { Line, Segment, Style } from './layout'
8import { separatorText } from './layout'
9import type { Press, Span } from './widgets'
10import { SPINNERS, spinnerSvg } from './draw-spinner'
11
12type Ink = { dark: string; light: string }
13type Term = Pick<Elements['terminal'], 'Box' | 'Text'> & Partial<Pick<Elements['terminal'], 'Client' | 'Button' | 'Link'>>
14type Rich = Pick<Elements['desktop'], 'Box' | 'Text' | 'Svg'> & Partial<Pick<Elements['desktop'], 'Button' | 'Link'>>
15// What a press on a span runs; absent, presses draw as plain text (the editor's preview).
16export type OnPress = (press: Press) => void
17export type Surface = 'terminal' | 'desktop' | 'vscode' | 'mobile'
18
19// The ten-tier scale, cool (safe) to warm (warning).
20export const TIERS: Ink[] = [
21 { dark: '#5965cd', light: '#4c55bc' },
22 { dark: '#4087de', light: '#266ec3' },
23 { dark: '#37aae3', light: '#0076a8' },
24 { dark: '#35c5db', light: '#007a8b' },
25 { dark: '#49d6cc', light: '#007c74' },
26 { dark: '#b8e45c', light: '#567a00' },
27 { dark: '#f9e149', light: '#856d00' },
28 { dark: '#fea92f', light: '#a05f00' },
29 { dark: '#fd7933', light: '#bc4c00' },
30 { dark: '#ed4b43', light: '#bb0916' },
31]
32export const MONEY: Ink = { dark: '#dfbc70', light: '#8a6215' }
33const DIM: Ink = { dark: '#898781', light: '#6f6d68' }
34const TRACK = 'rgba(137,135,129,0.3)'
35const ACTIVITY_TIER = 5
36
37// Powerline backgrounds of the widgets that have no tier, and the text on a tier's background.
38const NEUTRAL: Record<string, Ink> = {
39 model: { dark: '#3a3f4b', light: '#d9dde6' },
40 git: { dark: '#2f4a3f', light: '#d3e6dc' },
41 cwd: { dark: '#3d3a52', light: '#dedbef' },
42 time: { dark: '#4a3d33', light: '#ecdfd3' },
43}
44const NEUTRAL_OF: Record<string, string> = { gitPr: 'git', sessionTime: 'time', compactions: 'time', tokenSpeed: 'time', command: 'time', claudeStatus: 'time' }
45const ON_TIER = '#1a1a1a'
46
47// The fixed scale's light palette for a light theme, the dark one for any other or none.
48const mode = (theme: TidemarkTheme | string | undefined): keyof Ink => (/light/i.test(theme ?? '') ? 'light' : 'dark')
49
50export const tierColor = (tier: number, theme: TidemarkTheme): string => TIERS[tier]![mode(theme)]
51
52// A span's text colour; undefined for plain text.
53export function colorOf(s: Span, theme: TidemarkTheme): string | undefined {
54 if (s.role === 'money') return MONEY[mode(theme)]
55 const tier = s.tier ?? (s.role === 'activity' ? ACTIVITY_TIER : undefined)
56 return tier === undefined ? undefined : tierColor(tier, theme)
57}
58
59// A powerline segment's background: its figure's tier, else its widget's neutral colour; and its text colour.
60function plaque(seg: Segment, theme: TidemarkTheme): { bg: string; fg?: string } {
61 const tier = seg.spans.find(s => s.tier !== undefined && !s.spark)?.tier
62 if (tier !== undefined) return { bg: TIERS[tier]![mode(theme)], fg: ON_TIER }
63 return { bg: NEUTRAL[NEUTRAL_OF[seg.widget] ?? seg.widget]?.[mode(theme)] ?? NEUTRAL.model![mode(theme)] }
64}
65
66// The Svg's colours as classes, the light ones under the image's own media query.
67function inks(classes: [string, Ink][]): string {
68 const rules = (m: keyof Ink) => classes.map(([n, ink]) => `.${n}{fill:${ink[m]}}`).join('')
69 return `<style>${rules('dark')}@media (prefers-color-scheme: light){${rules('light')}}</style>`
70}
71const svg = (w: number, h: number, style: string, body: string) =>
72 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${style}${body}</svg>`
73
74// A bar as a 60×6 track filled to its percentage; a sparkline as 4 px columns on one baseline.
75function graphic(s: Span, widget: string): { source: string; alt: string; width: number; height: number } | undefined {
76 if (s.bar) {
77 const ink = s.bar.dim ? DIM : (TIERS[s.tier ?? -1] ?? DIM)
78 const w = Math.round((Math.min(Math.max(s.bar.percent, 0), 100) * 60) / 100)
79 const body = `<rect x="0" y="0" width="60" height="6" rx="3" fill="${TRACK}"/>${w > 0 ? `<rect class="k" x="0" y="0" width="${w}" height="6" rx="3"/>` : ''}`
80 return { source: svg(60, 6, inks([['k', ink]]), body), alt: `${widget} ${s.bar.percent}% used`, width: 60, height: 6 }
81 }
82 if (s.spark && s.spark.values.length > 0) {
83 const { values, tiers } = s.spark
84 const top = Math.max(...values, 1)
85 const width = values.length * 6 - 2
86 const cols = values.map((v, i) => {
87 const h = Math.max(Math.round((v * 14) / top), 3)
88 return `<rect class="t${tiers[i] ?? 0}" x="${i * 6}" y="${14 - h}" width="4" height="${h}" rx="1"/>`
89 }).join('')
90 return { source: svg(width, 14, inks(TIERS.map((ink, t) => [`t${t}`, ink])), cols), alt: `${widget} growth in each of the last ${values.length} changes`, width, height: 14 }
91 }
92 return undefined
93}
94
95// A span that does something as its element: a borderless Button for a press, a Link for a URL; undefined
96// for plain text, or with no `act` (presses off, the editor's preview).
97function control({ Button, Link }: { Button?: Elements['terminal']['Button']; Link?: Elements['terminal']['Link'] }, s: Span, widget: string, act?: OnPress) {
98 if (!act) return undefined
99 const label = s.text.trim()
100 const press = s.press
101 if (press && Button) return <Button key={`tidemark-${widget}-${press}`} label={label} plain dimColor={s.role === 'dim'} onPress={() => act(press)} />
102 if (s.href && Link) return <Link href={s.href} label={label} />
103 return undefined
104}
105
106// Spans as nested Text; a sparkline glyph by glyph in its tiers' colours. `fg` overrides every colour.
107function textRun({ Text }: Term, spans: Span[], theme: TidemarkTheme, fg?: string) {
108 return spans.filter(s => s.text).map(s => {
109 if (s.spark && fg === undefined) {
110 const glyphs = [...s.text]
111 const lead = glyphs.length - s.spark.tiers.length
112 return <Text>{glyphs.map((g, k) => <Text color={k < lead ? undefined : tierColor(s.spark!.tiers[k - lead] ?? 0, theme)}>{g}</Text>)}</Text>
113 }
114 return <Text color={fg ?? colorOf(s, theme)} dimColor={fg === undefined && s.role === 'dim'}>{s.text}</Text>
115 })
116}
117
118// Spans as a row's items: text trimmed, bars and sparklines as Svg.
119function itemRun(els: Rich, spans: Span[], widget: string, theme: TidemarkTheme, fg?: string, act?: OnPress) {
120 const { Text, Svg } = els
121 return spans.flatMap(s => {
122 const c = control(els as never, s, widget, act)
123 if (c) return [c]
124 if (s.activity !== undefined) {
125 const rest = [...s.text.trim()].slice(Math.min(s.activity, SPINNERS)).join('')
126 const ink = TIERS[s.tier ?? 5]!
127 return [<Svg {...spinnerSvg(s.activity, ink)} />, ...(rest ? [<Text color={fg ?? colorOf(s, theme)}>{rest}</Text>] : [])]
128 }
129 const g = graphic(s, widget)
130 if (g) return [<Svg {...g} />]
131 const text = s.text.trim()
132 return text ? [<Text color={fg ?? colorOf(s, theme)} dimColor={fg === undefined && s.role === 'dim'}>{text}</Text>] : []
133 })
134}
135
136type Part = { inline: RenderChildren } | { block: RenderChildren }
137
138// Inline parts joined into one truncating Text; a line with a spinner Client (which a Text may not hold)
139// becomes a row of them.
140function joinParts({ Box, Text }: Term, parts: Part[]) {
141 const groups: RenderChildren[] = []
142 let run: RenderChildren[] = []
143 const flush = () => { if (run.length) groups.push(<Text wrap="truncate-end">{run}</Text>); run = [] }
144 for (const p of parts) {
145 if ('inline' in p) run.push(p.inline)
146 else { flush(); groups.push(p.block) }
147 }
148 flush()
149 return groups.length === 1 ? groups[0] : <Box flexDirection="row">{groups}</Box>
150}
151
152// A segment's parts: its text, with an activity span's spinners as a Client and its `+N` as text.
153function segmentParts(els: Term, spans: Span[], theme: TidemarkTheme, plaqueOf?: { bg: string; fg?: string }, act?: OnPress, widget = ''): Part[] {
154 const { Text, Client } = els
155 const parts: Part[] = []
156 const text = (t: string) => { if (t) parts.push({ inline: <Text backgroundColor={plaqueOf!.bg} color={plaqueOf!.fg}>{t}</Text> }) }
157 let pending = plaqueOf ? ' ' : ''
158 for (const s of spans) {
159 const n = s.activity
160 if (n !== undefined && Client) {
161 const glyphs = Math.min(n, SPINNERS)
162 const lead = s.text.match(/^ */)![0]
163 const rest = [...s.text.trimStart()].slice(glyphs).join('')
164 const color = plaqueOf?.fg ?? colorOf(s, theme)
165 if (plaqueOf) text(pending + lead)
166 else if (lead) parts.push({ inline: <Text>{lead}</Text> })
167 pending = ''
168 parts.push({ block: <Client key="tidemark-spinner" module="./draw-activity.ts" width={glyphs} height={1}
169 props={{ count: glyphs, ...(color !== undefined && { color }), ...(plaqueOf && { bg: plaqueOf.bg }) }} /> })
170 if (plaqueOf) pending = rest
171 else if (rest) parts.push({ inline: <Text color={color}>{rest}</Text> })
172 } else if (plaqueOf) pending += s.text
173 // ponytail: a Button takes no background, so on a powerline plaque a press stays plain text.
174 else if (control(els, s, widget, act)) {
175 const lead = s.text.match(/^ */)![0]
176 if (lead) parts.push({ inline: <Text>{lead}</Text> })
177 parts.push({ block: control(els, s, widget, act) })
178 } else parts.push(...textRun(els, [s], theme).map(inline => ({ inline })))
179 }
180 if (plaqueOf) text(`${pending} `)
181 return parts
182}
183
184function terminalLine(els: Term, line: Line, style: Style, theme: TidemarkTheme, act?: OnPress) {
185 const { Text } = els
186 const parts: Part[] = []
187 if (style.separator === 'powerline') {
188 const arrow = style.icons === 'nerd' ? '\ue0b0' : '▶'
189 const plaques = line.map(seg => plaque(seg, theme))
190 line.forEach((seg, i) => {
191 parts.push(...segmentParts(els, seg.spans, theme, plaques[i]))
192 parts.push({ inline: <Text color={plaques[i]!.bg} backgroundColor={plaques[i + 1]?.bg}>{arrow}</Text> })
193 })
194 } else {
195 const sep = separatorText(style)
196 line.forEach((seg, i) => {
197 if (i > 0) parts.push({ inline: <Text dimColor>{sep}</Text> })
198 parts.push(...segmentParts(els, seg.spans, theme, undefined, act, seg.widget))
199 })
200 }
201 return joinParts(els, parts)
202}
203
204function richLine(els: Rich, line: Line, style: Style, theme: TidemarkTheme, act?: OnPress) {
205 const { Box, Text } = els
206 const power = style.separator === 'powerline'
207 const sep = separatorText(style).trim()
208 return (
209 <Box flexDirection="row" alignItems="center" gap={1}>
210 {line.flatMap((seg, i) => {
211 const p = power ? plaque(seg, theme) : undefined
212 return [
213 ...(i > 0 && sep ? [<Text dimColor>{sep}</Text>] : []),
214 <Box flexDirection="row" alignItems="center" gap={1} {...(p && { backgroundColor: p.bg, paddingX: 1 })}>
215 {itemRun(els, seg.spans, seg.widget, theme, p?.fg, p ? undefined : act)}
216 </Box>,
217 ]
218 })}
219 </Box>
220 )
221}
222
223// The band: one row per line. `els` is the surface's element table (`$.ui.resolve(e)`); branch on the
224// surface, since the terminal's table answers for `Svg` with a placeholder.
225export function drawBand(els: Term | Rich, surface: Surface, lines: Line[], style: Style, theme: TidemarkTheme, act?: OnPress) {
226 const { Box } = els
227 return (
228 <Box flexDirection="column" paddingX={1} marginTop={surface === 'terminal' ? 1 : 0}>
229 {lines.map(line => {
230 const draw = (part: Line) => (surface === 'terminal' ? terminalLine(els as Term, part, style, theme, act) : richLine(els as Rich, part, style, theme, act))
231 // `flex` segments split the line into groups spread across it: one flex puts what follows at the
232 // right edge, two put the part between them in the middle, with equal gaps on both sides.
233 const groups: Line[] = [[]]
234 for (const seg of line) seg.widget === 'flex' ? groups.push([]) : groups[groups.length - 1]!.push(seg)
235 if (groups.length === 1) return draw(line)
236 return (
237 <Box flexDirection="row" justifyContent="space-between">
238 {groups.map(g => (g.length > 0 ? draw(g) : <Box />))}
239 </Box>
240 )
241 })}
242 </Box>
243 )
244}
245
246// A pane row: a heading, or a label and its spans.
247export type PaneRow = { head: string } | { label: string; spans: Span[] }
248const LABEL = 14
249
250// The pane's rows: bold headings; labels in a fixed-width column, then their spans.
251export function drawPaneLines(els: Term | Rich, surface: Surface, rows: PaneRow[], theme: TidemarkTheme) {
252 const { Box, Text } = els
253 return (
254 <Box flexDirection="column" paddingX={1}>
255 {rows.map((r, i) => 'head' in r
256 ? <Box marginTop={i > 0 ? 1 : 0}><Text bold>{r.head}</Text></Box>
257 : (
258 <Box flexDirection="row" gap={surface === 'terminal' ? 0 : 1}>
259 <Box width={LABEL} flexShrink={0}><Text dimColor>{r.label}</Text></Box>
260 {surface === 'terminal'
261 ? <Text wrap="truncate-end">{textRun(els as Term, r.spans, theme)}</Text>
262 : <Box flexDirection="row" alignItems="center" flexWrap="wrap" gap={1}>{itemRun(els as Rich, r.spans, r.label, theme)}</Box>}
263 </Box>
264 ))}
265 </Box>
266 )
267}
268
269// The mobile app draws no Input or Select, so the editor and the note fields send it elsewhere.
270export function noFields({ Box, Text }: Pick<Term, 'Box' | 'Text'>) {
271 return <Box><Text dimColor>Open this in the terminal or the desktop app: the mobile app has no input fields.</Text></Box>
272}
273