SLOPSHOPPER

tidemark

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

newpanebandcommandpromptprocess
v0.2.1MITupdated 2026-10-08VizzleTF/claude-skills/plugins/tidemark
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tidemark
│ ┃ tidemark ✕ › fix the failing auth test and add an audit log call │ ┃ Context │ ┃ window ■■■■■□□□□□ 49% 97k of 200k ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Cache & quota ⏺ Update(src/auth.ts) │ ┃ 5h ■■□□□ 31% ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Session & agents ⎿ 3 pass, 1 fail │ ┃ session ID preview-session │ ┃ model claude-opus-5-5 ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tidemark │ │ ■■■■■□□□□□ 49% 97k/200k /compact  5h 31%  0m  opus 5.5  app ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
■■■■■□□□□□ 49% 97k/200k /compact  5h 31%  0m  opus 5.5  app ⟨Claude Code's own drawing⟩
Pane · tidemark
Context window ■■■■■□□□□□ 49% 97k of 200k Cache & quota 5h ■■□□□ 31% Session & agents session ID preview-session model claude-opus-5-5
Pane · tidemark config
Preview ■■■■■□□□□□ 49% 97k/200k  5h 31% sources: default Lines 1 [ project ] [ context ] [ compact ] [ cache ] [ flex ] [ q Add widget add: Context ▾ Style separator: space ▾ icons: nerd font ▾ buttons: on ▾ Pane sections context [ on ] [ ↑ ] cacheQuota [ on ] [ ↑ ] agents [ on ] [ ↑ ] Alerts toasts: off ▾ context %: 80 ⏎ set 5h quota %: 90 ⏎ set 7d quota %: 90 ⏎ set cache cold in, s: 60 ⏎ set Compact button button: on ▾ from context %: 70 ⏎ set Presets preset: minimal ▾ Save save to: global ▾ [ Save ] [ Revert ]
Pane · tidemark goal
goal: what this session is for ⏎ set Enter sets it, an empty field clears it, Esc closes
Pane · tidemark project
project: the project name, kept for this directory ⏎ set Enter sets it, an empty field clears it, Esc closes
Pane · tidemark-raw
ctx: {"window":200000,"model":"claude-opus-5-5","tokens":97400,"p ercent":49} history: [97400] compactions: 0 compactionLog: [] breakdown: null limits: [{"kind":"five_hour","percentUsed":31}] limitsLive: true limitsAt: 1760000000220 cache: null cacheTtl: null ttlDefault: 3600000 cacheStats: {"input":0,"output":0,"read":0,"write":0,"last":0} model: "claude-opus-5-5" effort: null agents: [] activeAgents: 0 cost: 0.42 turnCost: 0 sessionId: "preview-session" startedAt: 1760000000020 speed: null theme: "dark" goal: null project: null
README

tidemark

ci

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.

The tidemark band: context 4% of 1M, 5-hour quota 28%, weekly quota 86%, git branch and model

Contents

Install

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.

Commands

CommandOpens
/tidemarkDetails pane: context by source, cache and quota with a forecast to reset, the session and its subagents
/tidemark-configConfig 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">

Widgets

The Default column marks the widgets of the band you get without a config file.

WidgetShowsDefault
contextctx bar, percent, used/window, growth sparkline✓
cachewarm 38m, cold, rewrote 45k✓
quota5h5h 42% ↻ 2h34m✓
quota7d7d 63% ↻ 2d7h✓
modelopus 5.5 · high; a press switches the model or the effort✓
gitbranch, * for uncommitted changes, sync with the upstream, +12 −3✓
goaltext from /tidemark-goal; hidden until set
projecttext from /tidemark-project; hidden until set✓
compacta /compact button✓
actionsa ⚙ button that opens /tidemark-config✓
flexnothing; splits the row into groups: left, middle, right✓
cost≈$1.84 (+$0.12): the session and the current turn
agentsa spinner per running subagent
cwdproject name, directory name or path✓
sessionTime⏱ 1h12m since the conversation started✓
compactions⇣2
tokenSpeed42 t/s for the last turn
commandthe 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.

Configure

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 toFile
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.

Uninstall

/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.

What it runs, reads and writes

tidemark has no telemetry and sends nothing to its author. It never changes Claude Code settings or the permission mode.

Hooks

HookWhat it does
Session events: start, each turn and model request, usage measurements, compaction, subagent starts, model switches, prompt submissionReads what happened to keep the band current and passes the event on unchanged
/config changes of theme and autoCompactSame
Built-in commands /clear, /resume, /branch, /model, /autocompact, /theme, /effortSame
command.describe on /effortReads the command's argument hint
command.run on /tidemark, /tidemark-config, /tidemark-goal, /tidemark-projectAnswers the four commands the mod adds
ui.renderDraws 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.

Programs

Each runs in the session's directory.

ProgramWhyWhen
git status, git diff --numstat HEADbranch, changes and diff size for the git widgetafter each turn, at most every 5 seconds
git fetch --quiet --no-tagsahead and behind counts; contacts your remoteevery 5 minutes; git.fetch: 0 turns it off
git rev-parse --show-toplevelthe project name for cwd with style: "project"at most once a minute
git remote get-url origin, then gh pr view or glab mr viewthe pull request of the branch for gitPr; contacts GitHub or GitLab with those tools' own loginonce its ttl has passed
sh -c <command>the command widget runs the command you configure, nothing elseonce its ttl has passed

Network

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.

Files and settings

WhatHoldsReadWritten
~/.config/tidemark/config.json, under $XDG_CONFIG_HOME when setglobal config✓on Save in /tidemark-config
.claude/tidemark.jsonproject config✓on Save in /tidemark-config
.claude/tidemark-project.txtproject name✓by /tidemark-project
Plugin storelast quota, effort levels each model accepted, goals of recent sessions✓as the session goes
HOME, XDG_CONFIG_HOMEwhere the global config lives✓
CLAUDE_CODE_PROMPT_CACHE_TTL, ENABLE_PROMPT_CACHING_1H, FORCE_PROMPT_CACHING_5M, setting promptCacheTtlthe 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.

Links

Source 40 files
hooks/register.tsx 36 lines
1// 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}
36
hooks/band.tsx 147 lines
1// 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}
147
hooks/config.ts 95 lines
1// 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}
95
hooks/editor.tsx 237 lines
1// 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}
237
hooks/pane.tsx 37 lines
1// 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}
37
hooks/notes.tsx 68 lines
1// /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}
68
hooks/snapshot.ts 424 lines
1// 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}
424
hooks/config-model.ts 280 lines
1// 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}
280
hooks/model-utils.ts 118 lines
1// 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}
118
hooks/widgets/index.ts 32 lines
1// 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}
32
hooks/widgets/kit.ts 185 lines
1// 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}
185
hooks/draw.tsx 273 lines
1// 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