SLOPSHOPPER

on-my-watch

A Claude Code mod: a notification pane for your Datadog queries and GitHub pull requests.

newpanecommandtoastprocesstimer
v0.1.0MITupdated 2026-10-22imhalawa/on-my-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · on-my-watch
│ ┃ Watch ✕ › fix the failing auth test and add an audit log call │ ┃ No runs yet. Put your config in │ ┃ ~/.on-my-watch/config.json, then check. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Refresh ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /watch │ ⎿ on-my-watch: On my watch opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Watch
No runs yet. Put your config in ~/.on-my-watch/config.json, then check. [ Refresh ]
Pane · Pull requests
not checked yet ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────── o: Open g: ☑ By repository b: ☐ Hide bots
Pane · Config
No config yet. Put it in ~/.on-my-watch/config.json, then press Reload. [ Reload ]
README

on-my-watch

Tests

on-my-watch: observability at your fingertips

on-my-watch is a Claude Code mod: a notification pane for your Datadog queries and GitHub pull requests. It runs your queries, compares each value with the line you set, and tells you when one crosses it.

Features

  • Alerts on your own Datadog queries over logs, traces and metrics.
  • Every pull request on your repositories, with its CI status and reviews.
  • Runs on a schedule, even with Claude Code closed.
  • Desktop notifications on macOS, Linux and Windows. Mute them any time.
  • Every run kept in History, 30 days by default.
  • No Claude tokens until you press Investigate on a problem.

Why

AI tools have made writing and shipping code faster than reviewing and watching it. A change can reach a shared environment and slowly make things worse, and the person who made it may not find out for a while.

Monitors are the right tool for failures you already know about. Each one has an owner, a threshold, alert routing and an on-call rotation behind it, which is why adding one takes care. Before that point there is usually a simpler question: what does this service look like right now, after my change? on-my-watch is for that question. It runs your own Datadog queries on a schedule, in a pane next to your work, and tells only you what it finds.

It does not replace monitoring:

  • Nothing it finds pages anyone. Notifications go to your desktop and nowhere else.
  • It reads Datadog with your own pup sign-in, so it sees only what you can already see.
  • A query that keeps proving useful belongs in a monitor. One that doesn't can be changed or deleted without a review.

architecture.md explains how it works, why it costs no Claude tokens, and how Claude can help you set it up.

Requirements

  • macOS, Linux or Windows, with Python 3.
  • pup, the Datadog CLI, signed in with pup auth login. It runs your queries.
  • gh, the GitHub CLI, signed in with gh auth login. It lists pull requests, CI status and reviews.
  • On Linux, notify-send for notifications.

When a tool is missing or signed out, the pane says what to do.

Quick start

  1. Install the plugin: `` /plugin marketplace add imhalawa/on-my-watch /plugin install on-my-watch@on-my-watch ``
  2. Run /on-my-watch:configure and answer its questions. It writes ~/.on-my-watch/config.json for you. To write it by hand instead, copy config.example.json there.
  3. Run /watch, then press ↻ in each pane.
  4. Optional: run the checks on the config's schedule with python3 <plugin folder>/bin/schedule.py install. It uses launchd on macOS, your crontab on Linux and Task Scheduler on Windows. uninstall removes it.

Panes

Watch

Watch: one card per repository and environment; each query with its last runs, since when, and which way it moves

  • Each card is one repository in one environment, such as Checkout · Production. Its rows are grouped by service.
  • A row is a query that crossed its alert line: priority, label and value, with its last 12 runs as a small chart, since when it has been over, and whether it went up or down since the last run. A query that wasn't over in the last run says new.
  • The value links to the query in Datadog, pinned to the window that was checked.
  • The most urgent card comes first.
  • Dropdowns filter by priority, repository and environment.
  • The window dropdown in the header checks a different window at once. Scheduled runs keep the config's window. A row's chart only compares runs of the same window.
  • Narrow panes keep one line per query and give the chart a line of its own under it.
  • Each row can turn its own notifications off and on.
  • Investigate opens a new Claude session in the same folder with one query's problem: a desktop Code session from the desktop app, a Terminal window from the terminal (macOS).

History

History: past runs by day, with their problem counts

  • Past runs by day, with their problems per label.
  • Reopen any run, with its links pinned to the window it checked.

Pull requests

Pull requests: what needs you first, each saying who it waits on, with size, idle time, CI and reviews

  • Every pull request on your repositories, by any author.
  • Filter by Open, Merged or Closed, over a span of up to a month.
  • Group by repository or one list. Hide bots hides the accounts in bots.
  • CI status and reviewers' pictures, with a check on those who approved.

Config

Config: the quick settings and the schedule

  • Quick settings: Datadog site, window, history, environment groups, bots, panes and notifications.
  • Environment groups and bots are chips on the desktop: × takes one out, + add adds one.
  • Repositories and queries live in config.json: Open in editor opens it, and its $schema gives your editor checks and completion. Or ask Claude to change them.
  • Save changes checks the config against the schema first and keeps the old file as config.json.bak.
  • Discard drops your changes. Reload reads a config edited by hand.
  • Schedule: a frequency with Use work hours, or a cron line. Set it for all queries or for one.
  • Pause and resume scheduled runs. An installed schedule is updated to match.
  • Mute all silences every notification, scheduled ones included, until you unmute.

In the terminal

The Watch pane in a dark terminal, docked beside Claude: block-character charts, words for marks, a letter per control

The Pull requests pane in a dark terminal: what needs you first, who each waits on, size and idle time

  • The same panes, drawn in text: charts in block characters, priorities and marks as words.
  • Each control has a letter to press, shown before it: r refreshes, h opens History.
  • Values and titles open in your browser when pressed, so a terminal without links never prints a long URL.

Notifications

Desktop notifications from on-my-watch, each with the lighthouse icon

  • One for each new entry over its line, each open pull request whose CI turns red, and each time pup or gh needs you.
  • Sent by the schedule, whether Claude Code is open or not, on macOS, Linux and Windows.
  • Mute all in Config silences them; each Watch row's bell turns its own off.

Config

The config is a list of GitHub repositories. Each one holds the queries that watch its services:

config.json
├── repos: one per GitHub repository (owner/name)
│   ├── name: what the panes show, such as Checkout
│   ├── pull requests: every PR on the repository, by any author
│   └── queries
│       ├── service: groups the rows inside a card
│       ├── env: one card per repository and environment
│       └── label, query, alert line
└── environments: optional groups for Watch's filter

repos lists each repository by its GitHub owner/name, with the name you want to see and its queries. Each query names the service it watches. List a repository once, and within it use each combination of service, env and label once:

"repos": [
  {
    "repo": "acme/checkout-api",
    "name": "Checkout",
    "queries": [
      { "service": "API", "label": "Errors", "env": "Production", "priority": 1, "source": "logs",
        "query": "service:checkout-api env:production status:error",
        "alert": { "above": 20 } }
    ]
  }
]

That entry reads Checkout › API: Production - Errors. Pull requests show once per repository, under its name. A repository with "watch_only": true is left out of Pull requests. A repository with no queries shows only there.

Query fieldMeaning
serviceThe service the query watches, as you want to see it. The pane groups rows by it.
labelWhat the query watches, such as Errors, Warnings or a name of your own
envThe environment the entry shows under. The query itself decides which data it reads.
priority1 (High, red), 2 (Medium, yellow) or 3 (Low, grey). Cards and rows sort by it.
sourcelogs, traces or metrics
queryThe full Datadog query, run exactly as written
alertabove, below or both. Every condition given must hold.
scheduleOptional cron line for this query alone. Without it, the top-level schedule applies.
computeFor logs and traces. Default count, for example percentile(@duration, 95)
reduceFor metrics. Default avg; also sum, max, min, last
notifyfalse keeps the entry in the pane only
investigateOptional prompt and problem templates for this query's Investigate. Each one overrides the general one in Investigate prompt; one left out uses the general one.

Top-level fields:

FieldMeaning
siteYour Datadog site, such as datadoghq.com
scheduleCron line for the checks
pullRequestsScheduleOptional cron line for pull requests. Without it, schedule applies.
windowHow far back each check looks, for example 1h
reposThe repositories and their queries, described above
environmentsOptional groups that Watch's filter shows as one, such as { "Production": ["Production", "Canary"] }. Each query keeps its own env. Watch's filter, the query form's Environment list and the Config counts all use it.
panes{ "watch": false } or { "pull_requests": false } turns a pane off
historyDaysHow many days of runs to keep. 30 by default.
botsThe GitHub usernames that Hide bots hides. ["dependabot"] by default.
notify{ "watch": true, "pull_requests": true }. Sends one desktop notification for each new entry over its line, each open pull request whose CI turns red, and each time pup or gh needs you. It works from the schedule with Claude Code closed.
investigate{ "session": "current" } investigates in the session you are in instead of a new one. { "skill": "triage" } runs your own skill with the problem, as /triage <problem>. prompt and problem set the message it sends. See Investigate prompt.

config.schema.json describes all of it. The checker refuses a config that breaks the schema and says where, so an unknown field, a missing label or a priority of 0 is caught before anything runs. python3 bin/on_my_watch.py validate checks yours. Point $schema at the file and your editor checks it as you type. See config.example.json for a full example.

Investigate prompt

Investigate sends Claude a message about the problems it was pressed on. investigate.prompt sets the whole message. investigate.problem sets one line, which is repeated once per problem into {{problems}}. Each template left out sends today's text.

"investigate": {
  "prompt": "Investigate {{name}} ({{repo}}) in {{env}}, from {{from}} to {{to}}.\n{{problems}}\nQuery Datadog with pup first. Do not write tests.",
  "problem": "- {{priority}} {{service}}: {{label}} is {{value}} ({{alert}}). {{source}} query `{{query}}`. Datadog: {{link}}"
}

A query can have its own investigate with prompt, problem or both. Each one it sets replaces the general one for that query's problem. The one it leaves out comes from the general investigate.

VariableInValue
{{name}}bothThe repository's name from the config, such as Checkout
{{repo}}bothThe repository, as owner/name
{{env}}bothThe query's env
{{window}}promptThe config's window, such as 1h
{{checkedAt}}promptWhen the check ran
{{from}}, {{to}}promptThe start and end of the window the check counted
{{problems}}promptEvery problem line, one per line
{{priority}}problemP1, P2 or P3
{{service}}problemThe query's service
{{label}}problemThe query's label
{{value}}problemThe number the check found, such as 666
{{alert}}problemThe alert line it crossed, such as above 19
{{source}}problemlogs, traces or metrics
{{query}}problemThe query, as written in the config
{{link}}problemThe query in Datadog, opened on the checked window

In prompt, {{name}}, {{repo}} and {{env}} are the first problem's. A variable with no value is filled with nothing. A name that is not in the table, such as {{lnk}}, stays as written, so a typo shows in the message.

Files

Everything lives in ~/.on-my-watch/: config.json, every run in history.db (SQLite), and run.log from scheduled runs.

Development

python3 -B -m unittest discover -s tests
claude plugin test .
claude plugin validate .

License

MIT

Source 2 files
hooks/register.tsx 1571 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Part, Problem, PullRequest, Result } from '../types'
5
6// Two panes, so the app can place and size them apart; each can be turned off in the config.
7const PANES = {
8  watch: { id: 'on-my-watch', title: 'Watch' },
9  pull_requests: { id: 'on-my-watch-prs', title: 'Pull requests' },
10} as const
11// Config and the schedule, for both parts: a pane of its own, always open beside the others.
12const CONFIG_PANE = { id: 'on-my-watch-config', title: 'Config' } as const
13// A desktop app can start with a short PATH; these are where pup and gh usually live on macOS and Linux.
14const TOOL_DIRS = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin'
15
16const result = atom({ plugin: 'on-my-watch', key: 'result' } as const, null)
17const running = atom({ plugin: 'on-my-watch', key: 'running' } as const, null)
18const runningSince = atom({ plugin: 'on-my-watch', key: 'runningSince' } as const, null)
19const span = atom({ plugin: 'on-my-watch', key: 'span' } as const, '3d')
20const hideBots = atom({ plugin: 'on-my-watch', key: 'hideBots' } as const, false)
21const prState = atom({ plugin: 'on-my-watch', key: 'prState' } as const, 'open')
22// Long lists draw a page at a time: the pull requests' page, and the open repository's page of queries.
23const prPage = atom({ plugin: 'on-my-watch', key: 'prPage' } as const, 1)
24// Watch narrows to one priority, name or environment; empty is all. The choices come from the config's queries.
25const priorityFilter = atom({ plugin: 'on-my-watch', key: 'priorityFilter' } as const, '')
26const nameFilter = atom({ plugin: 'on-my-watch', key: 'nameFilter' } as const, '')
27const envFilter = atom({ plugin: 'on-my-watch', key: 'envFilter' } as const, '')
28const choices = atom({ plugin: 'on-my-watch', key: 'choices' } as const, { names: [], envs: [], envGroups: {}, priorities: [], bots: ['dependabot'] })
29const quietEntries = atom({ plugin: 'on-my-watch', key: 'quietEntries' } as const, [])
30const groupPrs = atom({ plugin: 'on-my-watch', key: 'groupPrs' } as const, true)
31const watchView = atom({ plugin: 'on-my-watch', key: 'watchView' } as const, 'latest')
32const runs = atom({ plugin: 'on-my-watch', key: 'runs' } as const, [])
33const archived = atom({ plugin: 'on-my-watch', key: 'archived' } as const, null)
34const watchWindow = atom({ plugin: 'on-my-watch', key: 'watchWindow' } as const, '')
35const avatars = atom({ plugin: 'on-my-watch', key: 'avatars' } as const, {})
36const muted = atom({ plugin: 'on-my-watch', key: 'muted' } as const, false)
37const scheduleState = atom({ plugin: 'on-my-watch', key: 'scheduleState' } as const, null)
38const customFor = atom({ plugin: 'on-my-watch', key: 'customFor' } as const, null)
39const cronDraft = atom({ plugin: 'on-my-watch', key: 'cronDraft' } as const, '')
40// The config as the Config pane edits it, when it was read and what the schema says of it.
41const configDoc = atom({ plugin: 'on-my-watch', key: 'configDoc' } as const, null)
42const configReadAt = atom({ plugin: 'on-my-watch', key: 'configReadAt' } as const, '')
43const configProblems = atom({ plugin: 'on-my-watch', key: 'configProblems' } as const, [])
44const configError = atom({ plugin: 'on-my-watch', key: 'configError' } as const, '')
45// Every change the Config pane makes lands here first; Save changes writes it, Discard and Reload drop it.
46const configDraft = atom({ plugin: 'on-my-watch', key: 'configDraft' } as const, null)
47// The environment groups as typed, [name, members] row by row, and the row being added; null until one changes.
48const envRows = atom({ plugin: 'on-my-watch', key: 'envRows' } as const, null)
49const envDraft = atom({ plugin: 'on-my-watch', key: 'envDraft' } as const, { name: '', members: '' })
50
51const settingsDraft = atom({ plugin: 'on-my-watch', key: 'settingsDraft' } as const, null)
52// The schedule builder: how often, between which hours (or at which), on which days. It reads a cron line back
53// into those parts, and makes one from them; a line it can't make is Custom.
54type Build = { every: '15m' | '30m' | '1h' | 'once' | 'twice'; allDay: boolean; from: number; to: number; at: number[]; days: number[] }
55const EVERY = [
56  { value: '15m', label: 'Every 15 minutes' },
57  { value: '30m', label: 'Every 30 minutes' },
58  { value: '1h', label: 'Every hour' },
59  { value: 'once', label: 'Once a day' },
60  { value: 'twice', label: 'Twice a day' },
61] as const
62const STEP: Record<string, number> = { '15m': 15, '30m': 30, '1h': 60 }
63// Work hours are 09:00–18:00, Monday to Friday, as the example config's schedule.
64const WORK: Build = { every: '1h', allDay: false, from: 9, to: 18, at: [9, 13], days: [1, 2, 3, 4, 5] }
65const WEEK = [[1, 'Mon', 'Monday'], [2, 'Tue', 'Tuesday'], [3, 'Wed', 'Wednesday'], [4, 'Thu', 'Thursday'], [5, 'Fri', 'Friday'],
66  [6, 'Sat', 'Saturday'], [0, 'Sun', 'Sunday']] as const
67const HOURS = Array.from({ length: 24 }, (_, h) => ({ value: String(h), label: `${String(h).padStart(2, '0')}:00` }))
68const hh = (h: number, m = 0) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
69
70function readDays(field: string): number[] | null {
71  if (field === '*') return [0, 1, 2, 3, 4, 5, 6]
72  const days = new Set<number>()
73  for (const part of field.split(',')) {
74    const m = /^([0-7])(?:-([0-7]))?$/.exec(part)
75    if (!m) return null
76    for (let d = Number(m[1]); d <= Number(m[2] ?? m[1]); d++) days.add(d % 7)
77  }
78  return [...days].sort((a, b) => a - b)
79}
80function daysField(days: number[]) {
81  if (days.length === 7) return '*'
82  const runs: number[][] = []
83  for (const d of [...days].sort((a, b) => a - b)) {
84    const last = runs.at(-1)
85    if (last && d === last.at(-1)! + 1) last.push(d)
86    else runs.push([d])
87  }
88  return runs.map(r => (r.length > 2 ? `${r[0]}-${r.at(-1)}` : r.join(','))).join(',')
89}
90function readBuild(cron: string): Build | null {
91  const f = cron.trim().split(/\s+/)
92  if (f.length !== 5 || f[2] !== '*' || f[3] !== '*') return null
93  const [minute, hour] = f as [string, string]
94  const days = readDays(f[4]!)
95  const every = ({ '*/15': '15m', '*/30': '30m', 0: '1h' } as Record<string, Build['every']>)[minute]
96  if (!days || !every) return null
97  if (hour === '*') return { ...WORK, every, allDay: true, days }
98  const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour)
99  if (range && Number(range[1]) <= Number(range[2]) && Number(range[2]) < 24) return { ...WORK, every, from: Number(range[1]), to: Number(range[2]), days }
100  const at = hour.split(',')
101  if (every === '1h' && at.length <= 2 && at.every(h => /^\d{1,2}$/.test(h) && Number(h) < 24))
102    return { ...WORK, every: at.length === 1 ? 'once' : 'twice', at: at.map(Number), days }
103  return null
104}
105function cronOfBuild(b: Build) {
106  const daily = b.every === 'once' || b.every === 'twice'
107  const minute = ({ '15m': '*/15', '30m': '*/30' } as Record<string, string>)[b.every] ?? '0'
108  const hour = daily ? b.at.slice(0, b.every === 'once' ? 1 : 2).join(',') : b.allDay ? '*' : `${b.from}-${b.to}`
109  return `${minute} ${hour} * * ${daysField(b.days)}`
110}
111// What a build runs, in words: `Every 30 minutes from 09:00 to 18:30, Monday to Friday`.
112function describeBuild(b: Build) {
113  const order = WEEK.filter(([d]) => b.days.includes(d))
114  const at = WEEK.map(([d]) => d as number)
115  const unbroken = order.length > 1 && order.every(([d], i) => i === 0 || at.indexOf(d) === at.indexOf(order[i - 1]![0]) + 1)
116  const days = b.days.length === 7 ? 'every day'
117    : b.days.length === 2 && b.days.includes(0) && b.days.includes(6) ? 'weekends'
118      : unbroken && order.length > 2 ? `${order[0]![2]} to ${order.at(-1)![2]}`
119        : order.map(([, , name]) => name).join(', ')
120  const step = STEP[b.every]
121  const when = b.every === 'once' ? `Once a day at ${hh(b.at[0]!)}`
122    : b.every === 'twice' ? `Twice a day at ${hh(b.at[0]!)} and ${hh(b.at[1]!)}`
123      : `${EVERY.find(e => e.value === b.every)!.label}${b.allDay ? ', all day' : ` from ${hh(b.from)} to ${hh(b.to, 60 - step!)}`}`
124  return `${when}, ${days}`
125}
126// The width, in cells, of every label in the Config pane, so the fields after them line up.
127const LABEL_WIDTH = 18
128// Fields for short values, in cells: `15m`, `30`.
129const SHORT_FIELDS: Record<string, number> = { window: 12, historyDays: 8 }
130
131// A query's priority: 1, 2 or 3, the most urgent first.
132const PRIORITY_NAMES = ['High', 'Medium', 'Low']
133// The mod's own accent: section icons, chevrons and the new mark, in Claude's orange.
134const ACCENT = 'accent' as const
135// The rows whose field the schema requires, by row key: their labels carry a red asterisk.
136const REQUIRED = new Set(['site', 'window', 'field:repo', 'field:name',
137  'field:service', 'field:label', 'field:env', 'field:priority', 'field:source', 'field:query', 'field:line'])
138// The widths, in cells, of History's columns: when and the window, then each count.
139const HISTORY_WHEN_WIDTH = 22
140const HISTORY_COUNT_WIDTH = 7
141// The width, in cells, of an environment group's name field, so the members after them line up.
142const ENV_GROUP_WIDTH = 18
143const CUSTOM = { value: 'custom', label: 'Custom…' }
144// A pull request's state as GitHub gives it: Closed is closed without merging.
145const STATES = [
146  { value: 'open', label: 'Open' },
147  { value: 'merged', label: 'Merged' },
148  { value: 'closed', label: 'Closed' },
149] as const
150
151// A box drawn before a label: the engine has no checkbox element.
152function checkbox(ui: any, look: Look, key: string, label: string, checked: boolean, toggle: () => unknown, hotkey?: string) {
153  return <ui.Button key={key} plain hotkey={hotkey ? look.hotkey(hotkey) : undefined} label={`${checked ? '☑' : '☐'} ${label}`} onPress={toggle} />
154}
155
156// One change to the draft: `set` a JSON value at a dotted path (`+` adds to a list) or `delete` it. Nothing is written.
157function applyEdit(o: any, [action, path, json]: string[]) {
158  const keys = path!.split('.')
159  const last = keys.pop()!
160  let node = o
161  for (const k of keys) node = Array.isArray(node) ? node[Number(k)] : (node[k] ??= {})
162  if (action === 'delete') {
163    if (Array.isArray(node)) node.splice(Number(last), 1)
164    else delete node[last]
165  } else if (last === '+') node.push(JSON.parse(json!))
166  else node[Array.isArray(node) ? Number(last) : last] = JSON.parse(json!)
167}
168async function saveConfig($: any, args: string[]) {
169  const base = (await read($, configDraft)) ?? (await read($, configDoc))
170  const next = JSON.parse(JSON.stringify(base ?? {}))
171  applyEdit(next, args)
172  await update($, configDraft, () => next)
173}
174// Save changes: what is still being typed joins the draft, then the checker checks the whole of it and writes it,
175// keeping the file it replaces.
176async function saveAll($: any) {
177  await saveSettings($, (await read($, settingsDraft)) ?? {})
178  const groups = await read($, envRows)
179  if (groups) {
180    const names = groups.map(([name]) => name.trim())
181    if (names.some(n => !n) || new Set(names).size !== names.length) return update($, configError, () => 'Each environment group needs a name of its own.')
182    const object = Object.fromEntries(groups.map(([name, members]) => [name.trim(), list(members)]))
183    await saveConfig($, groups.length ? ['set', 'environments', JSON.stringify(object)] : ['delete', 'environments'])
184  }
185  const typed = await read($, customFor)
186  if (typed !== null) await setSchedule($, await read($, cronDraft), typed)
187  const draft = await read($, configDraft)
188  if (!draft) return
189  const done = await checker($, ['config', 'replace'], JSON.stringify(draft))
190  const refused = done.exitCode === 0 ? '' : done.stderr.trim() || 'the config was not saved'
191  await update($, configError, () => refused)
192  if (refused) return
193  await discard($)
194  await readPanes($)
195  await scheduleCommand($, ['status'])
196}
197async function discard($: any) {
198  for (const clear of [() => update($, configDraft, () => null), () => update($, settingsDraft, () => null),
199    () => update($, customFor, () => null), () => update($, envRows, () => null)]) await clear()
200}
201
202// The settings the Config pane edits: what a field shows of the config, and the JSON it saves (null takes it out).
203const list = (v: string) => v.split(',').map(x => x.trim()).filter(Boolean)
204const listJson = (v: string) => (list(v).length ? JSON.stringify(list(v)) : null)
205const SETTINGS = [
206  { path: 'site', section: 'watch', label: 'Datadog site', hint: 'datadoghq.com', of: (d: any) => d.site ?? '', json: (v: string) => JSON.stringify(v) },
207  { path: 'window', section: 'watch', label: 'Window', hint: '1h', of: (d: any) => d.window ?? '', json: (v: string) => JSON.stringify(v) },
208  { path: 'historyDays', section: 'watch', label: 'History rotation', unit: 'days', hint: '30', of: (d: any) => (d.historyDays == null ? '' : String(d.historyDays)), json: (v: string) => v || null },
209  { path: 'bots', section: 'prs', label: 'Bots', hint: 'dependabot, renovate', of: (d: any) => (d.bots ?? ['dependabot']).join(', '), json: listJson },
210] as const
211// The settings as typed, each changed one put into the draft.
212async function saveSettings($: any, typed: Record<string, string>) {
213  for (const f of SETTINGS) {
214    const doc = ((await read($, configDraft)) ?? (await read($, configDoc))) as any
215    const v = typed[f.path]?.trim()
216    if (v === undefined || v === f.of(doc ?? {})) continue
217    const json = f.json(v)
218    await saveConfig($, json === null ? ['delete', f.path] : ['set', f.path, json])
219  }
220  await update($, settingsDraft, () => null)
221}
222
223// The Config pane: the file and what it holds, settings, environments, panes and notifications, and the schedule.
224async function configView($: any, ui: any, look: Look, picker: ReturnType<typeof pickerFor>, narrow = false) {
225  const { Box, Text, Button } = ui
226  const canType = 'Input' in ui
227  const dirtyDraft = await read($, configDraft)
228  const doc = (dirtyDraft ?? (await read($, configDoc))) as any
229  const readAt = await read($, configReadAt)
230  const problems = await read($, configProblems)
231  const saveError = await read($, configError)
232  const state = await read($, scheduleState)
233  const custom = await read($, customFor)
234  const draft = await read($, cronDraft)
235  const changes = await read($, settingsDraft)
236  const when = (iso: string) => (day(iso) === 'Today' ? time(iso) : `${day(iso)} ${time(iso)}`)
237  // The environments the config's queries already use, for the query form's dropdown.
238  const environments = environmentList(doc)
239
240  // A section as a card: its icon and name, its actions at the right, then what it holds.
241  const card = (key: string, icon: IconName, text: string, right: unknown, ...body: unknown[]) => (
242    <Box key={`card:${key}`} flexDirection="column" rowGap={1} borderStyle="round" borderColor="subtle" paddingX={1}>
243      <Box key={`h:${key}`} flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2} flexWrap="wrap">
244        {/* The icon is decoration here: the terminal writes no word for it, the title says it. */}
245        <Box flexDirection="row" alignItems="center" columnGap={1}>{look.icon(icon, ACCENT, text, '')}<Text bold>{text}</Text></Box>
246        {right}
247      </Box>
248      {body}
249    </Box>
250  )
251  // A group's name inside a form, dim, above the fields it heads.
252  const group = (key: string, text: string) => <Box key={`group:${key}`} marginTop={1}><Text dimColor>{text}</Text></Box>
253  // A labelled line: every label takes the same width, so the fields after them line up.
254  // Narrow: the label above its field, so the field and its hint get the whole width.
255  const row = (key: string, label: string, ...control: unknown[]) => (
256    <Box key={`row:${key}`} flexDirection={narrow ? 'column' : 'row'} alignItems={narrow ? 'stretch' : 'center'} columnGap={1}>
257      <Box key={`label:${key}`} width={narrow ? undefined : LABEL_WIDTH} flexShrink={0} flexDirection="row">
258        <Text dimColor>{label}</Text>{REQUIRED.has(key) && <Text key={`required:${key}`} color="error"> *</Text>}
259      </Box>
260      <Box flexDirection="row" flexGrow={1} minWidth={0} columnGap={2} alignItems="center" flexWrap="wrap">{control}</Box>
261    </Box>
262  )
263
264  // The schedule builder for one entry ('' is every query): each change saves the cron line it makes.
265  const cronPicker = (entry: string, cron: string, extra: { value: string; label: string }[] = [], same = false) => {
266    // `same`: it has no line of its own and follows Watch's, so only the dropdown shows, on Same as Watch.
267    if (same) {
268      const from = readBuild(cron) ?? WORK
269      return (
270        <Box key={`picker:${entry}`} flexDirection="row">
271          {picker(`cron:${entry}`, '', [...extra, ...EVERY, CUSTOM], 'default', (v: string) => (v === CUSTOM.value
272            ? Promise.all([update($, customFor, () => entry), update($, cronDraft, () => cron)])
273            : v === 'default' ? null : setSchedule($, cronOfBuild({ ...from, every: v as Build['every'] }), entry)))}
274        </Box>
275      )
276    }
277    const built = custom === entry ? null : readBuild(cron)
278    const save = (b: Build) => setSchedule($, cronOfBuild(b), entry)
279    const base = built ?? WORK
280    const pick = (v: string) => {
281      if (v === CUSTOM.value) return Promise.all([update($, customFor, () => entry), update($, cronDraft, () => cron)])
282      if (v === 'default') return setSchedule($, v, entry)
283      return save({ ...base, every: v as Build['every'] })
284    }
285    const hotkey = (k: string) => (entry ? '' : k)
286    const daily = built && (built.every === 'once' || built.every === 'twice')
287    return (
288      <Box key={`picker:${entry}`} flexDirection="column" rowGap={1}>
289        <Box flexDirection="row" columnGap={2} alignItems="center" flexWrap="wrap">
290          {picker(`cron:${entry}`, hotkey('c'), [...extra, ...EVERY, CUSTOM], built ? built.every : CUSTOM.value, pick)}
291          {built && !daily && !built.allDay && [
292            <Text key="from-word" dimColor>from</Text>,
293            picker(`from:${entry}`, '', HOURS, String(built.from), v => save({ ...built, from: Number(v), to: Math.max(built.to, Number(v)) })),
294            <Text key="to-word" dimColor>to</Text>,
295            picker(`to:${entry}`, '', HOURS, String(built.to), v => save({ ...built, to: Number(v), from: Math.min(built.from, Number(v)) })),
296          ]}
297          {built && !daily && checkbox(ui, look, `allday:${entry}`, 'All day', built.allDay, () => save({ ...built, allDay: !built.allDay }))}
298          {daily && [
299            <Text key="at-word" dimColor>at</Text>,
300            picker(`at1:${entry}`, '', HOURS, String(built.at[0]), v => save({ ...built, at: [Number(v), built.at[1] ?? 13] })),
301            ...(built.every === 'twice' ? [<Text key="and-word" dimColor>and</Text>,
302              picker(`at2:${entry}`, '', HOURS, String(built.at[1] ?? 13), v => save({ ...built, at: [built.at[0]!, Number(v)] }))] : []),
303          ]}
304        </Box>
305        {built && (
306          <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
307            {WEEK.map(([d, short]) => checkbox(ui, look, `day:${d}:${entry}`, short, built.days.includes(d), () => {
308              const days = built.days.includes(d) ? built.days.filter(x => x !== d) : [...built.days, d]
309              return days.length ? save({ ...built, days }) : null // a schedule needs one day at least
310            }))}
311            <Button key={`work:${entry}`} variant="secondary" label="Use work hours" onPress={() => save({ ...WORK, every: daily ? '1h' : built.every })} />
312          </Box>
313        )}
314        {built && <Box key={`cron-line:${entry}`}><Text dimColor wrap="wrap">{describeBuild(built)}</Text></Box>}
315        {!built && canType && (
316          <ui.Input key={`cron-input:${entry}`} value={custom === entry ? draft : cron} placeholder="0 9-18 * * 1-5"
317            onInput={(v: string) => update($, cronDraft, () => v)} onSubmit={(v: string) => update($, cronDraft, () => v).then(() => saveAll($))} />
318        )}
319        {!built && <Text dimColor>minute hour day month weekday</Text>}
320      </Box>
321    )
322  }
323
324  // A setting's field: typing keeps a draft; submitting it, or Save changes, saves what changed.
325  const setting = (f: typeof SETTINGS[number]) => row(f.path, f.label, canType
326    // A short value gets a short field; the rest take the line.
327    ? <Box key={`fill:set:${f.path}`} {...(SHORT_FIELDS[f.path] ? { width: SHORT_FIELDS[f.path], flexShrink: 0 } : { flexGrow: 1, minWidth: 0 })}>
328      <ui.Input key={`set:${f.path}`} value={changes?.[f.path] ?? f.of(doc)} placeholder={f.hint}
329        onInput={(v: string) => update($, settingsDraft, d => ({ ...(d ?? {}), [f.path]: v }))}
330        onSubmit={(v: string) => update($, settingsDraft, d => ({ ...(d ?? {}), [f.path]: v })).then(() => saveAll($))} />
331    </Box>
332    : <Text>{f.of(doc) || '—'}</Text>,
333  'unit' in f && <Text key={`unit:${f.path}`} dimColor>{f.unit}</Text>)
334  const toggle = (path: string, label: string, on: boolean) => checkbox(ui, look, `set:${path}`, label, on, () => saveConfig($, ['set', path, String(!on)]))
335
336  const reload = <Button key="reload-config" variant="secondary" hotkey={look.hotkey('k')} label="Reload" onPress={() => discard($).then(() => readPanes($))} />
337  if (!doc) {
338    return (
339      <Box flexDirection="column" rowGap={1}>
340        <Text dimColor>No config yet. Put it in ~/.on-my-watch/config.json, then press Reload.</Text>
341        <Box flexDirection="row">{reload}</Box>
342      </Box>
343    )
344  }
345  const repos = (Array.isArray(doc.repos) ? doc.repos : []) as any[]
346  const queries = repos.flatMap(r => r?.queries ?? [])
347  const envs = environments.length
348  // Settings typed and not saved yet, by the card they sit in: that card gets Save changes.
349  // Unsaved: the draft, settings typed, or a cron line typed.
350  const groupRows = await read($, envRows)
351  const dirty = !!dirtyDraft || !!groupRows || custom !== null || SETTINGS.some(f => changes?.[f.path] !== undefined && changes[f.path]!.trim() !== f.of(doc))
352  const saveBar = (
353    <Box key="save-bar" flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
354      {dirty && [
355        <Text key="unsaved" color="warning">Unsaved changes</Text>,
356        <Button key="discard-config" variant="secondary" label="Discard" onPress={() => discard($)} />,
357        <Button key="save-config" variant="primary" label="Save changes" onPress={() => saveAll($)} />,
358      ]}
359      {reload}
360      {/* Repositories and queries are edited in the file: its $schema gives the editor checks and completion. */}
361      <Button key="open-config" variant="secondary" label="Open in editor" onPress={() => openConfig($)} />
362    </Box>
363  )
364  const saveRow = (_section: string) => null
365  const newGroup = await read($, envDraft)
366  const stateLine = state && (
367    <Box flexDirection="row" columnGap={2} alignItems="center">
368      {state.paused
369        ? <Box key="schedule-state" flexDirection="row" columnGap={1} alignItems="center">{look.icon('pause', 'warning', 'paused')}<Text color="warning">{`Paused since ${when(state.paused)}`}</Text></Box>
370        : <Box key="schedule-state" flexDirection="row" columnGap={1} alignItems="center">{look.icon('dot', 'success', 'running')}<Text color="success">Running</Text><Text dimColor>{state.next ? ` · next ${when(state.next)}` : ''}</Text></Box>}
371      {state.paused
372        ? <Button key="resume" variant="secondary" label="Resume" onPress={() => scheduleCommand($, ['resume'])} />
373        : <Button key="pause" variant="secondary" label="Pause" onPress={() => scheduleCommand($, ['pause'])} />}
374    </Box>
375  )
376  // A refusal can name every query it breaks: the first three say enough.
377  const errorLines = saveError ? saveError.split('\n') : []
378  return (
379    <Box flexDirection="column" rowGap={1}>
380      {card('file', 'logs', 'Config file', saveBar,
381        <Box key="config-file"><Text dimColor>~/.on-my-watch/config.json</Text></Box>,
382        <Box key="config-counts">
383          <Text dimColor wrap="wrap">{[plural(repos.length, 'repository', 'repositories'), plural(queries.length, 'query', 'queries'), plural(envs, 'environment')].join(' · ')}</Text>
384        </Box>,
385        <Box key="config-state">
386          <Text color={problems.length ? 'error' : 'success'}>{`${problems.length ? 'not valid' : 'valid'}${readAt ? ` · read ${time(readAt)}` : ''}`}</Text>
387        </Box>,
388        ...problems.slice(0, 3).map((p: string) => <Text key={`problem:${p}`} color="error" wrap="truncate-end">{p}</Text>),
389        errorLines.length > 0 && (
390          <Box key="config-error" flexDirection="column">
391            <Text color="error" bold>Not saved:</Text>
392            {errorLines.slice(0, 3).map((l: string, i: number) => <Text key={`error:${i}`} color="error" wrap="truncate-end">{l}</Text>)}
393            {errorLines.length > 3 && <Text color="error">{`and ${errorLines.length - 3} more`}</Text>}
394          </Box>
395        ))}
396
397      {card('watch', 'metrics', 'General', null,
398        ...SETTINGS.filter(f => f.section === 'watch').flatMap(f => [setting(f),
399          ...(f.path === 'window' ? [row('hint:window-row', '', <Box key="hint:window"><Text dimColor>A number and m, h or d: 15m, 4h, 7d</Text></Box>)] : [])]),
400        saveRow('watch'))}
401
402      {(() => {
403        // Environment groups: each a name and the envs it covers, comma-separated; the panes show it as one.
404        const rows = (groupRows ?? Object.entries((doc.environments ?? {}) as Record<string, string[]>).map(([n, m]) => [n, m.join(', ')])) as [string, string][]
405        const setRows = (next: [string, string][]) => update($, envRows, () => next)
406        const field = (key: string, value: string, hint: string, onInput: (v: string) => unknown, width?: number) => (
407          <Box key={`fill:${key}`} {...(width ? { width, flexShrink: 0 } : { flexGrow: 1, minWidth: 0 })}>
408            <ui.Input key={key} value={value} placeholder={hint} onInput={onInput} onSubmit={() => saveAll($)} />
409          </Box>
410        )
411        // On the desktop each group is its name and chips, each taking effect at once; the terminal, which draws no
412        // dropdown to pick from, types members as a comma-separated list.
413        const chips = !look.isTerminal
414        const known = [...new Set([...environments.flatMap(([, m]) => m), ...rows.flatMap(([, m]) => list(m))])]
415        const change = (next: [string, string][]) => setRows(next).then(() => saveAll($))
416        const removeButton = (i: number) => (
417          <Button key={`env-remove:${i}`} plain dimColor hover={{ color: 'error' }} label="Remove" onPress={() => (chips ? change : setRows)(rows.filter((_, j) => j !== i))} />
418        )
419        const chip = (key: string, text: string, drop: () => unknown) => (
420          <Box key={key} flexDirection="row" columnGap={1} alignItems="center" borderStyle="round" borderColor="subtle" paddingX={1}>
421            <Text>{text}</Text>
422            <Button key={`${key.replace(':', '-remove:')}`} plain dimColor label="×" onPress={drop} />
423          </Box>
424        )
425        return card('envs', 'list', 'Environments', <Text dimColor>Shown as one in Watch</Text>,
426          ...(rows.length ? [] : [<Box key="envs-none"><Text dimColor>No groups: each environment shows on its own.</Text></Box>]),
427          ...rows.map(([name, members], i) => chips
428            ? (
429              <Box key={`env-row:${i}`} flexDirection="column" rowGap={1}>
430                <Box flexDirection="row" columnGap={1} alignItems="center" justifyContent="space-between">
431                  {field(`env-group:${i}`, name, 'Name', v => setRows(rows.map((r, j) => (j === i ? [v, r[1]] : r))), ENV_GROUP_WIDTH)}
432                  {removeButton(i)}
433                </Box>
434                <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
435                  {list(members).map(m => chip(`env-chip:${i}:${m}`, m, () => change(rows.map((r, j) => (j === i ? [r[0], list(r[1]).filter(x => x !== m).join(', ')] : r)))))}
436                  {picker(`env-add:${i}`, '', [{ value: '', label: '+ add' }, ...known.filter(k => !list(members).includes(k)).map(k => ({ value: k, label: k }))], '',
437                    (v: string) => v && change(rows.map((r, j) => (j === i ? [r[0], [...list(r[1]), v].join(', ')] : r))))}
438                </Box>
439              </Box>
440            )
441            : canType && narrow
442              // Narrow: the name and Remove, then the members on a line of their own.
443              ? (
444                <Box key={`env-row:${i}`} flexDirection="column">
445                  <Box flexDirection="row" columnGap={1} alignItems="center" justifyContent="space-between">
446                    {field(`env-group:${i}`, name, 'Name', v => setRows(rows.map((r, j) => (j === i ? [v, r[1]] : r))), ENV_GROUP_WIDTH)}
447                    {removeButton(i)}
448                  </Box>
449                  {field(`env-members:${i}`, members, 'Production, Canary', v => setRows(rows.map((r, j) => (j === i ? [r[0], v] : r))))}
450                </Box>
451              )
452              : (
453                <Box key={`env-row:${i}`} flexDirection="row" columnGap={1} alignItems="center">
454                  {canType ? [
455                    field(`env-group:${i}`, name, 'Name', v => setRows(rows.map((r, j) => (j === i ? [v, r[1]] : r))), ENV_GROUP_WIDTH),
456                    field(`env-members:${i}`, members, 'Production, Canary', v => setRows(rows.map((r, j) => (j === i ? [r[0], v] : r)))),
457                  ] : <Text>{`${name}: ${members}`}</Text>}
458                  {removeButton(i)}
459                </Box>
460              )),
461          canType && (
462            <Box key="env-row:new" flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
463              {field('env-group-new', newGroup.name, 'Name, such as Production', v => update($, envDraft, x => ({ ...x, name: v })), ENV_GROUP_WIDTH)}
464              {chips
465                ? picker('env-new-first', '', [{ value: '', label: 'Pick an environment' }, ...known.map(k => ({ value: k, label: k }))], list(newGroup.members)[0] ?? '',
466                  (v: string) => update($, envDraft, x => ({ ...x, members: v })))
467                : field('env-members-new', newGroup.members, 'Production, Canary', v => update($, envDraft, x => ({ ...x, members: v })))}
468              <Button key="env-add" variant="primary" label="Add" onPress={async () => {
469                if (!newGroup.name.trim() || !list(newGroup.members).length) return update($, configError, () => 'A group needs a name and at least one environment.')
470                await (chips ? change : setRows)([...rows, [newGroup.name.trim(), newGroup.members]])
471                await update($, envDraft, () => ({ name: '', members: '' }))
472              }} />
473            </Box>
474          ))
475      })()}
476
477      {card('prs', 'merged', 'Pull requests', null,
478        ...(look.isTerminal
479          ? [...SETTINGS.filter(f => f.section === 'prs').map(setting),
480            row('hint:bots-row', '', <Box key="hint:bots"><Text dimColor>Separate usernames with commas: dependabot, renovate</Text></Box>)]
481          // On the desktop, a chip per bot; typing a name and Enter adds one.
482          : [row('bots', 'Bots', <Box key="bot-chips" flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
483            {((doc.bots ?? ['dependabot']) as string[]).map(b => (
484              <Box key={`bot-chip:${b}`} flexDirection="row" columnGap={1} alignItems="center" borderStyle="round" borderColor="subtle" paddingX={1}>
485                <Text>{b}</Text>
486                <Button key={`bot-chip-remove:${b}`} plain dimColor label="×"
487                  onPress={() => saveConfig($, ['set', 'bots', JSON.stringify(((doc.bots ?? ['dependabot']) as string[]).filter(x => x !== b))]).then(() => saveAll($))} />
488              </Box>
489            ))}
490            {canType && <Box key="fill:bot-add" width={16} flexShrink={0}>
491              <ui.Input key="bot-add" value="" placeholder="+ add a bot"
492                onSubmit={(v: string) => v.trim() && saveConfig($, ['set', 'bots', JSON.stringify([...new Set([...((doc.bots ?? ['dependabot']) as string[]), v.trim()])])]).then(() => saveAll($))} />
493            </Box>}
494          </Box>)]),
495        saveRow('prs'))}
496
497      {card('notify', 'bell', 'Panes and notifications', muteControl(ui, look, $, await read($, muted)),
498        row('panes', 'Show', toggle('panes.watch', 'Watch', doc.panes?.watch !== false), toggle('panes.pull_requests', 'Pull requests', doc.panes?.pull_requests !== false)),
499        row('notify', 'Notify', toggle('notify.watch', 'Watch', doc.notify?.watch === true), toggle('notify.pull_requests', 'Pull requests', doc.notify?.pull_requests === true)))}
500
501      {card('schedule', 'schedule', 'Schedule', stateLine,
502        ...(!doc.schedule ? [<Text key="no-schedule" dimColor>No schedule yet.</Text>] : [
503          group('watch-schedule', 'Watch'),
504          cronPicker('', doc.schedule),
505          look.divider('sep:prs-schedule'),
506          group('prs-schedule', 'Pull requests'),
507          cronPicker('prs', doc.pullRequestsSchedule ?? doc.schedule, [{ value: 'default', label: 'Same as Watch' }], !doc.pullRequestsSchedule && custom !== 'prs'),
508          ...(ownSchedules(doc).length ? [
509            look.divider('sep:own'),
510            group('own', 'Own schedules'),
511            ...ownSchedules(doc).map(o => (
512              <Box key={`own:${o.entry}`} flexDirection="column" rowGap={1} borderStyle="round" borderColor="subtle" paddingX={1}>
513                <Text wrap="truncate-end">{o.entry.split('|').filter(Boolean).join(' › ')}</Text>
514                {cronPicker(o.entry, o.schedule, [{ value: 'default', label: 'Same as every query' }])}
515              </Box>
516            )),
517          ] : []),
518        ]))}
519
520    </Box>
521  )
522}
523
524// The engine caps a process at 10 minutes; a scheduled run has no cap.
525async function checker($: any, args: string[], stdin?: string) {
526  const script = `${$.plugin.root}/bin/on_my_watch.py`
527  const timeoutMs = args[0] === 'run' ? 600_000 : 30_000
528  // Windows has no /usr/bin/env and names it python; its own PATH already finds the tools.
529  if ((await $.env.get('OS')) === 'Windows_NT') return $.process.run(['python', script, ...args], { timeoutMs, stdin })
530  const path = [await $.env.get('PATH'), TOOL_DIRS].filter(Boolean).join(':')
531  return $.process.run(['/usr/bin/env', 'python3', script, ...args], { env: { PATH: path }, timeoutMs, stdin })
532}
533
534async function load($: any) {
535  const done = await checker($, ['latest'])
536  if (done.exitCode !== 0) return
537  const latest = JSON.parse(done.stdout || '{}')
538  await update($, result, () => (latest.checkedAt || latest.prsCheckedAt ? latest : null))
539  const quiet = await checker($, ['notifications', 'status'])
540  if (quiet.exitCode === 0) {
541    const state = JSON.parse(quiet.stdout || '{}')
542    await update($, muted, () => state.muted === true)
543    await update($, quietEntries, () => state.quiet ?? [])
544  }
545  const scheduled = await checker($, ['schedule', 'status'])
546  if (scheduled.exitCode === 0) await update($, scheduleState, () => JSON.parse(scheduled.stdout || 'null'))
547  // Pictures change only when pull requests are fetched: read them again only then, not every minute.
548  if (latest.prsCheckedAt && latest.prsCheckedAt !== picturesFor) {
549    const pictures = await checker($, ['avatars'])
550    if (pictures.exitCode === 0) {
551      await update($, avatars, () => JSON.parse(pictures.stdout || '{}'))
552      picturesFor = latest.prsCheckedAt
553    }
554  }
555}
556// The pull request fetch the pictures in `avatars` were read after.
557let picturesFor: string | undefined
558
559// `panes` in ~/.on-my-watch/config.json; a pane left out is on.
560// Every environment the config knows, as [group, members]: the Environments section's groups in its order, then each
561// env the queries use that no group names, on its own. The Watch filter, the query form and the counts all read this.
562function environmentList(doc: any): [string, string[]][] {
563  const groups = Object.entries((doc?.environments ?? {}) as Record<string, string[]>).filter(([, m]) => Array.isArray(m))
564  const named = new Set(groups.flatMap(([, m]) => m))
565  const loose = [...new Set(((doc?.repos ?? []) as any[]).flatMap(r => r?.queries ?? []).map((q: any) => q.env).filter(Boolean))] as string[]
566  return [...groups, ...loose.filter(e => !named.has(e)).map(e => [e, [e]] as [string, string[]])]
567}
568
569async function readPanes($: any) {
570  let panes: Partial<Record<Part, boolean>> = {}
571  try {
572    const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
573    const config = JSON.parse(await $.fs.read(`${home}/.on-my-watch/config.json`))
574    panes = config.panes ?? {}
575    await update($, configDoc, () => config)
576    // A refusal belongs to the file it was made against: a fresh read leaves it behind.
577    await update($, configError, () => '')
578    await update($, configReadAt, () => new Date().toISOString())
579    const checked = await checker($, ['validate'])
580    const verdict = checked.stdout.trim()
581    await update($, configProblems, () => (checked.exitCode === 0 && verdict === 'config.json holds to the schema' ? [] : (verdict || checked.stderr.trim()).split('\n')))
582    const repos = (Array.isArray(config.repos) ? config.repos : []) as any[]
583    const queries = repos.flatMap(repo => repo?.queries ?? [])
584    const distinct = (values: unknown[]) => [...new Set(values.filter(v => v !== undefined && v !== null && v !== ''))]
585    await update($, choices, () => ({
586      names: repos.map(repo => repo?.name).filter(Boolean),
587      // A grouped env shows as its group, once; the rest as themselves.
588      envs: environmentList(config).map(([group]) => group),
589      envGroups: (config.environments && typeof config.environments === 'object' ? config.environments : {}) as Record<string, string[]>,
590      // The priorities the queries use, most urgent first, by name: High, Medium, Low.
591      priorities: (distinct(queries.map((q: any) => q.priority)) as number[]).sort((a, b) => a - b).map(n => PRIORITY_NAMES[n - 1] ?? `P${n}`),
592      bots: Array.isArray(config.bots) ? config.bots : ['dependabot'],
593    }))
594  } catch {} // no config yet: both panes, the Watch one saying where it goes
595  return { watch: panes.watch !== false, pull_requests: panes.pull_requests !== false }
596}
597
598// The newest run and the config, so the panes draw them however they come to open.
599async function ready($: any) {
600  await load($)
601  return readPanes($)
602}
603
604async function show($: any) {
605  try {
606    const on = await ready($)
607    for (const part of ['watch', 'pull_requests'] as const) if (on[part]) await $.ui.open(PANES[part])
608    await $.ui.open(CONFIG_PANE)
609    // The last pane opened is shown, and opening an open one only retitles it: focus brings Watch to the front.
610    if (on.watch) await $.ui.open({ ...PANES.watch, focus: true })
611  } catch (error) {
612    $.ui.toast(`on-my-watch: ${String(error)}`)
613  }
614}
615
616// Set only by a run this load of the module started. State outlives a reload but the run doesn't, so a `running`
617// flag with this unset is left over from before the reload and must not hold Refresh back.
618let runHere = false
619
620async function checkNow($: any, part: Part | 'all') {
621  if (runHere) return // one run at a time
622  runHere = true
623  await update($, running, () => part)
624  await update($, runningSince, () => new Date().toISOString())
625  try {
626    const chosen = part === 'pull_requests' ? '' : await read($, watchWindow)
627    const done = await checker($, [...(part === 'all' ? ['run'] : ['run', part]), ...(chosen ? ['--window', chosen] : [])])
628    if (done.exitCode !== 0) $.ui.toast(`on-my-watch: check failed: ${done.stderr.trim().split('\n').pop()}`)
629    await load($)
630  } finally {
631    runHere = false
632    await update($, running, () => null)
633  }
634}
635
636// pup signs in through the browser and waits for it, up to the engine's 10-minute cap; then Watch checks again.
637async function signIn($: any) {
638  const path = [await $.env.get('PATH'), TOOL_DIRS].filter(Boolean).join(':')
639  const done = await $.process.run(['pup', 'auth', 'login'], { env: { PATH: path }, timeoutMs: 600_000 })
640  if (done.exitCode !== 0) return $.ui.toast(`on-my-watch: sign-in failed: ${done.stderr.trim().split('\n').pop()}`)
641  await checkNow($, 'watch')
642}
643
644// Opt-in AI: nothing reaches Claude until Investigate is pressed. `investigate` in the config picks the
645// session (a new one by default, or this one), a skill the problem is handed to, and the `prompt` and
646// `problem` templates; a query's own `investigate` overrides each template for its problem.
647async function investigate($: any, r: Result, problems: Problem[], surface: string) {
648  const first = problems[0]!
649  const config = (await read($, configDoc)) as any
650  const options = config?.investigate ?? {}
651  const own = (config?.repos ?? []).find((c: any) => c.name === first.name)?.queries
652    ?.find((q: any) => q.service === first.service && q.env === first.env && q.label === first.label)?.investigate ?? {}
653  const prompt = own.prompt ?? options.prompt
654  const problem = own.problem ?? options.problem
655  const fill = (template: string, values: Record<string, unknown>) =>
656    template.replace(/\{\{(\w+)\}\}/g, (all, name) => (name in values ? String(values[name] ?? '') : all))
657  const lines = problems.map(p => problem
658    ? fill(problem, { name: p.name, repo: p.repo, env: p.env, priority: p.priority != null ? `P${p.priority}` : '', service: p.service,
659      label: p.label, value: p.value, alert: p.alert, source: p.source, query: p.query, link: p.link })
660    : `- ${p.priority != null ? `P${p.priority} ` : ''}${p.service ? `${p.service}: ` : ''}${p.label} is ${p.value}${p.alert ? ` (${p.alert})` : ''}, ${p.source} query \`${p.query}\`. Datadog: ${p.link}`)
661  const body = prompt
662    ? fill(prompt, { name: first.name, repo: first.repo, env: first.env, window: r.window, checkedAt: r.checkedAt,
663      from: r.span?.from, to: r.span?.to, problems: lines.join('\n') })
664    : [`Investigate ${first.name} (${first.repo}) in ${first.env}, in the ${r.window} before ${r.checkedAt ?? 'the last check'}.`, ...lines].join('\n')
665  if (options.session !== 'current' && (await openSession($, options.skill ? `/${options.skill} ${body}` : body, surface))) return
666  if (options.skill) await $.command.run({ command: options.skill, args: body })
667  else await $.prompt.submit({ text: body })
668}
669
670// A new interactive session in this session's folder: the desktop app's own link, or a Terminal window.
671// shortcut: macOS only (`open`, Terminal.app); elsewhere the command fails and Investigate stays in this session.
672async function openSession($: any, text: string, surface: string) {
673  const cwd = await $.session.cwd()
674  const quote = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`
675  const argv = surface === 'terminal'
676    ? ['osascript', '-e', 'on run argv', '-e', 'tell application "Terminal" to activate', '-e', 'tell application "Terminal" to do script (item 1 of argv)', '-e', 'end run',
677      // $'…' keeps the prompt's lines on one command line, so Terminal runs it as typed.
678      `cd ${quote(cwd)} && claude $'${text.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`]
679    : ['open', `claude://code/new?${new URLSearchParams({ q: text, folder: cwd })}`]
680  try {
681    if ((await $.process.run(argv)).exitCode === 0) return true
682  } catch {}
683  await $.ui.toast('on-my-watch: could not open a new session, investigating here')
684  return false
685}
686
687// Opens a file or an address with the system's opener, on the machine Claude runs on. The terminal presses these in
688// place of links: one without link support prints a link's whole URL beside its text.
689async function openWith($: any, target: string) {
690  const windows = (await $.env.get('OS')) === 'Windows_NT'
691  for (const argv of windows ? [['cmd', '/c', 'start', '', target]] : [['open', target], ['xdg-open', target]]) {
692    try {
693      if ((await $.process.run(argv)).exitCode === 0) return
694    } catch {}
695  }
696  $.ui.toast(`on-my-watch: could not open ${target}`)
697}
698async function openConfig($: any) {
699  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
700  await openWith($, `${home}/.on-my-watch/config.json`)
701}
702
703// Mutes every notification, Watch and Pull requests alike, scheduled runs included, until unmuted.
704async function toggleMute($: any) {
705  const done = await checker($, ['notifications', (await read($, muted)) ? 'on' : 'off'])
706  if (done.exitCode === 0) await update($, muted, () => JSON.parse(done.stdout || '{}').muted === true)
707}
708
709// One entry's notifications on or off; the general Mute still silences everything.
710const entryKey = (p: Problem) => `${p.name}|${p.service}|${p.env}|${p.label}`
711async function toggleEntry($: any, p: Problem) {
712  const off = (await read($, quietEntries)).includes(entryKey(p))
713  const done = await checker($, ['notifications', off ? 'loud' : 'quiet', entryKey(p)])
714  if (done.exitCode === 0) await update($, quietEntries, () => JSON.parse(done.stdout || '{}').quiet ?? [])
715}
716
717async function openHistory($: any) {
718  const done = await checker($, ['history'])
719  if (done.exitCode === 0) await update($, runs, () => JSON.parse(done.stdout || '[]'))
720  await update($, watchView, () => 'history')
721}
722
723// Pause and resume write a flag file the scheduled run reads: the schedule itself stays installed.
724async function scheduleCommand($: any, args: string[]) {
725  const done = await checker($, ['schedule', ...args])
726  if (done.exitCode !== 0) return done.stderr.trim().split('\n').pop() || 'the schedule could not be changed'
727  await update($, scheduleState, () => JSON.parse(done.stdout))
728  return ''
729}
730
731// A cron line for every query (entry '') or one query's own; 'default' drops a query's own.
732// A cron line into the draft: '' is Watch's, 'prs' the pull requests', else a query's own by its entry; 'default' drops it.
733async function setSchedule($: any, cron: string, entry: string) {
734  const doc = ((await read($, configDraft)) ?? (await read($, configDoc))) as any
735  const path = entry === '' ? 'schedule' : entry === 'prs' ? 'pullRequestsSchedule'
736    : ownSchedules(doc).find(o => o.entry === entry)?.path
737  if (path) await saveConfig($, cron === 'default' ? ['delete', path] : ['set', path, JSON.stringify(cron.trim())])
738  await update($, customFor, () => null)
739}
740// The queries with a schedule of their own: where each sits in the config, its entry, and its cron line.
741function ownSchedules(doc: any) {
742  return ((doc?.repos ?? []) as any[]).flatMap((r, i) => ((r?.queries ?? []) as any[]).map((q, j) => ({ q, j }))
743    .filter(({ q }) => q?.schedule)
744    .map(({ q, j }) => ({ path: `repos.${i}.queries.${j}.schedule`, entry: [r.name, q.service, q.env, q.label].join('|'), schedule: q.schedule as string })))
745}
746
747async function openRun($: any, id: number) {
748  const done = await checker($, ['show', String(id)])
749  if (done.exitCode !== 0) return $.ui.toast(`on-my-watch: run ${id} could not be read`)
750  await update($, archived, () => JSON.parse(done.stdout))
751  await update($, watchView, () => 'archived')
752}
753
754const window = (span?: { from: string; to: string }) => (span ? `${time(span.from)}–${time(span.to)}` : '')
755// A run's day as people say it: Today, Yesterday, else the weekday and date.
756function day(iso: string) {
757  const at = new Date(iso)
758  const start = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
759  const ago = Math.round((start(new Date()) - start(at)) / 86_400_000)
760  return ago === 0 ? 'Today' : ago === 1 ? 'Yesterday' : at.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' })
761}
762// A row's runs from where its history begins.
763const fromStart = (all: (number | null)[]) => all.slice(Math.max(0, all.findIndex(v => v != null)))
764// Two counts draw a hook, not a trend: a row's chart waits for a third.
765const charted = (p: Problem) => !!p.trend && !p.trend.isNew && p.trend.values.filter(v => v != null).length >= 3
766const sinceText = (p: Problem) => (p.trend?.since && !p.trend.isNew ? `since ${short(p.trend.since)}` : '')
767// Each value placed between the lowest and highest of the values and the threshold, 0 to 1; null stays null.
768function spark(all: (number | null)[], threshold?: number | null) {
769  const values = fromStart(all)
770  const seen = [...values, threshold].filter((v): v is number => v != null)
771  const low = Math.min(...seen), span = Math.max(...seen) - low
772  const at = (v: number) => (span ? (v - low) / span : 0.5)
773  return { points: values.map(v => (v == null ? null : at(v))), line: threshold == null ? null : at(threshold) }
774}
775// A tinted box in the priority's colour: the threshold dashed, the runs as a line with the area under it shaded.
776// A run with no count (stored before every count was kept) breaks the line.
777function sparkSvg(values: (number | null)[], hex: string, threshold: number | null | undefined, width: number) {
778  const pad = 3, h = 20, { points, line } = spark(values, threshold)
779  const x = (i: number) => (points.length > 1 ? pad + (i * (width - 2 * pad)) / (points.length - 1) : width - pad)
780  const y = (v: number) => h - pad - v * (h - 2 * pad)
781  const runs: [number, number][][] = [[]]
782  points.forEach((v, i) => (v == null ? runs.push([]) : runs.at(-1)!.push([x(i), y(v)])))
783  const xy = (p: [number, number]) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`
784  const marks = runs.filter(r => r.length).map(r => (r.length > 1
785    ? `<path d="M${r[0]![0].toFixed(1)},${h - pad} L${r.map(xy).join(' L')} L${r.at(-1)![0].toFixed(1)},${h - pad} Z" fill="${hex}" fill-opacity="0.12"/>`
786      + `<polyline points="${r.map(xy).join(' ')}" fill="none" stroke="${hex}" stroke-width="1.5" stroke-linejoin="round"/>`
787    : `<circle cx="${r[0]![0].toFixed(1)}" cy="${r[0]![1].toFixed(1)}" r="1.4" fill="${hex}"/>`))
788  const last = points.at(-1) == null ? null : [x(points.length - 1), y(points.at(-1)!)] as [number, number]
789  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${h}">`
790    + `<rect width="${width}" height="${h}" rx="4" fill="${hex}" fill-opacity="0.08"/>`
791    + (line == null ? '' : `<line x1="${pad}" y1="${y(line).toFixed(1)}" x2="${width - pad}" y2="${y(line).toFixed(1)}" stroke="${hex}" stroke-opacity="0.55" stroke-dasharray="2 2"/>`)
792    + marks.join('') + (last ? `<circle cx="${last[0].toFixed(1)}" cy="${last[1].toFixed(1)}" r="2.2" fill="${hex}"/>` : '') + '</svg>'
793}
794
795const time = (iso: string) => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
796const plural = (n: number, word: string, many = `${word}s`) => `${n} ${n === 1 ? word : many}`
797function age(iso: string) {
798  const minutes = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000))
799  return minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.round(minutes / 60)}h` : `${Math.round(minutes / 1440)}d`
800}
801
802const DAY = 86_400_000
803// Rows drawn per page. The desktop app stops drawing a pane whose tree grows past what it takes: 60 closed pull
804// requests with their reviewers' pictures drew, 90 did not, nor one repository's 142 queries in Config.
805const PAGE = 30
806// Page numbers under a long list: ‹ 1 2 3 … 10 ›, the page shown as the main button. Nothing for one page.
807function pager(ui: any, key: string, page: number, pages: number, go: (n: number) => unknown) {
808  if (pages <= 1) return null
809  const shown = pages <= 7 ? Array.from({ length: pages }, (_, i) => i + 1)
810    : [...new Set([1, page - 1, page, page + 1, pages].filter(n => n >= 1 && n <= pages))].sort((a, b) => a - b)
811  const items: unknown[] = []
812  if (page > 1) items.push(<ui.Button key={`${key}-prev`} variant="secondary" label="‹" onPress={() => go(page - 1)} />)
813  shown.forEach((n, i) => {
814    if (i > 0 && n - shown[i - 1]! > 1) items.push(<ui.Text key={`${key}-gap:${n}`} dimColor>…</ui.Text>)
815    items.push(<ui.Button key={`${key}:${n}`} variant={n === page ? 'primary' : 'secondary'} label={String(n)} onPress={() => go(n)} />)
816  })
817  if (page < pages) items.push(<ui.Button key={`${key}-next`} variant="secondary" label="›" onPress={() => go(page + 1)} />)
818  return <ui.Box key={`${key}-pager`} flexDirection="row" columnGap={1} alignItems="center" marginTop={1}>{items}</ui.Box>
819}
820// When a refresh last ran, as the panes say it: `Last refresh 16:02`, or with the day when not today.
821// A time today, else with its day: a time alone from yesterday would read as later than today's.
822const when = (iso: string) => (day(iso) === 'Today' ? time(iso) : `${day(iso)} ${time(iso)}`)
823// The same, short enough for a row: a weekday this week, else the date.
824const short = (iso: string) => {
825  const days = (Date.now() - new Date(iso).getTime()) / 86_400_000
826  const on = day(iso) === 'Today' ? '' : days < 6 ? new Date(iso).toLocaleDateString([], { weekday: 'short' }) : new Date(iso).toLocaleDateString([], { day: 'numeric', month: 'short' })
827  return on ? `${on} ${time(iso)}` : time(iso)
828}
829const refreshed = (iso: string) => `Last refresh ${when(iso)}`
830const SPANS = [
831  { value: 'today', label: 'Today', since: () => new Date().setHours(0, 0, 0, 0) },
832  { value: '3d', label: 'Last 3 days', since: () => Date.now() - 3 * DAY },
833  { value: 'week', label: 'Last week', since: () => Date.now() - 7 * DAY },
834  { value: 'month', label: 'Last month', since: () => Date.now() - 30 * DAY },
835]
836// What Refresh checks in Watch; empty is the config's window, which the schedule always uses.
837const WINDOWS = [
838  { value: '', label: 'Config window' },
839  { value: '1m', label: 'Last minute' },
840  { value: '5m', label: 'Last 5 minutes' },
841  { value: '15m', label: 'Last 15 minutes' },
842  { value: '30m', label: 'Last 30 minutes' },
843  { value: '1h', label: 'Last hour' },
844  { value: '4h', label: 'Last 4 hours' },
845  { value: '12h', label: 'Last 12 hours' },
846  { value: '1d', label: 'Last day' },
847  { value: '7d', label: 'Last 7 days' },
848]
849// Below this many cells a Watch row's label and its facts don't fit on one line: priority, label, icon, value, an
850// alert line such as `above 19` and the bell take about this much.
851const NARROW = 64
852const WIDE = 100
853// A chart's column, in cells; the long one, on a narrow row's own line, is twice that.
854const CHART_CELLS = 10
855// A run's window in words, as the picker names it: `1h` is "last hour".
856const windowLabel = (w: string) => (WINDOWS.find(o => o.value === w)?.label ?? `Last ${w}`).toLowerCase()
857// A dropdown where the surface draws one and isn't a terminal; else a button that steps to the next value.
858function pickerFor(ui: any, surface: string) {
859  const Select = 'Select' in ui && surface !== 'terminal' ? ui.Select : undefined
860  const { Button } = ui
861  return (key: string, hotkey: string, options: readonly { value: string; label: string }[], value: string, set: (v: string) => unknown) => {
862    if (Select) return <Select key={key} options={options} value={value} onSelect={(v: string) => set(v)} />
863    const at = Math.max(0, options.findIndex(o => o.value === value))
864    return <Button key={key} plain hotkey={hotkey || undefined} label={options[at]!.label} onPress={() => set(options[(at + 1) % options.length]!.value)} />
865  }
866}
867// A pull request idle this many days has stalled: its idle time turns amber.
868const STALE_DAYS = 7
869// Whose move it is on an open pull request, and in which tone.
870function waitingOn(pr: PullRequest, me: string | null): readonly [string, Tone] | null {
871  const pending = (pr.reviewers ?? []).filter(r => r.review === 'pending').map(r => r.login)
872  if (pr.conflicts) return ['Conflicts', 'warning']
873  if (pr.review === 'CHANGES_REQUESTED') return ['Changes requested', 'error']
874  if (pr.draft) return ['Draft', 'muted']
875  if (pr.review === 'APPROVED' && pr.ci !== 'failing') return ['Ready to merge', 'success']
876  if (me && pending.includes(me)) return ['Waiting on you', ACCENT]
877  if (pending.length) return [`Waiting on ${pending[0]}${pending.length > 1 ? ` +${pending.length - 1}` : ''}`, 'muted']
878  return ['Waiting on review', 'muted']
879}
880// Open and on you: your review is asked for, or it is yours and changes were asked for or CI is red.
881const needsYou = (pr: PullRequest, me: string | null) => !!me && (pr.state ?? 'OPEN') === 'OPEN' && (
882  (pr.reviewers ?? []).some(r => r.review === 'pending' && r.login === me)
883  || (pr.author === me && (pr.review === 'CHANGES_REQUESTED' || pr.ci === 'failing')))
884// A PR opened in the last day gets the "new" mark.
885const isNew = (pr: PullRequest) => Date.now() - new Date(pr.createdAt).getTime() < DAY
886
887// When a PR last moved: closed ones when they closed, open ones when last updated.
888const moved = (pr: PullRequest) => pr.closedAt ?? pr.updatedAt ?? pr.createdAt
889const freshest = (a: PullRequest, b: PullRequest) => moved(b).localeCompare(moved(a))
890
891// PRs active in the span, grouped by repo; repos and PRs freshest first.
892function repos(prs: PullRequest[], chosen: string, bots: string[], state: string) {
893  const since = (SPANS.find(s => s.value === chosen) ?? SPANS[1]!).since()
894  // Runs stored before `state` existed held open PRs only.
895  const inState = prs.filter(pr => (pr.state ?? 'OPEN') === state.toUpperCase())
896  const fresh = inState
897    .filter(pr => new Date(moved(pr)).getTime() >= since)
898    // gh names bot accounts app/<name>; the config names them bare, in any case.
899    .filter(pr => !bots.includes(pr.author?.replace(/^app\//, '').toLowerCase() ?? ''))
900    .sort(freshest)
901  const byRepo = new Map<string, PullRequest[]>()
902  for (const pr of fresh) byRepo.set(pr.repo, [...(byRepo.get(pr.repo) ?? []), pr])
903  return { groups: [...byRepo], shown: fresh.length }
904}
905
906
907// Our own icons: 16x16, one colour. An Svg is an image and can't take theme colours, so each tone has a mid
908// hex that reads on light and dark; the terminal, which draws no Svg, gets the glyph in the theme colour.
909const TONES = {
910  error: ['#D0485C', 'error'],
911  warning: ['#C48A00', 'warning'],
912  success: ['#2F9E44', 'success'],
913  merged: ['#8957E5', 'merged'],
914  accent: ['#D97757', 'claude'],
915  info: ['#4C6EF5', 'suggestion'],
916  muted: ['#8A8A8A', 'inactive'],
917} as const
918type Tone = keyof typeof TONES
919const stroke = 'fill="none" stroke="C" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"'
920const ICONS = {
921  dot: ['<circle cx="8" cy="8" r="4.5" fill="C"/>', '●'],
922  check: [`<path ${stroke} d="M3 8.5l3 3 7-7"/>`, '✓'],
923  cross: [`<path ${stroke} d="M4 4l8 8M12 4l-8 8"/>`, '✗'],
924  ring: [`<circle ${stroke} cx="8" cy="8" r="5"/>`, '○'],
925  draft: [`<circle ${stroke} stroke-dasharray="2.5 2" cx="8" cy="8" r="5"/>`, '◌'],
926  merged: [`<g ${stroke}><circle cx="5" cy="3.5" r="1.6"/><circle cx="5" cy="12.5" r="1.6"/><circle cx="11.5" cy="8.5" r="1.6"/><path d="M5 5.1v5.8M5 5.5c0 2.5 4.8 1.2 4.9 3"/></g>`, '●'],
927  closed: [`<g ${stroke}><circle cx="8" cy="8" r="5"/><path d="M4.5 11.5l7-7"/></g>`, '⊘'],
928  spark: ['<path fill="C" d="M8 1.5l1.7 4.8 4.8 1.7-4.8 1.7L8 14.5l-1.7-4.8L1.5 8l4.8-1.7z"/>', '✦'],
929  warning: ['<path fill="C" d="M8 1.8l6.6 11.7H1.4z"/>', '!'],
930  error: [`<g><circle fill="C" cx="8" cy="8" r="6.5"/><path stroke="#fff" stroke-width="1.8" stroke-linecap="round" d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8"/></g>`, '✗'],
931  bell: [`<g ${stroke}><path d="M4 11.5h8l-1-1.6V7a3 3 0 0 0-6 0v2.9z"/><path d="M7 13.6h2"/></g>`, '!'],
932  logs: [`<g ${stroke}><path d="M4 1.8h5l3 3v9.4H4z"/><path d="M6 8h4M6 10.6h4"/></g>`, ''],
933  metrics: [`<g ${stroke}><path d="M2 13.5h12"/><path d="M2.5 10.5l3-3.5 2.8 2.2L13.5 3"/></g>`, ''],
934  traces: ['<g fill="C"><rect x="1.5" y="2.2" width="9" height="2.8" rx="1.2"/><rect x="4.5" y="6.6" width="8.5" height="2.8" rx="1.2"/><rect x="8" y="11" width="6.5" height="2.8" rx="1.2"/></g>', ''],
935  history: [`<g ${stroke}><circle cx="8" cy="8" r="5.8"/><path d="M8 4.8V8l2.2 1.6"/></g>`, ''],
936  group: [`<g ${stroke}><rect x="2" y="2" width="12" height="5" rx="1.5"/><rect x="2" y="9" width="12" height="5" rx="1.5"/></g>`, ''],
937  list: [`<path ${stroke} d="M2.5 4h11M2.5 8h11M2.5 12h11"/>`, ''],
938  schedule: [`<g ${stroke}><rect x="2" y="3" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M5.5 1.8v2.4M10.5 1.8v2.4M8 8.6v2.2l1.5 1"/></g>`, ''],
939  pause: ['<g fill="C"><rect x="4" y="3" width="3" height="10" rx="0.8"/><rect x="9" y="3" width="3" height="10" rx="0.8"/></g>', ''],
940  play: ['<path fill="C" d="M5 2.8v10.4L13 8z"/>', ''],
941  bellOff: [`<g ${stroke}><path d="M4 11.5h8l-1-1.6V7a3 3 0 0 0-6 0v2.9z"/><path d="M7 13.6h2M2.5 2.5l11 11"/></g>`, ''],
942  // A dog's face: the watch dog that investigates.
943  comment: [`<path ${stroke} d="M2.5 3.5h11v7.5H8l-3 2.5V11H2.5z"/>`, ''],
944  investigate: [`<g ${stroke}><path d="M4.6 6C4 3.6 2 3.8 1.8 6c-.2 2 .5 3.6 1.8 4.2"/><path d="M11.4 6c.6-2.4 2.6-2.2 2.8 0 .2 2-.5 3.6-1.8 4.2"/><path d="M4.3 7.4c0-2.3 1.7-3.6 3.7-3.6s3.7 1.3 3.7 3.6v3.2c0 1.9-1.7 3.2-3.7 3.2s-3.7-1.3-3.7-3.2z"/></g><g fill="C"><circle cx="6.4" cy="8.3" r=".85"/><circle cx="9.6" cy="8.3" r=".85"/><ellipse cx="8" cy="11" rx="1.3" ry=".95"/></g>`, ''],
945} as const
946// What the terminal presses in place of a control's icon.
947const MARKS = { investigate: '✻', bell: '◉', bellOff: '◯', history: 'History' } as const
948type IconName = keyof typeof ICONS
949// Wider than any pane: an Svg with no width takes its own width up to the slot, so it fills the line.
950const HAIRLINE = `<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="1"><rect width="4000" height="1" fill="${'#8A8A8A'}" fill-opacity="0.35"/></svg>`
951const BADGES = {
952  approved: `<circle cx="15" cy="15" r="4.5" fill="${'#2F9E44'}" stroke="#fff" stroke-width="1"/><path d="M13 15.2l1.4 1.4 2.6-2.8" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`,
953  changes: `<circle cx="15" cy="15" r="4.5" fill="${'#D0485C'}" stroke="#fff" stroke-width="1"/><path d="M13.4 13.4l3.2 3.2M16.6 13.4l-3.2 3.2" stroke="#fff" stroke-width="1.4" stroke-linecap="round"/>`,
954  pending: '',
955} as const
956const avatarSvg = (login: string, review: keyof typeof BADGES, picture?: string) =>
957  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><defs><clipPath id="c"><circle cx="9" cy="9" r="8"/></clipPath></defs>` +
958  (picture
959    ? `<image href="${picture}" x="1" y="1" width="16" height="16" clip-path="url(#c)"/>`
960    : `<circle cx="9" cy="9" r="8" fill="#8A8A8A"/><text x="9" y="12.5" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#fff">${login.slice(0, 1).toUpperCase()}</text>`) +
961  `${BADGES[review]}</svg>`
962const svg = (name: IconName, tone: Tone) =>
963  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">${ICONS[name][0].replaceAll('"C"', `"${TONES[tone][0]}"`)}</svg>`
964
965const REVIEW = {
966  APPROVED: ['check', 'success', 'approved'],
967  CHANGES_REQUESTED: ['cross', 'error', 'changes requested'],
968  REVIEW_REQUIRED: ['ring', 'warning', 'waiting for review'],
969} as const
970const CI = { passing: ['dot', 'success', 'ci ok'], failing: ['dot', 'error', 'ci failing'], running: ['dot', 'warning', 'ci running'] } as const
971// What the terminal writes for a mark; empty means the mark is only decoration there.
972const WORDS: Record<IconName, string> = {
973  dot: '', check: 'approved', cross: 'changes', ring: 'review', draft: 'draft', merged: 'merged', closed: 'closed',
974  spark: 'new', warning: '', bell: '', bellOff: '', investigate: '', comment: '', error: '', logs: '', metrics: '', traces: '', history: '', group: '', list: '', schedule: '', pause: '', play: '',
975}
976
977const review = (pr: PullRequest) =>
978  pr.state === 'MERGED' ? (['merged', 'merged', 'merged'] as const)
979    : pr.state === 'CLOSED' ? (['closed', 'muted', 'closed'] as const)
980      : pr.draft ? (['draft', 'muted', 'draft'] as const) : pr.review ? REVIEW[pr.review] : null
981
982export const register: Register = on => {
983
984  on('session.start', async ($, e, next) => {
985    await $.command.register({ name: 'watch', description: 'Show your services and pull requests' })
986    // State outlives a reload but the run that set this flag doesn't: left set, Refresh would never come back.
987    await update($, running, () => null)
988    // Runs land in the database from launchd; read the newest while the session runs.
989    $.clock.every(60_000, () => load($))
990    // No pane is open to say what failed; /watch reads again and toasts it.
991    await ready($).catch(() => {})
992
993    return next(e)
994  })
995
996  // A desktop app attaching reads the newest run; the panes wait for /watch.
997  on('session.attach', async ($, e, next) => {
998    const attached = await next(e)
999    await ready($).catch(() => {})
1000
1001    return attached
1002  })
1003
1004  on('command.run', { command: 'watch' }, async $ => {
1005    await show($)
1006
1007    return { text: 'On my watch opened.' }
1008  })
1009
1010  on('ui.render', { component: 'Pane', requestId: PANES.watch.id }, async ($, e) => {
1011    const ui = $.ui.resolve(e)
1012    const { Box, Text, Button, Link } = ui
1013    const look = surfaceLook(ui, e.surface)
1014    const picker = pickerFor(ui, e.surface)
1015    const latestRun = await read($, result)
1016    const busy = await read($, running)
1017    const view = await read($, watchView)
1018    const since = await read($, runningSince)
1019    const refresh = (part: Part | 'all') => refreshControl(ui, look, $, part, busy, since)
1020    // A full button, not plain: the desktop draws a plain one with a hotkey as a hint, easy to miss as clickable.
1021    const back = <Button key="latest" variant="secondary" hotkey={look.hotkey('l')} label="← Latest" onPress={() => update($, watchView, () => 'latest')} />
1022
1023    if (!latestRun) {
1024      return (
1025        <Box flexDirection="column" gap={1}>
1026          <Text dimColor>No runs yet. Put your config in ~/.on-my-watch/config.json, then check.</Text>
1027          <Box flexDirection="row">{refresh('all')}</Box>
1028        </Box>
1029      )
1030    }
1031
1032    if (view === 'history') {
1033      const past = await read($, runs)
1034      return (
1035        <Box flexDirection="column">
1036          <Box flexDirection="row" justifyContent="space-between"><Text bold>History</Text>{back}</Box>
1037          {past.length === 0 && <Text dimColor>No runs yet.</Text>}
1038          {/* Grouped by day; each run: when, the window it checked, then what it found. */}
1039          {past.flatMap((run, i) => [
1040            ...(i === 0 || day(run.checkedAt) !== day(past[i - 1]!.checkedAt)
1041              ? [<Box key={`day:${day(run.checkedAt)}`} marginTop={1}><Text bold dimColor>{day(run.checkedAt)}</Text></Box>]
1042              : [look.divider(`sep:run:${run.id}`)]),
1043            // Columns that line up down the list: when and the window, then each priority's count.
1044            <Box key={`row:${run.id}`} flexDirection="row" columnGap={1} alignItems="center">
1045              <Box key={`col:when:${run.id}`} width={HISTORY_WHEN_WIDTH} flexShrink={0}>
1046                <Button key={`run:${run.id}`} plain onPress={() => openRun($, run.id)}>
1047                  {`${time(run.checkedAt)}${run.span ? ` · ${window(run.span)}` : ''}`}
1048                </Button>
1049              </Box>
1050              {run.status === 'failed'
1051                // What went wrong, as the checker kept it: the fix it needs and what pup or gh said.
1052                ? <Box key={`reason:${run.id}`} flexDirection="row" columnGap={1} minWidth={0} flexShrink={1}>
1053                  <Text color="error">check failed</Text>
1054                  {run.reason && <Text dimColor wrap="truncate-end">{run.reason}</Text>}
1055                </Box>
1056                : !run.problems
1057                  ? <Text color="success">all clear</Text>
1058                  : [
1059                    // How many services each priority had failing; the words go to screen readers.
1060                    ...[1, 2, 3].map(rank => {
1061                      const n = (run.labels ?? []).filter(l => l.priority === rank).reduce((sum, l) => sum + l.count, 0)
1062                      return (
1063                        <Box key={`col:p${rank}:${run.id}`} width={HISTORY_COUNT_WIDTH} flexShrink={0} flexDirection="row" alignItems="center">
1064                          {n > 0 && [serviceIcon(ui, look, rank, n), <Text key="n">{String(n)}</Text>]}
1065                        </Box>
1066                      )
1067                    }),
1068                  ]}
1069            </Box>,
1070          ])}
1071        </Box>
1072      )
1073    }
1074
1075    const stored = view === 'archived' ? ((await read($, archived)) ?? latestRun) : latestRun
1076    const [chosenPriority, chosenName, chosenEnv] = [await read($, priorityFilter), await read($, nameFilter), await read($, envFilter)]
1077    const known = await read($, choices)
1078    // Runs stored before entries had names carry service instead; they show under it until refreshed.
1079    const named = stored.problems.map(p => ({ ...p, name: p.name ?? (p as any).alias ?? (p as any).service }))
1080    const r = {
1081      ...stored,
1082      problems: named
1083        .filter(p => !chosenPriority || nameOf(p) === chosenPriority)
1084        .filter(p => !chosenName || p.name === chosenName)
1085        .filter(p => !chosenEnv || p.env === chosenEnv || (known.envGroups?.[chosenEnv] ?? []).includes(p.env)),
1086    }
1087    // The config's choices; before the config is read, those this run found.
1088    // Narrow panes put each row's facts under its label; the width is in character cells.
1089    const columns = (e.props as { bodyColumns?: number }).bodyColumns ?? 100
1090    const narrow = columns < NARROW
1091    // Wide rows also say since when and what they read; below that the label keeps the room.
1092    const wide = columns >= WIDE
1093    const quietOnes = await read($, quietEntries)
1094    const listed = <T,>(fromConfig: T[], found: T[]) => (fromConfig.length ? fromConfig : [...new Set(found)])
1095    const priorityOptions = [{ value: '', label: narrow ? 'Priority' : 'All priorities' }, ...listed(known.priorities, named.map(nameOf)).map(p => ({ value: p, label: p }))]
1096    const nameOptions = [{ value: '', label: narrow ? 'Repository' : 'All repositories' }, ...listed(known.names, named.map(p => p.name)).map(n => ({ value: n, label: n }))]
1097    const envOptions = [{ value: '', label: narrow ? 'Env' : 'All environments' }, ...listed(known.envs, named.map(p => p.env)).map(n => ({ value: n, label: n }))]
1098
1099    // One card per name and environment; the most urgent first, a query without priority after all that have one.
1100    const rank = (p: Problem) => (p.priority == null ? Number.MAX_SAFE_INTEGER : p.priority)
1101    const cards: Problem[][] = [...r.problems.reduce((all, p) => all.set(`${p.name}@${p.env}`, [...(all.get(`${p.name}@${p.env}`) ?? []), p]), new Map<string, Problem[]>()).values()]
1102      .map(list => [...list].sort((a, b) => rank(a) - rank(b)))
1103    cards.sort((a, b) => rank(a[0]!) - rank(b[0]!) || a[0]!.name.localeCompare(b[0]!.name))
1104    // The history column fits the widest history on screen: every row lines up, no cell is left blank for a
1105    // longer one that isn't there.
1106    const historyCells = Math.max(3, ...r.problems.map(p => {
1107      const said = sinceText(p).length
1108      const drawn = charted(p) ? (look.isTerminal ? fromStart(p.trend!.values).length : CHART_CELLS) : 0
1109      return p.trend?.isNew ? 3 : drawn + (drawn && said ? 1 : 0) + said
1110    }))
1111    const pausedAt = (await read($, scheduleState))?.paused
1112    const controls = [
1113      pausedNote(ui, look, pausedAt),
1114      wide || look.isTerminal
1115        ? (
1116          <Box key="history-row" flexDirection="row" alignItems="center">
1117            {look.icon('history', 'muted', 'History')}
1118            <Button key="history" plain dimColor hotkey={look.hotkey('h')} label="History" onPress={() => openHistory($)} />
1119          </Box>
1120        )
1121        : look.control('history', 'history', 'History', () => openHistory($)),
1122    ]
1123    // The window is picked in the header, so the filters fit one line; picking one checks it right away.
1124    // The config's choice names the config's window; the last run's may be another, picked earlier.
1125    const configured = ((await read($, configDoc)) as any)?.window as string | undefined
1126    const windows = WINDOWS.map(o => (o.value === '' && configured ? { ...o, label: `Config: ${windowLabel(configured)}` } : o))
1127    const windowPicker = picker('window', 't', windows, await read($, watchWindow), async v => { await update($, watchWindow, () => v); await checkNow($, 'watch') })
1128    const checked = r.checkedAt ? (wide ? ` · ${refreshed(r.checkedAt)}` : ` · ${when(r.checkedAt)}`) : ''
1129
1130    return (
1131      <Box flexDirection="column">
1132        {r.setup?.filter(s => s.tool === 'pup').map(s => (
1133          <Box key={`setup:${s.tool}`} flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center">
1134            <Text color="warning">{s.fix}</Text>
1135            {s.need === 'login' && <Button key="signin:pup" variant="secondary" label="Sign in" onPress={() => signIn($)} />}
1136          </Box>
1137        ))}
1138        {view === 'archived'
1139          ? (
1140            <Box key="archived" flexDirection="row" justifyContent="space-between" columnGap={1}>
1141              <Text dimColor>{`Run ${r.checkedAt ? time(r.checkedAt) : ''}${r.span ? ` · ${window(r.span)}` : ''}`}</Text>
1142              {back}
1143            </Box>
1144          )
1145          : (
1146            // Line one: what the last check found, History and Refresh; line two: what to show and check.
1147            <Box key="watch" flexDirection="column">
1148              <Box key="header" flexDirection="row" flexWrap="wrap" justifyContent="space-between" alignItems="center" columnGap={2}>
1149                <Box flexDirection="row" flexShrink={1} minWidth={0} columnGap={1} alignItems="center" flexWrap="wrap">
1150                  {!r.checkedAt
1151                    ? <Text dimColor>not checked yet</Text>
1152                    : r.status === 'failed'
1153                      ? <Text color="error" bold>check failed</Text>
1154                      : r.problems.length
1155                        // How many services each priority has failing, the most urgent first.
1156                        ? <Box key="counts" flexDirection="row" columnGap={2} alignItems="center">
1157                          {[1, 2, 3, null].map(rank => [rank, r.problems.filter(p => (p.priority ?? null) === rank).length] as const)
1158                            .filter(([, n]) => n > 0)
1159                            .map(([rank, n]) => (
1160                              <Box key={`count:${rank}`} flexDirection="row" alignItems="center">
1161                                {serviceIcon(ui, look, rank, n)}<Text bold>{String(n)}</Text>
1162                              </Box>
1163                            ))}
1164                        </Box>
1165                        : <Text dimColor>Checked</Text>}
1166                  {windowPicker}
1167                  <Text dimColor>{checked}</Text>
1168                </Box>
1169                <Box flexDirection="row" columnGap={2} alignItems="center" flexShrink={0} flexGrow={1} justifyContent="flex-end">
1170                  {controls}
1171                  {refresh('watch')}
1172                </Box>
1173              </Box>
1174              {look.divider('sep:watch')}
1175              <Box key="filters" flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
1176                {picker('priorities', 'p', priorityOptions, chosenPriority, v => update($, priorityFilter, () => v))}
1177                {picker('names', 'v', nameOptions, chosenName, v => update($, nameFilter, () => v))}
1178                {picker('envs', 'e', envOptions, chosenEnv, v => update($, envFilter, () => v))}
1179              </Box>
1180            </Box>
1181          )}
1182
1183        {/* Nothing crossed a line: say so in the middle of where the cards would be. */}
1184        {r.checkedAt && r.status !== 'failed' && cards.length === 0 && (
1185          <Box key="clear" flexDirection="column" alignItems="center" marginY={2}>
1186            <Box flexDirection="row" columnGap={1} alignItems="center">
1187              {look.icon('check', 'success', 'all clear')}
1188              <Text color="success" bold>All clear</Text>
1189            </Box>
1190            <Text dimColor>No query crossed its alert line</Text>
1191          </Box>
1192        )}
1193        {cards.map(list => {
1194          const first = list[0]!
1195          const id = `card:${first.name}@${first.env}`
1196          return (
1197            // A card: name and environment under its most urgent priority, then one row per query over its line.
1198            <Box key={id} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} marginTop={1}>
1199              {/* Top-aligned: a title that wraps keeps its priority beside its first line. */}
1200              <Box key={`card-head:${first.name}@${first.env}`} flexDirection="row" columnGap={1} alignItems="flex-start">
types/index.d.ts 154 lines
1// What bin/on_my_watch.py stores per run and prints with `latest`; the pane only reads it.
2
3/** A query of a named entry that crossed its alert line in the checked window. */
4export type Problem = {
5  /** The repository's name from the config, such as Checkout. */
6  name: string
7  /** The repository, owner/name. */
8  repo?: string | null
9  /** The service in that repository, as the config names it. */
10  service?: string
11  /** The query's own env, for display; the query itself says which data it reads. */
12  env: string
13  label: string
14  /** Rank: 1 is the most urgent. */
15  priority?: number | null
16  /** The priority as the config names it: high, or P1 when priorities are numbers. */
17  priorityName?: string
18  source: 'logs' | 'traces' | 'metrics'
19  value: string
20  /** The alert line it crossed, in words: `above 19`. */
21  alert?: string
22  /** The query in Datadog, pinned to the checked window. */
23  link: string
24  query: string
25  /** The config's notify for this query; false keeps it in the pane only. */
26  notify?: boolean
27  /** The count as measured; runs stored before it was kept have none. */
28  raw?: number | null
29  /** The alert line as a number, when the query alerts above one. */
30  threshold?: number | null
31  /** The last runs, worked out when the newest is read. */
32  trend?: {
33    /** Oldest first; null where the entry was under its line. */
34    values: (number | null)[]
35    /** When its current unbroken stretch over the line began. */
36    since: string | null
37    previous: number | null
38    /** The run before didn't have it. */
39    isNew: boolean
40  }
41}
42
43export type PullRequest = {
44  /** Its size: files changed, lines added and taken out. Older runs have none. */
45  files?: number | null
46  additions?: number | null
47  deletions?: number | null
48  /** It conflicts with its base; behind it. */
49  conflicts?: boolean
50  behind?: boolean
51  /** Review threads not yet resolved. */
52  threads?: number
53  /** The names of the entries whose repo this is. */
54  name?: string
55  /** Older runs only, before entries had names. */
56  service?: string
57  alias?: string | null
58  repo: string
59  number: number
60  title: string
61  author: string | null
62  /** The author is a bot account, such as dependabot. */
63  bot: boolean
64  draft: boolean
65  state: 'OPEN' | 'MERGED' | 'CLOSED'
66  /** All checks in one state; null when the PR has none. */
67  ci?: 'passing' | 'failing' | 'running' | null
68  review: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null
69  createdAt: string
70  /** Last activity; the list is sorted and filtered by it. */
71  updatedAt: string
72  /** Closed and merged ones: when; a later comment moves updatedAt, not this. */
73  closedAt?: string | null
74  /** Who reviewed or is asked to: approved, asked for changes, or still pending. */
75  reviewers?: { login: string; review: 'approved' | 'changes' | 'pending' }[]
76  url: string
77}
78
79export type Part = 'watch' | 'pull_requests'
80
81export type Result = {
82  /** When observability last ran; null until it has. */
83  checkedAt: string | null
84  window: string
85  /** The exact window the watch run counted; every link of the run is pinned to it. */
86  span?: { from: string; to: string }
87  status: 'ok' | 'failed'
88  errors: string[]
89  problems: Problem[]
90  pullRequests: PullRequest[]
91  /** When pull requests were last fetched; null until they have been. */
92  prsCheckedAt: string | null
93  /** Who the GitHub CLI is signed in as: whose review a PR waits on. */
94  viewer?: string | null
95  prErrors: string[]
96  /** What the user must do first: install or sign in to pup or gh. */
97  setup: { tool: 'pup' | 'gh'; need?: 'install' | 'login'; fix: string }[]
98}
99
100/** One past watch run, from the checker's `history`. */
101export type RunSummary = {
102  id: number; checkedAt: string; status: 'ok' | 'failed'; span?: { from: string; to: string }
103  /** Why a failed run failed: the fix it needed and what pup or gh said; absent for an ok run. */
104  reason?: string | null
105  /** What it found: problems, and how many per label (most urgent first). */
106  problems: number; labels?: { label: string; count: number; priority: number | null; priorityName: string }[]
107}
108
109declare module 'claude-code' {
110  interface PluginState {
111    'on-my-watch': { result: Result | null; running: Part | 'all' | null
112      runningSince: string | null; span: string; hideBots: boolean; prState: 'open' | 'merged' | 'closed'
113      /** The page of pull requests shown, from 1. */
114      prPage: number
115      priorityFilter: string
116      nameFilter: string
117      envFilter: string
118      /** What the Watch dropdowns offer, from the config's entries and queries. */
119      choices: { names: string[]; envs: string[]; envGroups: Record<string, string[]>; priorities: string[]; bots: string[] }
120      /** Entries whose notifications are turned off from the pane, as `repo name|service|env|label`. */
121      quietEntries: string[]
122      groupPrs: boolean
123      watchView: 'latest' | 'history' | 'archived'
124      /** The config as the Config pane edits it; when it was read, and the schema's objections to it. */
125      configDoc: Record<string, any> | null
126      configReadAt: string
127      configProblems: string[]
128      /** Why the last change was refused; empty when it saved. */
129      configError: string
130      /** Settings typed but not saved yet, by path. */
131      settingsDraft: Record<string, string> | null
132      /** The environment groups as typed, [name, comma-separated members] row by row; null until one changes. */
133      envRows: [string, string][] | null
134      /** The environment group being added. */
135      envDraft: { name: string; members: string }
136      /** The config with the Config pane's unsaved changes; null when it has none. */
137      configDraft: Record<string, any> | null
138      runs: RunSummary[]
139      archived: Result | null
140      watchWindow: string
141      /** Reviewers' pictures as data URLs, by login: an Svg can't load one from the web. */
142      avatars: Record<string, string>
143      /** Notifications are muted: a flag file the checker reads, so scheduled runs stay quiet too. */
144      muted: boolean
145      /** Scheduled runs: paused since when (null while on), the general cron line, the next run, and queries with their own. */
146      scheduleState: { paused: string | null; schedule: string; next: string | null; own: { entry: string; schedule: string }[] } | null
147      /** The picker showing a Custom input: '' for the general schedule, else a query's entry; null for none. */
148      customFor: string | null
149      /** The Custom input's text, for Save. */
150      cronDraft: string
151    }
152  }
153}
154