The briefing as a clickable dashboard in Claude Code (German or English): decide with buttons, hand topics to new cmux tabs, watch running tabs. Active only…

Stop re-explaining your work to your coding agent every morning.
A git repo of markdown and YAML that Claude Code, Codex or Copilot CLI reads at the start of every session. Your repos, your clients, your open tasks and yesterday's work log are already in context, so the first answer picks up where you stopped. No database, no hosted service: plain files in a private repo you own. The BKS-Lab team runs its own work on it every day (status).
> good morning
Morning. You're mid-incident on bigcorp: Stripe webhook retries are
failing in prod (P1, opened yesterday). Root cause traced to the
webhook secret; next step is the retry fix. startupxyz onboarding:
email-verification wired, 6 tests green. Board: 2 doing · 1 review ·
cart-a11y waits on PR #214.
That is the shipped demo workspace answering. The clip below is the same morning, 15 seconds without sound (click it for the 1:25 film on YouTube); the full live session adds the morning session start, an incident taken from log triage to a tested fix, and first-run onboarding.
<a id="get-started"></a>
Your data needs a private home before you write any of it: your own private repo becomes origin, BKS open-bridge stays a read-only upstream. The easiest way is to let your agent do it.
Set up BKS open-bridge for me by following this guide:
https://github.com/bks-lab/open-bridge/blob/main/SETUP.md
Read all of it first, then show me your plan and wait for my go
before you change anything.
If you can't open links: git clone https://github.com/bks-lab/open-bridge.git my-bridge,
then read my-bridge/SETUP.md and follow it.
Every step the agent takes is in SETUP.md: check your tools, show you the plan, re-home the remotes to your private repo, arm the push guard, prove it, then ask you to restart it inside the new folder. Why setup ends with a restart: docs/install.md.
Two commands, nothing to configure. The repo ships a runnable demo (examples/agency/): a fictional two-client agency with a filled board, two days of logged work, and a P1 incident in flight. You need an agent CLI, for example npm install -g @anthropic-ai/claude-code.
git clone https://github.com/bks-lab/open-bridge.git
cd open-bridge/examples/agency && claude # or: codex, copilot
Ask good morning, then where was I on the payment retry? and why is the cart task in review?. Everything it answers is read from plain markdown in that folder: open work/log.md next to it and the trick disappears. Do not git push from this clone; it points at the public repo.
One person wearing several hats (a consultancy partner who is also a freelancer, with a household and a home server)? examples/portfolio/ is that shape: three personas, a managed client service, life admin, and digests that stay silent until something is red.
CLAUDE.md is probably enough. open-bridge pays off once you juggle several repos, clients or roles.Four pieces do the daily work:
| Piece | What it is |
|---|---|
| Registry | ecosystem.yaml: your repos, clients and workspaces in one file, read as a table of contents with one entry fetched on demand (context-index) |
| Task management | work/: one folder per task with a STATUS.md, a generated board, and an append-only daily log (work-system) |
| Skills | one skills/ tree of plain-language verbs (/briefing, /debrief, /archive, …) that every supported tool loads (commands) |
| Standing orders | always-on rules in protocols/standing-orders/, loaded every session and scoped per sub-agent |
And capabilities you switch on when you need them:
| Capability | One line |
|---|---|
| Org overlays | subscribe to your organisation's shared config by git URL, without a fork |
| Workloads | one file per scheduled job or daemon on your machines, checked against the live service manager |
| Inbox | everything that needs you waits in one place until the live source says it is done, and the briefing shows it first |
| Workplace | plan the day as workspaces with one agent tab per task, and steer the tabs from one place through a driver for your terminal tool |
| Always-on machine | a machine that stays on runs your watchers and files what it finds into the inbox while your laptop sleeps |
| Secrets | reference URIs instead of values, and a skill that resolves them without printing them |
| Memory | durable facts as one file each, versioned with the rest of the instance |
| Tracker sync | reconcile tasks with GitHub Project boards, gated, never automatic |
| Meeting debrief | transcript to protocol, proposed tasks and a draft recap mail |
| Bridge-Agents | a persistent A2A endpoint that fronts a persona to the outside world, under a human gate |
| Workspaces | bind the repos and config overlays one engagement touches into a named container |
| Learning loop | lessons land in the skill they concern; improvement proposals wait for your review in /bridge-learn |
| Where things live | a question in your own words on the left, the file that answers it on the right |
A CLAUDE.md is one flat instruction sheet. A personal notes vault wired to an agent (the Obsidian-style second brain) remembers well for one person. A Bridge is a structured workspace that keeps a work record across sessions, keeps clients and companies apart in separate instances, and shares one company layer across a team: a new colleague starts with a Bridge that already knows the clients and the routines (org overlays). Template updates arrive without touching your data.
a CLAUDE.md | session-history recall | memory-MCP server | Notion/Linear + MCP | notes vault + agent | BKS open-bridge | |
|---|---|---|---|---|---|---|
| Survives across sessions | partly: static instructions | yes | yes | yes | yes | yes |
| Plain files you own, diffable, no lock-in | yes | a local index over past sessions | usually a DB or vendor store | no, SaaS | yes | yes |
| Same context in Claude Code, Codex, Copilot CLI | partly | yes, for the tools it indexes | per-tool wiring | per-tool wiring | depends on the setup | yes: one skills/ tree |
| Shipped work structure (board, log, task status) | no | no, it recalls, it does not organise | no | you build it yourself | notes and daily pages | yes |
| Separate per-client worlds | no | not its purpose | no | manual discipline | one personal vault | yes: one instance per client or company |
| Shared company layer for a team | no | not its purpose | no | shared workspace, SaaS | no | yes: an org overlay every instance subscribes to |
| Safety gates written in (propose-confirm, push guard) | no | not its purpose | no | no | no | yes |
Where the others are ahead, honestly: a CLAUDE.md is the right answer for one repo and one person, and open-bridge is more files and rules than that case needs. Session-history recall works with no discipline from you, while open-bridge relies on the agent writing log rows and task status, a convention it follows rather than a guarantee. And open-bridge publishes no measured benefit numbers yet.
cat exactly what the agent reads; its memory is a git history you own. These are conventions the agent follows, not an OS sandbox: read them in AGENTS.md and adapt them.Five moments from a long-running instance, told generically.
Morning triage. "good morning" returns one list: a client's overnight error report has two new failure clusters, a backup target is three days stale, a tax document is due Friday, two applications have had no reply for over ten days. Nothing else is shown, because the rest is green. (briefing)
After a call. The recording becomes a transcript, the transcript a protocol in the team wiki, three proposed board items waiting for your approval, and a recap mail as a draft. The agent does not send it; you press send. (debrief, transcription worker)
An incident without context loss. "What happened to invoice X?" A sub-agent searches the logs and returns a short delta table instead of a log dump. The finding lands in the task's STATUS.md and a log row, so tomorrow's session resumes mid-thought. (sub-agents, task management)
The quiet server. Dozens of declared jobs run on a small home server. The agent never trusts a status field; it asks the service manager, and only a broken or stale job becomes a morning line with a fix hint. (workloads, deploy reconciliation)
Wearing another hat. Switching to a freelance client swaps the signature, the filing path and the board. A private task never syncs to any tracker, because its STATUS.md says bridge_only: true. (personas, task sync)
your-bridge/
├── ecosystem.yaml registry: repos, clients, workspaces
├── identity/ WHO am I, to WHOM do I send
├── infra/ WHERE runs what, HOW to reach it
├── workflow/ WHAT happens when (contexts, projects)
├── skills/ the verbs, one tree for every tool
├── protocols/standing-orders/ always-on rules
└── work/
├── board.md generated task board
├── log.md daily work log
├── tasks/bigcorp-api-payment-retry/STATUS.md
├── tasks/startupxyz-onboarding/STATUS.md
├── streams/ long-running, never "done"
└── done/2026-06/ closed, archived monthly
The tree above stops at what a fresh instance edits day to day. It omits the tooling and reference layers, bin/, scripts/, docs/, examples/, themes/, trackers/, rules/ and agents/, laid out in docs/structure.md.
A task's STATUS.md starts with YAML frontmatter (template, schema); status is a closed enum (backlog, doing, review, done):
---
slug: bigcorp-api-payment-retry
type: incident
status: doing
priority: P1
created: 2026-06-23
last_updated: 2026-06-24
headline: "Stripe webhook signatures failing in prod (P1)"
sync:
bridge_only: false
github: { repo: acme-dev/bigcorp-issues, issues: [142] }
---
A log row uses one frozen format, | YYYY-MM-DD HH:MM | glyph | context | what |:
| 2026-06-24 14:30 | 📝 | bigcorp | logged the Stripe webhook-secret root cause + next steps in the task STATUS |
<a id="sub-agents"></a>
%%{init: {'theme': 'dark'}}%%
flowchart LR
USER((You)) -->|"good morning, /debrief, /archive"| AGENT[Your coding agent]
AGENT -->|reads at session start| BRIDGE
subgraph BRIDGE["BKS open-bridge (plain text in git)"]
ECO[Registry<br/>ecosystem.yaml]
WORK[Task management<br/>work/]
SKILLS[Skills<br/>skills/]
STANDING[Standing orders<br/>protocols/standing-orders/]
end
AGENT -->|heavy reads| SUB[Sub-agents<br/>.claude/agents/]
SUB -->|short summary| AGENT
CORE and USER, in five lines. CORE (skills, rules, scripts, docs, templates) ships on the default branch and updates for everyone. Your data (config, tasks, logs, personas, references to credentials) lives on your own user/{name} branch. The two touch disjoint paths, so git merge upstream/main does not meet your edits (updating). Your branch goes only to your private origin; a pre-push guard (push-guard) blocks it from a public remote. Generic improvements flow back through /bridge-promote as fork PRs, routed by each file's scope: (extension model).
Sub-agents (Claude Code) take heavy reads out of the main session and return a summary; one reference sub-agent ships in .claude/agents/. What a session loads before its first answer has a declared budget that CI enforces: docs/context-index.md.
<a id="status"></a>
Early and newly public. BKS open-bridge is used every day by the BKS-Lab team on its own instances, and besides those there are closed instances run for other companies. One maintainer's own instance, measured on 24 September 2026: 4,072 work-log rows since 4 July, 164 closed tasks, 2,426 commits since 20 June. The first outside contribution, a GitLab tracker playbook, was merged the same day (#244). Why BKS-Lab runs on it, and what changed since June: blog post, and the lab page shows it in operation. Every merge to main ships as its own release (releasing.md), so the version number moves fast by design; it tracks merges, not maturity. What is proven, what is still a bet, and what is open lives in one place: ROADMAP.md. React on the issues you want most; that is how priorities get decided. Found a rough edge? Open an issue.
See CONTRIBUTING.md. Contribute from your private instance, never from a public fork you onboarded into. Build something generic on CORE, then run /bridge-contribute (files) or /bridge-promote (commits): it routes by scope:, runs the mandatory content-safety scan, and opens a fork-based PR with a DCO sign-off (git commit -s). scope: user content never goes anywhere public.
Code and content are MIT. A separate trademark policy covers the name BKS open-bridge and the logo, because licenses cover copyright, not brands. Copyright (c) 2026 BKS-Lab (Boiman Kupermann Solutions GmbH) and Contributors. Provided as-is, without warranty.
BKS open-bridge draws on a large body of public work; named inspirations are in ACKNOWLEDGMENTS.md.
hooks/register.tsx 1830 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ResolveInput } from 'claude-code'
3
4import type {
5 Mark, Mode, PageId, Row, SectionId, SideTab, Tab, TabSeen, Target, Team, TeamRun, UiConfig, View,
6} from '../types'
7import {
8 askAbout, askInfo, asText, ATTENTION, BRIEFING_PAGE, clockTime, DAY, evening, filedBy, followUp, isoDate,
9 isWaiting, launchItem, layout, longText, mergeView, nextMonday, overview, pageBadge, planDay, parseConfig, parseDate,
10 parseTasks, parseTeams, parseView, refNumber, rowUrl, short, SHORTCUT, stateWord, tabColor, tabFor, taskRow,
11 teamState, toTab, withTasks,
12} from './logic'
13import type { Layout, Section, TaskInfo } from './logic'
14import { TABLES, T, setLanguage } from './text'
15
16/**
17 * briefing-ui: the briefing as a clickable dashboard in Claude Code.
18 *
19 * The mod is a thin surface over the bridge scripts and holds
20 * no logic of its own: data comes from `briefing.py collect --json`,
21 * `inbox.py` and `workplace.py status --json`, every action is one of these
22 * calls. It does nothing unless the session runs in a bridge
23 * and `briefing.claude_code_ui.enabled` in bridge-config.yaml is true.
24 *
25 * The card is built around the questions of the day, not around the sections
26 * of the briefing: who is waiting for me, what do I hand off, what am I working on,
27 * what is for later. Compact shows only the most important rows of each block.
28 *
29 * Early access: written against the function hooks API of Claude Code
30 * 2.1.291, which may change between versions.
31 */
32
33const PANE = 'briefing-ui'
34const STORE_VIEW = 'briefing-ui:view'
35const STORE_DAY = 'briefing-ui:morning'
36const STORE_HIDDEN = 'briefing-ui:hidden'
37const STORE_TEAMS = 'briefing-ui:teams'
38
39const config = atom({ plugin: 'briefing-ui', key: 'config' } as const, null)
40const view = atom({ plugin: 'briefing-ui', key: 'view' } as const, null)
41const loading = atom({ plugin: 'briefing-ui', key: 'loading' } as const, null)
42const tabs = atom({ plugin: 'briefing-ui', key: 'tabs' } as const, [])
43const selected = atom({ plugin: 'briefing-ui', key: 'selected' } as const, [])
44const settled = atom({ plugin: 'briefing-ui', key: 'settled' } as const, [])
45const expanded = atom({ plugin: 'briefing-ui', key: 'expanded' } as const, null)
46const notes = atom({ plugin: 'briefing-ui', key: 'notes' } as const, [])
47const isBandHidden = atom({ plugin: 'briefing-ui', key: 'isBandHidden' } as const, false)
48const messageTo = atom({ plugin: 'briefing-ui', key: 'messageTo' } as const, null)
49const hidden = atom({ plugin: 'briefing-ui', key: 'hidden' } as const, {})
50const openSection = atom({ plugin: 'briefing-ui', key: 'openSection' } as const, null)
51const showAll = atom({ plugin: 'briefing-ui', key: 'showAll' } as const, false)
52const showTabs = atom({ plugin: 'briefing-ui', key: 'showTabs' } as const, false)
53const cardId = atom({ plugin: 'briefing-ui', key: 'cardId' } as const, null)
54const isClosed = atom({ plugin: 'briefing-ui', key: 'isClosed' } as const, false)
55const showHelp = atom({ plugin: 'briefing-ui', key: 'showHelp' } as const, false)
56const helpLang = atom({ plugin: 'briefing-ui', key: 'helpLang' } as const, 'en' as 'de' | 'en')
57const teamList = atom({ plugin: 'briefing-ui', key: 'teams' } as const, [])
58const teamOf = atom({ plugin: 'briefing-ui', key: 'teamOf' } as const, {})
59const tabSeen = atom({ plugin: 'briefing-ui', key: 'tabSeen' } as const, {})
60const lastSnapshot = atom({ plugin: 'briefing-ui', key: 'lastSnapshot' } as const, null)
61const snapshots = atom({ plugin: 'briefing-ui', key: 'snapshots' } as const, null)
62const sideTab = atom({ plugin: 'briefing-ui', key: 'sideTab' } as const, 'all')
63const sidePage = atom({ plugin: 'briefing-ui', key: 'sidePage' } as const, 0)
64const sideMore = atom({ plugin: 'briefing-ui', key: 'sideMore' } as const, false)
65const page = atom({ plugin: 'briefing-ui', key: 'page' } as const, 'briefing' as PageId)
66/** How long the last quick run and the last second run took (ms), for the loading indicator */
67const loadTook = atom({ plugin: 'briefing-ui', key: 'loadTook' } as const, {} as Record<string, number>)
68const tabsError = atom({ plugin: 'briefing-ui', key: 'tabsError' } as const, null as string | null)
69const isControl = atom({ plugin: 'briefing-ui', key: 'isControl' } as const, null as boolean | null)
70const starting = atom({ plugin: 'briefing-ui', key: 'starting' } as const, [] as string[])
71const confirmAll = atom({ plugin: 'briefing-ui', key: 'confirmAll' } as const, false)
72/**
73 * True once a collect came back without a view: the built-in profile, or a profile whose view.style is
74 * sources. The card is built from the view, so from then on this session asks for the triage view.
75 */
76const askTriage = atom({ plugin: 'briefing-ui', key: 'askTriage' } as const, false)
77/** Why bridge-config.yaml could not be read; null when it was read (switched on or off) */
78const configError = atom({ plugin: 'briefing-ui', key: 'configError' } as const, null as string | null)
79
80const READ_CONFIG = [
81 'import json, yaml',
82 "c = (yaml.safe_load(open('bridge-config.yaml', encoding='utf-8')) or {})",
83 "u = dict((c.get('briefing') or {}).get('claude_code_ui') or {})",
84 "u['_control'] = (((c.get('workplace') or {}).get('control') or {}).get('name'))",
85 "l = c.get('language')",
86 "u['_language'] = l.get('conversation') if isinstance(l, dict) else l",
87 'print(json.dumps(u))',
88].join('\n')
89
90const PRIO_COLOR: Record<string, string> = { P0: 'red', P1: 'yellow', P2: 'cyan', P3: 'gray' }
91const TAB_CAP = 4
92/** Below this width (columns) the card in the chat also draws as a sidebar */
93const NARROW = 90
94
95const SECTION_COLOR: Record<SectionId, string | undefined> = { now: 'yellow', give: 'cyan', work: 'green', later: 'gray' }
96/** Separator line that the layout shortens to the width */
97const RULE = '─'.repeat(300)
98/** The languages name themselves in their own language */
99const LANG_NAME = { de: 'Deutsch', en: 'English' } as const
100/** The identifier sits in the text of the output line, the only place that tells one card from another. */
101const CARD_ID = /\(briefing-ui (\d+)\)/
102
103// ---------------------------------------------------------------- Bridge calls
104
105async function bridge(
106 $: EngineInterface,
107 args: string[],
108 timeoutMs = 30000,
109): Promise<{ ok: boolean; out: string; err: string }> {
110 const cwd = await $.session.cwd()
111 try {
112 const run = await $.process.run(['python3', ...args], { cwd, timeoutMs })
113 return { ok: run.exitCode === 0, out: run.stdout, err: run.stderr.trim() }
114 } catch (error) {
115 return { ok: false, out: '', err: String(error) }
116 }
117}
118
119/** Feedback for a click: as a brief toast, and readable at the bottom of the card. */
120async function note($: EngineInterface, text: string): Promise<void> {
121 const now = await $.clock.now()
122 await update($, notes, list => [`${clockTime(now)} ${text}`, ...list].slice(0, 6))
123 $.ui.toast(short(text, 160))
124}
125
126/** What an action returns and has not already reported itself becomes feedback. */
127function reported(): Set<string> {
128 const t = T()
129 return new Set([t.resDo, t.resLater, t.resDrop, t.resDone, t.resStarted, t.resPartly, t.resFailed, t.resHidden,
130 t.resAsked, ''])
131}
132
133function act($: EngineInterface, run: () => unknown): () => Promise<void> {
134 return async () => {
135 const result = await run()
136 if (typeof result === 'string' && !reported().has(result)) await note($, result)
137 }
138}
139
140/**
141 * What the session finds: outside a Bridge, a config that could not be read (with the reason),
142 * switched off, or the config. A read error is never reported as "switched off".
143 */
144type Loaded = { outside: true } | { error: string } | { off: true } | { cfg: UiConfig }
145
146async function loadConfig($: EngineInterface): Promise<Loaded> {
147 const cwd = await $.session.cwd()
148 const isBridge =
149 (await $.fs.exists(`${cwd}/bridge-config.yaml`)) && (await $.fs.exists(`${cwd}/scripts/briefing.py`))
150 if (!isBridge) return { outside: true }
151 const run = await bridge($, ['-c', READ_CONFIG], 15000)
152 if (!run.ok) return { error: short(run.err || 'python3 failed', 200) }
153 try {
154 const cfg = parseConfig(JSON.parse(run.out) as Record<string, unknown>)
155 // even when switched off: the notice from /briefing-ui already speaks the configured language
156 setLanguage(cfg.language)
157 return cfg.enabled ? { cfg } : { off: true }
158 } catch (error) {
159 return { error: short(String(error), 200) }
160 }
161}
162
163
164/**
165 * quick: everything except trackers and command sections (SLOW_KINDS); those stay at the
166 * last state · auto: on open; if the last full run is older than full_minutes
167 * (briefing.claude_code_ui), a second run fetches everything · full: always both, with --fresh.
168 */
169type LoadHow = 'quick' | 'auto' | 'full'
170const HOW_RANK: Record<LoadHow, number> = { quick: 0, auto: 1, full: 2 }
171/** What the quick run leaves out and the second run fetches */
172// Calendar and commits take 2 s each and belong in the quick run: headline and advice then see
173// the appointments even after a click. Only those that wait for a service are slow.
174const SLOW_KINDS = ['tracker', 'command']
175
176/** Requests during a load: the strongest runs afterwards (full before auto before quick). */
177let reloadAfter: LoadHow | null = null
178
179async function loadView($: EngineInterface, how: LoadHow): Promise<void> {
180 if ((await read($, loading)) !== null) {
181 if (reloadAfter === null || HOW_RANK[how] > HOW_RANK[reloadAfter]) reloadAfter = how
182 if (how === 'full') await note($, T().alreadyLoading)
183 return
184 }
185 // A pending "approve all?" refers to rows that are about to change.
186 await update($, confirmAll, () => false)
187 // Claim it immediately, before the first script runs: otherwise two loads get through.
188 await update($, loading, () => T().loadTasks)
189 const last = await read($, view)
190 const now0 = await $.clock.now()
191 const freshFor = ((await read($, config))?.fullMinutes ?? 10) * 60000
192 const wantsFull = how === 'full' || (how === 'auto' && !(last?.isFull && now0 - (last.fullMs || 0) < freshFor))
193 const taskRun = await bridge($, ['scripts/workplace.py', 'tasks', '--json'], 20000)
194 let taskList: TaskInfo[] = []
195 try {
196 if (taskRun.ok) taskList = parseTasks(taskRun.out)
197 } catch {
198 // without a task list the card shows only the rows of the briefing
199 }
200 // The recipes hardly change: once per session (and on reload).
201 if ((await read($, teamList)).length === 0 || how === 'full') {
202 const teamRun = await bridge($, ['scripts/workplace.py', 'teams', '--json'], 20000)
203 try {
204 if (teamRun.ok) {
205 const list = parseTeams(teamRun.out)
206 await update($, teamList, () => list)
207 }
208 } catch {
209 // without recipes there are no team buttons
210 }
211 }
212 const took = await read($, loadTook)
213 for (const pass of wantsFull ? (['quick', 'rest'] as const) : (['quick'] as const)) {
214 const guess = took[pass] ? T().seconds(Math.round(took[pass] / 1000)) : ''
215 await update($, loading, () => (pass === 'rest' ? T().loadRest(guess) : T().loadQuick(guess)))
216 // The card pages by itself ("+N more"): all rows, not only those of the morning text.
217 // The second run fetches everything: headline and advice need inbox and calendar together,
218 // and it takes no longer than one with --only (the sections run side by side).
219 const args = ['scripts/briefing.py', 'collect', '--json', '--no-save', '--max-items', '500']
220 if (pass === 'quick') for (const kind of SLOW_KINDS) args.push('--skip', kind)
221 // ⟳ wants the state of right now: past the cache (cache_minutes).
222 if (how === 'full') args.push('--fresh')
223 if (await read($, askTriage)) args.push('--style', 'triage')
224 const started = await $.clock.now()
225 let run = await bridge($, args, 120000)
226 let raw: unknown = null
227 try {
228 raw = run.ok ? JSON.parse(run.out) : null
229 } catch {
230 // unreadable output is reported below
231 }
232 // Without a view the card would show the tasks and nothing else: ask once more for the triage view.
233 if (run.ok && raw !== null && typeof raw === 'object' && !('view' in raw) && !(await read($, askTriage))) {
234 await update($, askTriage, () => true)
235 run = await bridge($, [...args, '--style', 'triage'], 120000)
236 }
237 const ended = await $.clock.now()
238 await update($, loadTook, t => ({ ...t, [pass]: ended - started }))
239 if (!run.ok) {
240 await note($, T().loadFailed(short(run.err, 120)))
241 break
242 }
243 try {
244 const fresh = parseView(JSON.parse(run.out), pass === 'rest', ended)
245 // What this run left out stays at the state of the previous one. Add the tasks only afterwards:
246 // otherwise a task whose inbox row comes from the previous run would appear twice.
247 const before = await read($, view)
248 const merged = withTasks(before ? mergeView(before, fresh) : fresh, taskList)
249 await update($, view, () => merged)
250 // Completed items stay gone while they are still in the list (a yes only runs at the next pass).
251 const present = new Set(merged.buckets.flatMap(b => b.rows.map(r => r.key)))
252 await update($, settled, list => list.filter(k => present.has(k)))
253 await $.store.set(STORE_VIEW, merged)
254 } catch (error) {
255 await note($, T().viewUnreadable(short(String(error), 120)))
256 break
257 }
258 }
259 await update($, loading, () => null)
260 if (reloadAfter) {
261 const next = reloadAfter
262 reloadAfter = null
263 startLoad($, next)
264 }
265}
266
267
268function startLoad($: EngineInterface, how: LoadHow): void {
269 // A timer outlives the dispatch that sets it: loading blocks no click.
270 $.clock.after(0, () => {
271 void loadView($, how)
272 })
273}
274
275/** The tab state that the control workspace files for the other sessions */
276const SHARED_TABS = '.bridge/briefing-ui/tabs.json'
277/** Whether a query is running: a hanging one must not pile up */
278let tabsBusy = false
279
280
281/**
282 * Fetch the tab state. Only the control workspace (the session in the workspace from workplace.control) queries
283 * cmux, reports via toast and files the state for the other sessions; every other session only reads
284 * it. If it is older than three beats (no control workspace open), it queries by itself, but stays quiet.
285 */
286async function loadTabs($: EngineInterface, cfg: UiConfig): Promise<void> {
287 if (tabsBusy) return
288 tabsBusy = true
289 try {
290 await loadTabsOnce($, cfg)
291 } finally {
292 tabsBusy = false
293 }
294}
295
296/** The tab state that the control workspace has filed; null if there is none or it is too old. */
297async function sharedTabs($: EngineInterface, cfg: UiConfig, now0: number): Promise<Record<string, unknown>[] | null> {
298 try {
299 const shared = JSON.parse(await $.fs.read(SHARED_TABS)) as { at: number; tabs: Record<string, unknown>[] }
300 if (now0 - shared.at < cfg.statusSeconds * 3000) {
301 await update($, tabsError, () => null)
302 return shared.tabs
303 }
304 } catch {
305 // no or broken filing: query by itself
306 }
307 return null
308}
309
310/** Query cmux by itself; null on an error (the reason then shows in red on the card). */
311async function askTabs($: EngineInterface, now0: number): Promise<Record<string, unknown>[] | null> {
312 const run = await bridge($, ['scripts/workplace.py', 'status', '--json'], 20000)
313 try {
314 if (!run.ok) throw new Error(run.err || T().statusFailed)
315 const raw = JSON.parse(run.out) as Record<string, unknown>[]
316 await update($, tabsError, () => null)
317 return raw
318 } catch (error) {
319 const was = await read($, tabsError)
320 const why = short(String(error).replace(/^Error: /, '').split('\n').pop() ?? '', 80)
321 if (!was) await update($, tabsError, () => T().tabsStale(clockTime(now0), why))
322 return null
323 }
324}
325
326
327async function loadTabsOnce($: EngineInterface, cfg: UiConfig): Promise<void> {
328 const now0 = await $.clock.now()
329 const role = await read($, isControl)
330 const raw = (role === false ? await sharedTabs($, cfg, now0) : null) ?? await askTabs($, now0)
331 if (raw === null) return
332 // Who am I: my own tab, by the identifier that cmux gives every session.
333 const own = await $.env.get('CMUX_SURFACE_ID')
334 const isSelf = (t: Record<string, unknown>) => t.is_self === true || (!!own && t.id === own)
335 const self = raw.find(isSelf)
336 // Control is the first Claude session in the control workspace: two sessions there do not report twice.
337 const first = raw.filter(t => cfg.control && asText(t.workspace) === cfg.control && (asText(t.state) !== 'shell' || isSelf(t)))
338 .sort((a, b) => refNumber(a.ref) - refNumber(b.ref))[0]
339 const amControl = cfg.control && self ? first !== undefined && asText(first.ref) === asText(self.ref) : null
340 if (amControl !== role) await update($, isControl, () => amControl)
341 // My own tab (the session in which the dashboard runs) never waits for itself.
342 const list = raw.filter(t => !isSelf(t)).map(toTab)
343 if (amControl === true) {
344 // For the other sessions, with my own tab: each one strikes its own when reading.
345 try {
346 await $.fs.write(SHARED_TABS, JSON.stringify({ at: now0, tabs: raw.map(t => ({ ...t, is_self: false })) }))
347 } catch {
348 // without a filing the others query by themselves
349 }
350 }
351 const isQuiet = amControl === false
352 const before = await read($, tabs)
353 const known = new Map(before.map(t => [t.ref, t.state]))
354 const woke = list.filter(t => ATTENTION.has(t.state) && known.has(t.ref) && !ATTENTION.has(known.get(t.ref) ?? ''))
355 // Write only what has changed: every write redraws card and band.
356 if (JSON.stringify(before) !== JSON.stringify(list)) await update($, tabs, () => list)
357 for (const t of isQuiet ? [] : woke) {
358 $.ui.toast(T().tabToast(short(t.name, 40), stateWord(t.state)))
359 if (cfg.voice) void $.audio.speak(T().voiceWaiting(short(t.name, 40)))
360 }
361 // Since when each tab has been in its state; whoever waits or runs for long is reported once.
362 const now = await $.clock.now()
363 const old = await read($, tabSeen)
364 const seen: Record<string, TabSeen> = {}
365 for (const t of list) {
366 const prev = old[t.ref]
367 seen[t.ref] = prev && prev.state === t.state ? prev : { state: t.state, since: now, isWarned: false }
368 }
369 for (const t of list) {
370 const text = longText(t, seen, now)
371 const entry = seen[t.ref]
372 if (text && entry && !entry.isWarned) {
373 seen[t.ref] = { ...entry, isWarned: true }
374 if (!isQuiet) $.ui.toast(T().tabToast(short(t.name, 40), text))
375 }
376 }
377 if (JSON.stringify(old) !== JSON.stringify(seen)) await update($, tabSeen, () => seen)
378}
379
380/**
381 * Save the cmux layout (workspaces, tabs, Claude sessions) with the building block of the cmux skill.
382 * It writes only when the structure changed and clears old saves; without CMUX_LAYOUT_NOTIFY
383 * there is no alarm.
384 */
385async function snapshot($: EngineInterface, isManual: boolean): Promise<void> {
386 const run = await bridge($, ['skills/cmux/scripts/cmux_layout.py', 'snapshot'], 180000)
387 const at = clockTime(await $.clock.now())
388 if (run.ok) await update($, lastSnapshot, () => at)
389 if (isManual || !run.ok) {
390 await note($, run.ok ? T().saved : T().saveFailed(short(run.err, 100)))
391 }
392 if ((await read($, snapshots)) !== null) await listSnapshots($)
393}
394
395async function listSnapshots($: EngineInterface): Promise<void> {
396 const run = await bridge($, ['skills/cmux/scripts/cmux_layout.py', 'list'], 20000)
397 const lines = run.ok ? run.out.split('\n').filter(l => l.trim()).slice(0, 6) : [T().listUnreadable(short(run.err, 80))]
398 await update($, snapshots, () => lines)
399}
400
401async function settle($: EngineInterface, row: Row, text: string): Promise<void> {
402 await update($, settled, list => (list.includes(row.key) ? list : [...list, row.key]))
403 await update($, selected, list => list.filter(k => k !== row.key))
404 await update($, expanded, cur => (cur === row.key ? null : cur))
405 await note($, `${text}: ${short(row.title, 60)}`)
406}
407
408async function approve($: EngineInterface, row: Row): Promise<string> {
409 if (!row.inboxId) return T().noInbox
410 const yes = await bridge($, ['scripts/inbox.py', 'approve', row.inboxId])
411 if (!yes.ok) {
412 await note($, T().approveFailed(short(yes.err, 100)))
413 return T().resFailed
414 }
415 // It runs on the machine that is the inbox runner (inbox.runner), at the next pass;
416 // an `inbox.py run` from here would run all approved entries, and on another machine not at all.
417 await settle($, row, T().approvedNote)
418 return T().resDo
419}
420
421async function defer($: EngineInterface, row: Row, date?: string): Promise<string> {
422 if (!row.inboxId) return T().noInbox
423 const until = date ?? isoDate((await $.clock.now()) + DAY)
424 const run = await bridge($, ['scripts/inbox.py', 'defer', row.inboxId, '--until', until])
425 if (!run.ok) {
426 await note($, T().laterFailed(short(run.err, 100)))
427 return T().resFailed
428 }
429 await settle($, row, T().laterBack(until))
430 return T().resLater
431}
432
433/** Hide on the card only, until the date; nothing changes on the task. */
434async function hideUntil($: EngineInterface, row: Row, until: string): Promise<string> {
435 const next = { ...(await read($, hidden)), [row.key]: until }
436 await update($, hidden, () => next)
437 await $.store.set(STORE_HIDDEN, next)
438 await update($, expanded, cur => (cur === row.key ? null : cur))
439 await update($, selected, list => list.filter(k => k !== row.key))
440 await note($, T().hiddenNote(until, short(row.title, 60)))
441 return T().resHidden
442}
443
444async function unhide($: EngineInterface, row: Row): Promise<void> {
445 const next = { ...(await read($, hidden)) }
446 delete next[row.key]
447 await update($, hidden, () => next)
448 await $.store.set(STORE_HIDDEN, next)
449}
450
451/** Later for inbox entries (really postponed), hide for everything else. */
452async function later($: EngineInterface, row: Row, until: string): Promise<string> {
453 return row.inboxId && row.gate !== 'free' ? defer($, row, until) : hideUntil($, row, until)
454}
455
456async function setUrgency($: EngineInterface, row: Row, urgency: string): Promise<void> {
457 if (!row.inboxId) return
458 const run = await bridge($, ['scripts/inbox.py', 'urgency', row.inboxId, urgency])
459 await note($, run.ok ? T().urgencySet(T().urgency[urgency] ?? urgency, short(row.title, 60))
460 : T().urgencyFailed(short(run.err, 100)))
461 if (run.ok) startLoad($, 'quick')
462}
463
464async function setPriority($: EngineInterface, row: Row, priority: string): Promise<void> {
465 if (!row.task) return
466 const run = await bridge($, ['scripts/task.py', 'priority', row.task, priority])
467 await note($, run.ok ? `${priority}: ${short(row.title, 60)}` : T().prioFailed(short(run.err, 100)))
468 if (run.ok) startLoad($, 'quick')
469}
470
471async function drop($: EngineInterface, row: Row): Promise<string> {
472 if (!row.inboxId) return hideUntil($, row, isoDate((await $.clock.now()) + 7 * DAY))
473 const run = await bridge($, ['scripts/inbox.py', 'drop', row.inboxId, '--note', T().dropInboxNote])
474 if (!run.ok) {
475 await note($, T().dropFailed(short(run.err, 100)))
476 return T().resFailed
477 }
478 await settle($, row, T().dropped)
479 return T().resDrop
480}
481
482async function closeDone($: EngineInterface, row: Row): Promise<string> {
483 if (!row.inboxId) return T().noInbox
484 const run = await bridge($, ['scripts/inbox.py', 'close', row.inboxId, '--note', T().doneInboxNote])
485 if (!run.ok) {
486 await note($, T().doneFailed(short(run.err, 100)))
487 return T().resFailed
488 }
489 await settle($, row, T().resDone)
490 return T().resDone
491}
492
493async function launch($: EngineInterface, rows: Row[], how: Mode, where: Target,
494 team?: { id: string; role: string; name: string }): Promise<string> {
495 const items = rows.map(launchItem).filter((x): x is string => x !== null)
496 if (items.length === 0) return T().nothingToStart
497 const here = (await $.env.get('CMUX_SURFACE_ID')) ?? ''
498 const args = ['scripts/workplace.py', 'launch', '--items', items.join(','), '--mode', how, '--target', where,
499 '--yes', '--json']
500 if (here) args.push('--here', here)
501 if (team) args.push('--team', team.id, '--role', team.role)
502 const word = team ? T().launchTeam(team.name) : how === 'go' ? T().btnDo : how === 'context' ? T().launchContext : T().btnAdvise
503 await note($, T().launchStart(word, items.length, T().where[where]))
504 const run = await bridge($, args, 90000)
505 let report = run.err
506 // Part of it may have started even if the run fails as a whole: the started ones
507 // count as done, otherwise a second click starts them twice.
508 let started: string[] = []
509 try {
510 const data = JSON.parse(run.out) as {
511 report?: string[]
512 items?: { item?: string; error?: string; label?: string; workspace?: string; own?: boolean; why?: string }[]
513 }
514 report = (data.report ?? []).join(' · ') || report
515 // For auto: where each item went and why, instead of the raw driver lines
516 if (where === 'auto') {
517 const placed = (data.items ?? []).filter(i => i.label && !i.error)
518 .map(i => `${short(i.label ?? '', 30)} → ${i.own ? T().ownWs : i.workspace ?? '?'}${i.why ? ` (${i.why})` : ''}`)
519 if (placed.length) report = placed.join(' · ')
520 }
521 const hasErrorLine = (data.report ?? []).some(line => line.startsWith('ERROR'))
522 if (!hasErrorLine && (data.report ?? []).length > 0) {
523 started = (data.items ?? []).filter(i => i.item && !i.error).map(i => asText(i.item))
524 }
525 } catch {
526 // no JSON answer: stderr is already in report
527 }
528 const launched = rows.filter(r => started.some(id => id === r.task || id === r.inboxId))
529 const outcome = run.ok ? T().resStarted : launched.length ? T().partlyStarted(launched.length) : T().startFailed
530 await note($, `${outcome} (${word}): ${short(report, 160)}`)
531 if (launched.length) {
532 await update($, selected, list => list.filter(k => !launched.some(r => r.key === k)))
533 const cfg = await read($, config)
534 if (cfg) $.clock.after(5000, () => void loadTabs($, cfg))
535 }
536 return run.ok ? T().resStarted : launched.length ? T().resPartly : T().resFailed
537}
538
539/**
540 * Start a role: the next one of the chosen team, or the first of another (switch).
541 * It is remembered before the start, so a second click does not open the same role again.
542 */
543async function startRole($: EngineInterface, row: Row, team: Team): Promise<string> {
544 if (!row.task) return T().noTask
545 const now = await $.clock.now()
546 const runs = await read($, teamOf)
547 const st = teamState(row, runs, await read($, teamList), await read($, tabs), now)
548 const isSame = st !== null && st.team.id === team.id
549 if (isSame && !st.isReady) return T().waitsForRole(st.waitingFor ?? T().prevRole)
550 const role = isSame ? st.next : team.roles[0]
551 if (!role) return T().allRolesStarted
552 const before = runs[row.task]
553 const run: TeamRun = { team: team.id, roles: isSame && before ? [...before.roles, role.id] : [role.id], at: now }
554 const save = async (next: Record<string, TeamRun>) => {
555 await update($, teamOf, () => next)
556 await $.store.set(STORE_TEAMS, next)
557 }
558 await save({ ...runs, [row.task]: run })
559 const cfg = await read($, config)
560 const result = await launch($, [row], role.mode, cfg?.target ?? 'area', { id: team.id, role: role.id, name: role.name })
561 if (result === T().resFailed) {
562 const rest = { ...(await read($, teamOf)) }
563 if (before) rest[row.task] = before
564 else delete rest[row.task]
565 await save(rest)
566 }
567 return result
568}
569
570/** Assign a running tab to a task: it is named after the task afterwards, the card finds it. */
571async function adopt($: EngineInterface, row: Row, tab: Tab): Promise<void> {
572 if (!row.task) return
573 const run = await bridge($, ['scripts/workplace.py', 'adopt', tab.ref, row.task])
574 await note($, run.ok ? T().adopted(short(tab.name, 30), short(row.title, 40))
575 : T().adoptFailed(short(run.err, 100)))
576 const cfg = await read($, config)
577 if (run.ok && cfg) await loadTabs($, cfg)
578}
579
580/** A note on a task or entry: the context that only you know. */
581async function addNote($: EngineInterface, row: Row, text: string): Promise<void> {
582 const line = text.trim()
583 if (!line) return
584 const run = row.task
585 ? await bridge($, ['scripts/task.py', 'note', row.task, '--', line])
586 : row.inboxId ? await bridge($, ['scripts/inbox.py', 'note', row.inboxId, '--', line]) : null
587 if (!run) {
588 await note($, T().noteOnly)
589 return
590 }
591 await note($, run.ok ? T().noteAdded(short(row.title, 40), short(line, 60)) : T().noteFailed(short(run.err, 100)))
592}
593
594/** Do it yourself: have it run with a yes where the inbox can, otherwise have a tab work it off. */
595async function doIt($: EngineInterface, rows: Row[], where?: Target): Promise<string> {
596 const cfg = await read($, config)
597 // A yes only runs what an action carries; a tab handles everything else.
598 const open = rows.filter(r => r.inboxState !== 'approved')
599 const yes = open.filter(r => r.inboxId && r.gate === 'your-yes' && r.hasAction)
600 const rest = open.filter(r => !yes.includes(r) && launchItem(r) !== null)
601 const said: string[] = []
602 for (const row of yes) said.push(await approve($, row))
603 if (rest.length) said.push(await launch($, rest, 'go', where ?? cfg?.target ?? 'area'))
604 return said.join(', ') || T().nothingToDo
605}
606
607/** Advise me: a tab reads the context and proposes; without a startable item the chat asks. */
608async function advise($: EngineInterface, rows: Row[]): Promise<string> {
609 const cfg = await read($, config)
610 const startable = rows.filter(r => launchItem(r) !== null)
611 if (startable.length) return launch($, startable, 'report', cfg?.target ?? 'area')
612 if (rows[0]) await ask($, askAbout(rows[0]))
613 return T().resAsked
614}
615
616async function jump($: EngineInterface, tab: Tab): Promise<void> {
617 const cmux = (await $.env.get('CMUX_BUNDLED_CLI_PATH')) ?? 'cmux'
618 try {
619 const ws = await $.process.run([cmux, 'select-workspace', '--workspace', tab.wsRef], { timeoutMs: 10000 })
620 const panel = await $.process.run([cmux, 'focus-panel', '--panel', tab.ref, '--workspace', tab.wsRef],
621 { timeoutMs: 10000 })
622 const failed = [ws, panel].find(r => r.exitCode !== 0)
623 if (failed) await note($, T().jumpFailed(short(failed.stderr.trim() || failed.stdout.trim(), 100)))
624 } catch (error) {
625 await note($, T().jumpFailed(short(String(error), 100)))
626 }
627}
628
629/** A key to the tab: the answer to a permission prompt (1 = yes, escape = no) */
630async function pressKey($: EngineInterface, tab: Tab, key: string, said: string): Promise<void> {
631 if (!tab.ref) return
632 // The state on the card may be seconds old: if the tab has moved on in the meantime,
633 // Esc would abort its work and the 1 would land in the prompt.
634 const now = (await askTabs($, await $.clock.now()))?.find(t => asText(t.ref) === tab.ref)
635 if (!now || asText(now.state) !== 'needs-you') {
636 await note($, T().notAsking(short(tab.name, 30)))
637 const cfg = await read($, config)
638 if (cfg) void loadTabs($, cfg)
639 return
640 }
641 const cmux = (await $.env.get('CMUX_BUNDLED_CLI_PATH')) ?? 'cmux'
642 try {
643 const run = await $.process.run([cmux, 'send-key', '--workspace', tab.wsRef, '--surface', tab.ref, key],
644 { timeoutMs: 10000 })
645 await note($, run.exitCode === 0 ? `${short(tab.name, 30)}: ${said}` : T().keyFailed(short(run.stderr.trim(), 100)))
646 } catch (error) {
647 await note($, T().keyFailed(short(String(error), 100)))
648 }
649 const cfg = await read($, config)
650 if (cfg) $.clock.after(3000, () => void loadTabs($, cfg))
651}
652
653/** Close a finished tab, on click only. Without a target cmux would close its own tab: then do nothing. */
654async function closeTab($: EngineInterface, tab: Tab): Promise<void> {
655 if (!tab.ref || !tab.wsRef) return
656 const cmux = (await $.env.get('CMUX_BUNDLED_CLI_PATH')) ?? 'cmux'
657 try {
658 const run = await $.process.run([cmux, 'close-surface', '--surface', tab.ref, '--workspace', tab.wsRef],
659 { timeoutMs: 10000 })
660 await note($, run.exitCode === 0 ? T().tabClosed(short(tab.name, 30)) : T().closeFailed(short(run.stderr.trim(), 100)))
661 } catch (error) {
662 await note($, T().closeFailed(short(String(error), 100)))
663 }
664 const cfg = await read($, config)
665 if (cfg) $.clock.after(1000, () => void loadTabs($, cfg))
666}
667
668async function send($: EngineInterface, tab: Tab, text: string): Promise<void> {
669 const run = await bridge($, ['scripts/workplace.py', 'send', tab.ref, '--', text])
670 await note($, run.ok ? T().sentTo(short(tab.name, 30), short(text, 60)) : T().sendFailed(short(run.err, 100)))
671 await update($, messageTo, () => null)
672}
673
674async function openPane($: EngineInterface): Promise<void> {
675 await $.ui.open({ id: PANE, title: T().briefing, focus: true, closeOnEscape: true, columns: 60, rows: 40 })
676}
677
678async function ask($: EngineInterface, text: string): Promise<void> {
679 await $.prompt.submit({ text })
680}
681
682
683/** The buttons of a row, brief; the rest hides behind ▾. */
684type Action = { id: string; label: string; primary?: boolean; dim?: boolean; run: () => unknown }
685
686function rowActions($: EngineInterface, row: Row, tab: Tab | undefined): Action[] {
687 const out: Action[] = []
688 if (tab) {
689 out.push({ id: 'hin', label: T().btnToTab, primary: true, run: () => jump($, tab) })
690 return out
691 }
692 if (row.inboxId && row.inboxState === 'approved') {
693 out.push({ id: 'ready', label: T().btnApproved, dim: true,
694 run: () => note($, T().approvedPending(short(row.title, 60))) })
695 out.push({ id: 'drop', label: T().btnDrop, dim: true, run: () => drop($, row) })
696 return out
697 }
698 if (row.bucket === 'waiting') {
699 out.push({ id: 'nudge', label: T().btnNudge, run: () => ask($, followUp(row)) })
700 return out
701 }
702 if (row.inboxId && row.gate === 'only-you') {
703 out.push({ id: 'done', label: T().btnDone, primary: true, run: () => closeDone($, row) })
704 out.push({ id: 'advise', label: T().btnAdvise, run: () => advise($, [row]) })
705 return out
706 }
707 if (launchItem(row) !== null) {
708 out.push({ id: 'do', label: T().btnDo, primary: true, run: () => doIt($, [row]) })
709 if (row.inboxId) out.push({ id: 'drop', label: T().btnDrop, dim: true, run: () => drop($, row) })
710 else out.push({ id: 'advise', label: T().btnAdvise, run: () => advise($, [row]) })
711 return out
712 }
713 out.push({ id: 'ask', label: T().btnAskClaude, run: () => ask($, askAbout(row)) })
714 return out
715}
716
717/** The dashboard: in the chat as a card (inChat) or as a panel, the same tree. */
718async function dashboard($: EngineInterface, e: ResolveInput, columns: number, inChat: boolean, height = 40) {
719 const table = $.ui.resolve(e)
720 const { Box, Button, Text, Link } = table
721 // "release all": only entries whose yes executes an action, no tab, none with a yes already given.
722 const approvable = (rows: Row[]) => rows.filter(r => r.inboxId && r.gate === 'your-yes' && r.hasAction
723 && r.inboxState !== 'approved' && !tabFor(r, tabList))
724 // Mark from view.marks (customer, project) before the title; if the area already stands before it, not twice.
725 const markText = (mark: Mark | null, area: string | null) =>
726 mark && mark.label.toLowerCase() !== (area ?? '').toLowerCase()
727 ? <Text color={mark.color ?? 'magenta'}>{`${mark.label} `}</Text> : null
728 // An input field does not exist on every surface (not on mobile).
729 const Input = 'Input' in table ? table.Input : null
730 const v = await read($, view)
731 const done = await read($, settled)
732 const picked = await read($, selected)
733 const isConfirming = await read($, confirmAll)
734 // Without cmux there are no tabs to count, steer or back up: those lines stay away.
735 const inCmux = Boolean(await $.env.get('CMUX_SURFACE_ID'))
736 const hasTabs = (await read($, tabs)).length > 0 || inCmux
737 const open = await read($, expanded)
738 const busy = await read($, loading)
739 const now = await $.clock.now()
740 const today = isoDate(now)
741 const tabList = await read($, tabs)
742 const typing = await read($, messageTo)
743 const log = await read($, notes)
744 const hide = await read($, hidden)
745 const section = await read($, openSection)
746 const all = await read($, showAll)
747 const tabsOpen = await read($, showTabs)
748 const help = await read($, showHelp)
749 const tabErr = await read($, tabsError)
750 const lang = await read($, helpLang)
751 const recipes = await read($, teamList)
752 const saved = await read($, lastSnapshot)
753 const snapList = await read($, snapshots)
754 // The layout is saved only from a session inside cmux (see session.start), as often as snapshot_minutes says.
755 const every = (await read($, config))?.snapshotMinutes ?? 60
756 const backupText = `${T().backup} ${saved ? T().backupAt(saved) : T().backupEvery(every)}`
757 const chosen = await read($, teamOf)
758 // In the chat the frame of the card subtracts, in the panel columns is already the width of the content.
759 const width = inChat ? Math.max(40, columns - 6) : Math.max(24, columns - 1)
760 const lay = layout(v, done, hide, today, section, all, tabList)
761 const byId = Object.fromEntries(lay.sections.map(s => [s.id, s])) as Record<SectionId, Section>
762 const seen = await read($, tabSeen)
763 // Whoever works for long also belongs at the top: maybe it is stuck.
764 const waiting = tabList.filter(t => isWaiting(t, v) || longText(t, seen, now) !== null)
765 const longCount = tabList.filter(t => longText(t, seen, now) !== null).length
766 const busyTabs = tabList.filter(t => t.state === 'working').length
767 const pickedRows = lay.sections.flatMap(s => s.all).filter(r => picked.includes(r.key))
768 const toggle = (row: Row) =>
769 update($, selected, list => (list.includes(row.key) ? list.filter(k => k !== row.key) : [...list, row.key]))
770 const setSection = (id: SectionId | null) => update($, openSection, cur => (cur === id ? null : id))
771
772 // Columns of the card: selection and number, area, mark, title (fills, shortens itself), buttons flush right.
773 // Below 120 columns the card tightens: narrower area, short button texts, no ✉.
774 const isTight = width < 120
775 const LEAD = 7
776 const AREA = isTight ? 9 : 11
777 const label = (a: Action) => (isTight ? T().shortLabel[a.id] ?? a.label : a.label)
778 const MARK = 4
779 // Per state the fitting buttons: answer a permission prompt, let it carry on, or clear it away.
780 const tabButtons = (tab: Tab) => {
781 const result = filedBy(tab, v)
782 if (tab.state === 'needs-you') {
783 return [
784 <Button key={`yes-${tab.ref}`} label={T().btnYes} onPress={() => pressKey($, tab, '1', T().saidYes)} />,
785 <Button key={`no-${tab.ref}`} label={T().btnNo} dimColor onPress={() => pressKey($, tab, 'escape', T().saidNo)} />,
786 ]
787 }
788 if (tab.state === 'waiting' && result) {
789 return [<Button key={`close-${tab.ref}`} label={T().btnCloseTab} dimColor onPress={() => closeTab($, tab)} />]
790 }
791 if (tab.state === 'waiting') {
792 return [<Button key={`go-${tab.ref}`} label={T().btnGoOn} onPress={() => send($, tab, T().goOnMessage)} />]
793 }
794 return []
795 }
796 const tabLine = (tab: Tab) => (
797 <Box key={`tab-${tab.ref}`} flexDirection="column">
798 <Box>
799 <Box width={LEAD} flexShrink={0} justifyContent="flex-end" paddingRight={1}>
800 <Text color={tabColor(tab)}>●</Text>
801 </Box>
802 <Box width={AREA} flexShrink={0}><Text dimColor wrap="truncate-end">{tab.workspace}</Text></Box>
803 <Box width={MARK} flexShrink={0} />
804 <Box flexGrow={1} flexShrink={1}>
805 <Text wrap="truncate-end">
806 {tab.name}
807 <Text color={longText(tab, seen, now) ? 'red' : tabColor(tab)}>
808 {` ${longText(tab, seen, now) ?? stateWord(tab.state)}`}
809 </Text>
810 {filedBy(tab, v) && <Text color="green">{` ${T().resultFiled}`}</Text>}
811 <Text dimColor>{tab.last ? ` ${tab.last}` : ''}</Text>
812 </Text>
813 </Box>
814 <Box flexShrink={0} marginLeft={1}>
815 <Button key={`jump-${tab.ref}`} label={T().btnToTab} variant="primary" onPress={() => jump($, tab)} />
816 {tabButtons(tab)}
817 {Input && !isTight && (
818 <Button key={`msg-${tab.ref}`} label="✉" dimColor
819 onPress={() => update($, messageTo, cur => (cur === tab.ref ? null : tab.ref))} />
820 )}
821 </Box>
822 </Box>
823 {Input && typing === tab.ref && (
824 <Input key={`input-${tab.ref}`} label={T().toTabLabel} placeholder={T().toTabPlaceholder} submitLabel={T().btnSend}
825 autoFocus onSubmit={(text: string) => send($, tab, text)} />
826 )}
827 </Box>
828 )
829
830 const detail = (row: Row, tab: Tab | undefined, inSidebar = false) => {
831 const url = rowUrl(row)
832 const isInbox = row.inboxId !== null && row.gate !== 'free'
833 return (
834 <Box key={`detail-${row.key}`} flexDirection="column" marginLeft={inSidebar ? 0 : 6}
835 borderStyle={inSidebar ? undefined : 'round'} borderDimColor paddingX={inSidebar ? 0 : 1}>
836 {!inSidebar && <Text wrap="wrap" bold>{row.title}</Text>}
837 {!inSidebar && row.why.length > 0 && <Text dimColor>{row.why.join(' · ')}</Text>}
838 {!inSidebar && tab && <Text dimColor>{T().tabInfo(tab.workspace, tab.name, stateWord(tab.state))}</Text>}
839 <Box flexWrap="wrap">
840 <Text>{isInbox ? T().laterPrefix : T().hidePrefix}</Text>
841 <Button key={`l1-${row.key}`} label={T().btnTomorrow} onPress={() => later($, row, isoDate(now + DAY))} />
842 <Button key={`l3-${row.key}`} label={T().btnMonday} onPress={() => later($, row, isoDate(nextMonday(now)))} />
843 <Button key={`l7-${row.key}`} label={T().btnWeek} onPress={() => later($, row, isoDate(now + 7 * DAY))} />
844 {Input && (
845 <Input key={`lx-${row.key}`} placeholder={T().datePlaceholder} submitLabel={T().btnUntil}
846 onSubmit={async (text: string) => {
847 const date = parseDate(text, await $.clock.now())
848 if (date) await later($, row, date)
849 else await note($, T().noDate(short(text, 20)))
850 }} />
851 )}
852 </Box>
853 {(row.inboxId || row.task) && (
854 <Box flexWrap="wrap">
855 {row.inboxId && <Text>{T().urgencyPrefix}</Text>}
856 {row.inboxId && ['now', 'today', 'later'].map(u => (
857 <Button key={`u-${u}-${row.key}`} label={T().urgency[u] ?? u} dimColor={row.urgency === u}
858 onPress={() => setUrgency($, row, u)} />
859 ))}
860 {row.task && <Text>{row.inboxId ? ' ' : ''}{T().prioPrefix}</Text>}
861 {row.task && ['P0', 'P1', 'P2', 'P3'].map(p => (
862 <Button key={`p-${p}-${row.key}`} label={p} dimColor={row.priority === p}
863 onPress={() => setPriority($, row, p)} />
864 ))}
865 </Box>
866 )}
867 {row.task && !row.inboxId && recipes.length > 0 && (() => {
868 const st = teamState(row, chosen, recipes, tabList, now)
869 const fits = (t: Team) => (row.taskType !== null && t.forTypes.includes(row.taskType) ? 0 : 1)
870 return (
871 <Box flexWrap="wrap">
872 <Text>{st ? T().teamProgress(st.team.label, st.started, st.team.roles.length) : T().teamPrefix}</Text>
873 {st && st.next && (st.isReady ? (
874 <Button key={`team-next-${row.key}`} variant="primary" label={`▶ ${st.next.name}`}
875 onPress={act($, () => startRole($, row, st.team))} />
876 ) : (
877 <Text dimColor>{T().teamStillWorking(st.waitingFor ?? '')}</Text>
878 ))}
879 {[...recipes].sort((a, b) => fits(a) - fits(b)).filter(t => !st || t.id !== st.team.id).map(t => (
880 <Button key={`team-${t.id}-${row.key}`} variant={fits(t) === 0 && !st ? 'primary' : undefined}
881 dimColor={st !== null}
882 label={inSidebar ? `${st ? T().teamNew : ''}${t.label}` : `${st ? T().teamNew : ''}${t.label} (${t.roles.map(r => r.name).join(' → ')})`}
883 onPress={act($, () => startRole($, row, t))} />
884 ))}
885 </Box>
886 )
887 })()}
888 {launchItem(row) !== null && (
889 <Box flexWrap="wrap">
890 <Text>{T().contextPrefix}</Text>
891 <Button key={`ctx-${row.key}`} label={T().btnCollect}
892 onPress={act($, async () => launch($, [row], 'context', (await read($, config))?.target ?? 'area'))} />
893 {Input && (
894 <Input key={`note-${row.key}`} placeholder={T().notePlaceholder} submitLabel={T().btnNote}
895 onSubmit={(text: string) => addNote($, row, text)} />
896 )}
897 </Box>
898 )}
899 {row.task && !row.inboxId && !tab && (() => {
900 // Agent tabs in the area of the task that do not belong to anything yet
901 const free = tabList.filter(t => row.areaNames.includes(t.workspace) && !t.slug && !t.item && t.state !== 'shell')
902 return free.length > 0 && (
903 <Box flexWrap="wrap">
904 <Text>{T().adoptPrefix}</Text>
905 {free.slice(0, 4).map(t => (
906 <Button key={`adopt-${t.ref}-${row.key}`} label={short(t.name, inSidebar ? 18 : 28)} dimColor
907 onPress={() => adopt($, row, t)} />
908 ))}
909 </Box>
910 )
911 })()}
912 <Box flexWrap="wrap">
913 {launchItem(row) !== null && !tab && (
914 <Button key={`ws-${row.key}`} label={inSidebar ? T().btnOwnWs : T().btnDoOwnWs}
915 onPress={act($, () => doIt($, [row], 'workspace'))} />
916 )}
917 {row.inboxId && row.gate === 'only-you' && (
918 <Button key={`drop-${row.key}`} label={T().btnDrop} dimColor onPress={act($, () => drop($, row))} />
919 )}
920 <Button key={`ask-${row.key}`} label={T().btnAskChat} dimColor onPress={() => ask($, askAbout(row))} />
921 {url && <Link key={`url-${row.key}`} href={url} label={T().btnOpen} />}
922 </Box>
923 </Box>
924 )
925 }
926
927 const rowLine = (row: Row, n: number | null) => {
928 const tab = tabFor(row, tabList)
929 const team = teamState(row, chosen, recipes, tabList, now)
930 const acts = rowActions($, row, tab)
931 const canPick = !tab && launchItem(row) !== null && row.inboxState !== 'approved'
932 const isOpen = open === row.key
933 const toggleRow = () => update($, expanded, cur => (cur === row.key ? null : row.key))
934 return (
935 <Box key={`row-${row.key}`} flexDirection="column">
936 <Box>
937 <Box width={LEAD} flexShrink={0}>
938 {canPick ? (
939 <Button key={`pick-${row.key}`} plain label={picked.includes(row.key) ? '[x]' : '[ ]'}
940 onPress={() => toggle(row)} />
941 ) : <Text> </Text>}
942 <Text dimColor>{n === null ? '' : String(n).padStart(3)}</Text>
943 </Box>
944 <Box width={AREA} flexShrink={0}><Text dimColor wrap="truncate-end">{row.area ?? ''}</Text></Box>
945 <Box width={MARK} flexShrink={0}>
946 {row.urgency === 'now' ? <Text color="red" bold>!</Text> : null}
947 {row.priority && <Text color={PRIO_COLOR[row.priority] ?? 'cyan'}>{row.priority}</Text>}
948 </Box>
949 <Box flexGrow={1} flexShrink={1}>
950 <Text wrap="truncate-end">
951 {markText(row.mark, row.area)}
952 {isOpen ? <Text bold>{row.title}</Text> : row.title}
953 {tab && <Text color={tabColor(tab)}>{` ● ${stateWord(tab.state)}`}</Text>}
954 {team && <Text color="magenta">{` ${team.team.label} ${team.started}/${team.team.roles.length}`}</Text>}
955 </Text>
956 </Box>
957 <Box flexShrink={0} marginLeft={1}>
958 {team && team.next && team.started > 0 && team.isReady && (
959 <Button key={`next-${row.key}`} label={`▶ ${team.next.name}`} variant="primary"
960 onPress={act($, () => startRole($, row, team.team))} />
961 )}
962 {acts.map(a => (
963 <Button key={`${a.id}-${row.key}`} label={label(a)} variant={a.primary ? 'primary' : undefined}
964 dimColor={a.dim} onPress={act($, a.run)} />
965 ))}
966 <Button key={`more-${row.key}`} label={isOpen ? '▴' : '▾'} dimColor onPress={toggleRow} />
967 </Box>
968 </Box>
969 {isOpen && detail(row, tab)}
970 </Box>
971 )
972 }
973
974 const numberOf = (row: Row) => lay.numbers.get(row.key) ?? null
975
976 const block = (id: SectionId) => {
977 const s = byId[id]
978 const more = s.all.length - s.shown.length
979 const tabCount = id === 'now' ? waiting.length : 0
980 if (s.all.length === 0 && tabCount === 0) return null
981 const isOpen = section === id || all
982 const shownTabs = isOpen ? waiting : waiting.slice(0, TAB_CAP)
983 const moreTabs = id === 'now' ? waiting.length - shownTabs.length : 0
984 return (
985 <Box key={`block-${id}`} flexDirection="column" marginTop={1}>
986 <Box>
987 <Box flexShrink={0}>
988 <Text bold color={SECTION_COLOR[id]}>{T().section[id]}</Text>
989 <Text dimColor>{` ${s.all.length + tabCount} `}</Text>
990 {id === 'give' && s.all.length > 1 && (
991 <Button key="pick-give" plain label={`${T().btnPickAll} `} dimColor
992 onPress={() => update($, selected, list => [...new Set([...list,
993 ...s.all.filter(r => !tabFor(r, tabList) && launchItem(r) !== null && r.inboxState !== 'approved')
994 .map(r => r.key)])])} />
995 )}
996 {id === 'give' && approvable(s.shown).length > 1 && !isConfirming && (
997 <Button key="approve-all" plain label={`${T().approveAll(approvable(s.shown).length)} `} dimColor
998 onPress={() => update($, confirmAll, () => true)} />
999 )}
1000 </Box>
1001 <Box flexGrow={1} width={0} height={1} overflow="hidden"><Text dimColor>{RULE}</Text></Box>
1002 </Box>
1003 {id === 'give' && isConfirming && approvable(s.shown).length > 0 && (
1004 <Box flexWrap="wrap">
1005 <Text color="yellow">{`${T().approveAsk(approvable(s.shown).length)} `}</Text>
1006 <Button key="approve-yes" label={T().approveYes} variant="primary" onPress={async () => {
1007 // Only the rows on screen: a yes never reaches an item nobody saw. Each approval notes itself.
1008 const rows = approvable(s.shown)
1009 await update($, confirmAll, () => false)
1010 await doIt($, rows)
1011 }} />
1012 <Button key="approve-no" label={T().approveNo} dimColor onPress={() => update($, confirmAll, () => false)} />
1013 </Box>
1014 )}
1015 {id === 'now' && shownTabs.map(tabLine)}
1016 {s.shown.map(row => rowLine(row, numberOf(row)))}
1017 {(more > 0 || moreTabs > 0) && !isOpen && (
1018 <Box paddingLeft={LEAD}>
1019 <Button key={`more-${id}`} plain dimColor
1020 label={`${T().moreRows(more + moreTabs)}${id === 'work' ? T().moreWork : ''}`}
1021 onPress={() => setSection(id)} />
1022 </Box>
1023 )}
1024 {section === id && !all && (
1025 <Box paddingLeft={LEAD}>
1026 <Button key={`less-${id}`} plain dimColor label={T().btnLess} onPress={() => setSection(null)} />
1027 </Box>
1028 )}
1029 </Box>
1030 )
1031 }
1032
1033 const give = byId.give
1034 const laterSection = byId.later
1035 const laterOpen = section === 'later' || all
1036
1037 // Status tabs: systems, appointments, GitHub, today; the same data as the briefing, no separate fetch.
1038 const pageSel = await read($, page)
1039 const infoPages = v?.pages ?? []
1040 const shownPage = infoPages.find(pg => pg.id === pageSel) ?? null
1041 const pageIds = [BRIEFING_PAGE, ...infoPages.map(pg => pg.id)]
1042 const pageTitle = (id: string) => (id === BRIEFING_PAGE ? T().briefing : infoPages.find(pg => pg.id === id)?.title ?? id)
1043 const badge = (id: string) => (id === BRIEFING_PAGE
1044 ? { text: String(waiting.length + byId.now.all.length + give.all.length) } as { text: string; color?: string }
1045 : pageBadge(infoPages.find(pg => pg.id === id)!))
1046 // Red findings and the tab they sit on
1047 const alarmPage = infoPages.find(pg => pg.sections.some(x => x.bad > 0)) ?? null
1048 const alarmCount = infoPages.reduce((n, pg) => n + pg.sections.reduce((m, x) => m + x.bad, 0), 0)
1049 const goPage = async (id: string) => {
1050 await update($, page, () => id)
1051 await update($, expanded, () => null)
1052 }
1053 const pageBar = (
1054 <Box flexWrap="wrap">
1055 {pageIds.map(id => (
1056 <Box key={`pg-${id}`} marginRight={2}>
1057 {(shownPage?.id ?? BRIEFING_PAGE) === id ? (
1058 <Text bold underline>{pageTitle(id)}</Text>
1059 ) : (
1060 <Button key={`pgb-${id}`} plain dimColor label={pageTitle(id)}
1061 onPress={() => goPage(id)} />
1062 )}
1063 <Text color={badge(id).color} dimColor={!badge(id).color}>{` ${badge(id).text}`}</Text>
1064 </Box>
1065 ))}
1066 </Box>
1067 )
1068
1069 const pageBarWidth = pageIds.reduce((n, id) => n + pageTitle(id).length + badge(id).text.length + 3, 0)
1070 const helpBox = help && (
1071 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginTop={1}>
1072 <Box>
1073 <Text bold>{`${TABLES[lang].helpTitle} `}</Text>
1074 <Button key="lang-de" label={LANG_NAME.de} dimColor={lang !== 'de'} onPress={() => update($, helpLang, () => 'de')} />
1075 <Button key="lang-en" label={LANG_NAME.en} dimColor={lang !== 'en'} onPress={() => update($, helpLang, () => 'en')} />
1076 </Box>
1077 {TABLES[lang].help.map(([what, does]) => (
1078 width >= 70 ? (
1079 <Box key={`help-${what}`}>
1080 <Box width={18} flexShrink={0}><Text bold>{what}</Text></Box>
1081 <Box flexGrow={1} width={0}><Text wrap="wrap">{does}</Text></Box>
1082 </Box>
1083 ) : (
1084 <Box key={`help-${what}`} flexDirection="column">
1085 <Text bold>{what}</Text>
1086 <Box paddingLeft={2}><Text wrap="wrap" dimColor>{does}</Text></Box>
1087 </Box>
1088 )
1089 ))}
1090 </Box>
1091 )
1092
1093 if (shownPage) {
1094 const startingNow = await read($, starting)
1095 const sections = shownPage.sections
1096 // Panel: as many rows per section as fit in the height; card: ten, "all" shows all.
1097 const perSection = all ? 999 : inChat ? 10 : Math.max(2, Math.floor((height - 6) / Math.max(1, sections.length)) - 2)
1098 const whenWidth = Math.min(16, Math.max(0, ...sections.flatMap(x => x.items.map(it => it.when.length))))
1099 const isWide = width >= 70
1100 return (
1101 <Box flexDirection="column" borderStyle={inChat ? 'round' : undefined} borderDimColor paddingX={inChat ? 1 : 0}>
1102 <Box>
1103 <Text dimColor>{`${T().asOf} `}</Text>
1104 <Text dimColor>{v ? `${v.collectedAt}${v.isFull ? '' : ` ${T().noTracker}`}` : T().loading}{busy ? ` · ${T().loadingWhat(busy)}` : ''}</Text>
1105 <Box flexGrow={1} />
1106 <Box flexShrink={0}>
1107 <Button key="pg-reload" plain label="⟳" dimColor onPress={() => startLoad($, 'full')} />
1108 <Text> </Text>
1109 <Button key="pg-all" plain label={all ? T().btnCompact : T().btnAll} dimColor={!all} onPress={() => update($, showAll, x => !x)} />
1110 <Text> </Text>
1111 <Button key="pg-help" plain label="?" dimColor={!help} onPress={() => update($, showHelp, x => !x)} />
1112 <Text> </Text>
1113 <Button key="pg-close" plain role="dismiss" label="✕" dimColor
1114 onPress={() => (inChat ? update($, isClosed, () => true) : $.ui.close({ id: PANE }))} />
1115 </Box>
1116 </Box>
1117 {pageBar}
1118 {helpBox}
1119 {sections.map(sec => (
1120 <Box key={`ps-${sec.id}`} flexDirection="column" marginTop={1}>
1121 <Box>
1122 <Box flexShrink={0}>
1123 <Text bold color={sec.alarm && sec.count > 0 ? 'red' : undefined}>{sec.title.replace(/\s*\(.*?\)/g, '')}</Text>
1124 <Text dimColor>{` ${sec.status === 'skipped' ? '' : sec.count} `}</Text>
1125 {sec.asOf && <Text dimColor>{`${T().sourceAsOf(sec.asOf)} `}</Text>}
1126 </Box>
1127 <Box flexGrow={1} width={0} height={1} overflow="hidden"><Text dimColor>{RULE}</Text></Box>
1128 </Box>
1129 {sec.status === 'skipped' && <Text dimColor>{` ${T().skippedSection}`}</Text>}
1130 {sec.status !== 'skipped' && sec.count === 0 && (
1131 <Text color={sec.alarm && sec.status === 'ok' ? 'green' : undefined} dimColor={!sec.alarm || sec.status !== 'ok'}>
1132 {` ${sec.status === 'ok' ? sec.empty : T().unreadable(short(sec.reason || sec.status, 90))}`}
1133 </Text>
1134 )}
1135 {sec.items.slice(0, perSection).map((it, i) => (
1136 <Box key={`pi-${sec.id}-${i}`}>
1137 {whenWidth > 0 && (
1138 <Box width={whenWidth + 1} flexShrink={0}>
1139 <Text dimColor wrap="truncate-end">{it.when}</Text>
1140 </Box>
1141 )}
1142 <Box flexGrow={1} width={0}>
1143 <Text wrap="truncate-end" dimColor={it.tone === 'dim'}
1144 color={it.tone === 'bad' ? 'red' : it.tone === 'warn' ? 'yellow' : undefined}>
1145 {markText(it.mark, null)}{it.title}</Text>
1146 </Box>
1147 {it.detail && isWide && (
1148 <Box flexShrink={0} marginLeft={1}><Text dimColor>{short(it.detail, 36)}</Text></Box>
1149 )}
1150 {isWide && it.task && (() => {
1151 const open = tabFor(taskRow(it.task, it.title), tabList)
1152 return (
1153 <Box flexShrink={0} marginLeft={1}>
1154 {open ? (
1155 <Button key={`pt-${sec.id}-${i}`} plain label={T().btnToTab} onPress={() => jump($, open)} />
1156 ) : startingNow.includes(it.task) ? (
1157 <Text dimColor>{T().startingTab}</Text>
1158 ) : (
1159 <Button key={`pt-${sec.id}-${i}`} plain dimColor label={T().btnOpenTab}
1160 onPress={act($, async () => {
1161 const slug = it.task!
1162 if ((await read($, starting)).includes(slug)) return null
1163 await update($, starting, list => [...list, slug])
1164 try {
1165 return await launch($, [taskRow(slug, it.title)], 'report', (await read($, config))?.target ?? 'area')
1166 } finally {
1167 // until the new tab shows up in the list (loadTabs 5 s after the start)
1168 $.clock.after(15000, () => void update($, starting, list => list.filter(x => x !== slug)))
1169 }
1170 })} />
1171 )}
1172 </Box>
1173 )
1174 })()}
1175 {isWide && it.ask && (
1176 <Box flexShrink={0} marginLeft={1}>
1177 <Button key={`pa-${sec.id}-${i}`} plain dimColor label={T().btnAsk}
1178 onPress={() => ask($, askInfo(sec.title.replace(/\s*\(.*?\)/g, ''), it))} />
1179 </Box>
1180 )}
1181 {it.url && isWide && (
1182 <Box flexShrink={0} marginLeft={1}><Link key={`pl-${sec.id}-${i}`} href={it.url} label={T().btnOpen} /></Box>
1183 )}
1184 </Box>
1185 ))}
1186 {sec.items.length > perSection && (
1187 <Box paddingLeft={whenWidth + 1}>
1188 <Button key={`pm-${sec.id}`} plain dimColor label={T().moreRows(sec.items.length - perSection)}
1189 onPress={() => update($, showAll, () => true)} />
1190 </Box>
1191 )}
1192 </Box>
1193 ))}
1194 {log[0] && <Text dimColor wrap="truncate-end">{log[0]}</Text>}
1195 </Box>
1196 )
1197 }
1198
1199 // Sidebar (and narrow card): tabs instead of blocks, one row per item, page instead of scroll.
1200 if (!inChat || width < NARROW) {hooks/logic.ts 510 lines1import type {
2 Bucket, Info, InfoPage, Mark, Row, SectionId, Tab, TabSeen, Team, TeamRun, UiConfig, View,
3} from '../types'
4import { T } from './text'
5
6/**
7 * Pure functions of the mod: reading the script outputs, ordering of the card, texts to Claude.
8 * Nothing here touches `$`; the tests exercise them without Claude Code.
9 */
10
11export const ATTENTION = new Set(['needs-you', 'waiting'])
12
13/** State of a tab in words */
14export function stateWord(state: string): string {
15 return T().state[state] ?? state
16}
17
18/** Color of the dot before a tab: needs you red, waits yellow, works green, terminal gray */
19export function tabColor(tab: Tab): string {
20 return tab.state === 'needs-you' ? 'red' : tab.state === 'waiting' ? 'yellow' : tab.state === 'working' ? 'green' : 'gray'
21}
22export const BUCKET_ORDER = ['do', 'plan', 'delegate', 'waiting', 'drop', 'tasks', 'resting']
23export const SHORTCUT = /^(\d+[abcvw])(\s+\d+[abcvw])*$/
24
25/** Rows per block in the compact view; Later stays closed. */
26export const CAP: Record<SectionId, number> = { now: 4, give: 4, work: 5, later: 0 }
27
28/** Status tabs: which sections sit on which tab and what goes in which column is set by the
29 * briefing profile (view.pages, docs/briefings.md § Dashboard pages); collect delivers them ready. */
30export const BRIEFING_PAGE = 'briefing'
31
32export function parseMark(x: unknown): Mark | null {
33 const m = x as Record<string, unknown> | null
34 if (!m || typeof m !== 'object' || typeof m.label !== 'string' || !m.label) return null
35 return { label: m.label, color: typeof m.color === 'string' ? m.color : null }
36}
37
38export function parseInfo(i: Record<string, unknown>): Info {
39 const tone = i.tone === 'bad' || i.tone === 'warn' || i.tone === 'dim' ? i.tone : null
40 return { title: asText(i.title), detail: asText(i.detail), when: asText(i.when), tone, url: asTextOrNull(i.url),
41 task: asTextOrNull(i.task), ask: i.ask === true, mark: parseMark(i.mark) }
42}
43
44/** A task as a row, so a status tab takes the same paths as the card (find tab, start tab) */
45export function taskRow(slug: string, title: string): Row {
46 return { key: `task:${slug}`, bucket: 'do', title, why: [], sources: [], inboxId: null, gate: null, task: slug,
47 ref: null, url: null, priority: null, urgency: null, area: null, areaNames: [], taskType: null, hasAction: false,
48 inboxState: null, inboxKey: null, mark: null }
49}
50
51/** Question to the conversation about a row of a status tab */
52export function askInfo(section: string, it: Info): string {
53 const extra = [it.detail, it.url && /^https?:/.test(it.url) ? it.url : ''].filter(Boolean).join(', ')
54 return T().askInfo(section, it.title, extra)
55}
56
57export function parsePages(data: Record<string, unknown>): InfoPage[] {
58 return ((data.pages as Record<string, unknown>[] | undefined) ?? []).map(p => ({
59 id: asText(p.id),
60 title: asText(p.title),
61 sections: ((p.sections as Record<string, unknown>[] | undefined) ?? []).map(x => {
62 const items = ((x.items as Record<string, unknown>[] | undefined) ?? []).map(parseInfo)
63 return {
64 id: asText(x.id),
65 kind: asText(x.kind),
66 title: asText(x.title),
67 status: asText(x.status),
68 reason: asText(x.reason),
69 alarm: x.alarm === true,
70 empty: asText(x.empty) || T().empty,
71 items,
72 count: items.length,
73 weight: typeof x.weight === 'number' ? x.weight : 0,
74 bad: typeof x.bad === 'number' ? x.bad : 0,
75 asOf: asTextOrNull(x.as_of),
76 }
77 }),
78 })).filter(p => p.id && p.id !== BRIEFING_PAGE && p.sections.length > 0)
79}
80
81/** Number on the tab: red findings, else what counts (yellow, if they are findings), else ✓ */
82export function pageBadge(pg: InfoPage): { text: string; color?: string } {
83 const bad = pg.sections.reduce((n, x) => n + x.bad, 0)
84 if (bad > 0) return { text: `✗${bad}`, color: 'red' }
85 const weight = pg.sections.reduce((n, x) => n + x.weight, 0)
86 const isWarn = pg.sections.some(x => x.weight > 0 && x.items.some(it => it.tone === 'warn'))
87 if (weight > 0) return { text: String(weight), color: isWarn ? 'yellow' : undefined }
88 return pg.sections.every(x => x.status === 'ok') ? { text: '✓', color: 'green' } : { text: '' }
89}
90
91// ---------------------------------------------------------------- Helpers
92
93export function short(text: string, max: number): string {
94 const line = text.replace(/\s+/g, ' ').trim()
95 return line.length > max ? line.slice(0, Math.max(1, max - 1)) + '…' : line
96}
97
98export function pad(n: number): string {
99 return String(n).padStart(2, '0')
100}
101
102export function isoDate(ms: number): string {
103 const d = new Date(ms)
104 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
105}
106
107export function clockTime(ms: number): string {
108 const d = new Date(ms)
109 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
110}
111
112export const DAY = 86400000
113
114export function nextMonday(ms: number): number {
115 const day = new Date(ms).getDay()
116 return ms + ((8 - day) % 7 || 7) * DAY
117}
118
119/** "2026-10-14", "14.10." or "14.10.2026" as YYYY-MM-DD; else null. */
120export function parseDate(text: string, now: number): string | null {
121 const t = text.trim()
122 if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return isoDate(new Date(`${t}T12:00:00`).getTime()) === t ? t : null
123 const m = /^(\d{1,2})\.(\d{1,2})\.?(\d{4})?$/.exec(t)
124 if (!m) return null
125 const year = m[3] ? Number(m[3]) : new Date(now).getFullYear()
126 const probe = new Date(year, Number(m[2]) - 1, Number(m[1]))
127 if (probe.getMonth() !== Number(m[2]) - 1 || probe.getDate() !== Number(m[1])) return null // 31.02., 99.99. (invalid dates)
128 const date = `${year}-${pad(Number(m[2]))}-${pad(Number(m[1]))}`
129 // without a year and already past: next year
130 return !m[3] && date < isoDate(now) ? `${year + 1}-${pad(Number(m[2]))}-${pad(Number(m[1]))}` : date
131}
132
133export function asText(value: unknown): string {
134 return typeof value === 'string' ? value : value == null ? '' : String(value)
135}
136
137export function asTextOrNull(value: unknown): string | null {
138 return typeof value === 'string' && value !== '' ? value : null
139}
140
141export function rowKey(row: Omit<Row, 'key'>): string {
142 if (row.inboxId) return `inbox:${row.inboxId}`
143 if (row.task) return `task:${row.task}`
144 if (row.ref) return `ref:${row.ref}`
145 return `title:${row.title.slice(0, 60)}`
146}
147
148/** What a new tab can take over: an inbox entry or a task. */
149export function launchItem(row: Row): string | null {
150 return row.inboxId ?? row.task
151}
152
153export function githubUrl(ref: string | null): string | null {
154 const m = ref ? /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(ref) : null
155 return m ? `https://github.com/${m[1]}/issues/${m[2]}` : null
156}
157
158export function rowUrl(row: Row): string | null {
159 if (row.url && /^https?:\/\//.test(row.url)) return row.url
160 return githubUrl(row.ref)
161}
162
163export function parseView(raw: unknown, isFull: boolean, now: number): View {
164 const data = (raw ?? {}) as Record<string, unknown>
165 const v = (data.view ?? {}) as Record<string, unknown>
166 const buckets: Bucket[] = []
167 const seenKeys = new Set<string>()
168 for (const b of (v.buckets as Record<string, unknown>[] | undefined) ?? []) {
169 const rows: Row[] = []
170 for (const i of (b.items as Record<string, unknown>[] | undefined) ?? []) {
171 const base = {
172 bucket: asText(b.id),
173 title: asText(i.title),
174 why: Array.isArray(i.why) ? i.why.map(asText).filter(Boolean) : [],
175 sources: Array.isArray(i.sources) ? i.sources.map(asText) : [],
176 inboxId: asTextOrNull(i.inbox_id),
177 gate: asTextOrNull(i.gate),
178 task: asTextOrNull(i.task),
179 ref: asTextOrNull(i.ref),
180 url: asTextOrNull(i.url),
181 priority: asTextOrNull(i.priority),
182 urgency: asTextOrNull(i.urgency),
183 area: null,
184 areaNames: [],
185 taskType: null,
186 hasAction: i.has_action === true,
187 inboxState: asTextOrNull(i.inbox_state),
188 inboxKey: asTextOrNull(i.inbox_key),
189 mark: parseMark(i.mark),
190 }
191 // Same key twice (same number from two trackers): the second gets the block added.
192 const key = rowKey(base)
193 rows.push({ ...base, key: seenKeys.has(key) ? `${key}@${base.bucket}:${rows.length}` : key })
194 seenKeys.add(key)
195 }
196 buckets.push({ id: asText(b.id), title: asText(b.title), rows })
197 }
198 buckets.sort((a, b) => BUCKET_ORDER.indexOf(a.id) - BUCKET_ORDER.indexOf(b.id))
199 const agenda = ((v.agenda as Record<string, unknown>[] | undefined) ?? [])
200 .filter(a => !a.info)
201 .map(a => ({ when: asText(a.when), title: asText(a.title) }))
202 const status = ((v.status as Record<string, unknown>[] | undefined) ?? []).map(s => ({
203 title: asText(s.title),
204 count: typeof s.count === 'number' ? s.count : 0,
205 isHealthy: s.health !== false,
206 }))
207 return {
208 headline: asText(v.headline),
209 collectedAt: clockTime(now),
210 collectedMs: now,
211 isFull,
212 fullMs: isFull ? now : 0,
213 skipped: ((data.sections as Record<string, unknown>[] | undefined) ?? [])
214 .filter(x => x.status === 'skipped').map(x => asText(x.id)),
215 buckets,
216 agenda,
217 status,
218 pages: parsePages(data),
219 }
220}
221
222export function parseConfig(raw: Record<string, unknown>): UiConfig {
223 const launch = (raw.launch ?? {}) as Record<string, unknown>
224 const seconds = typeof raw.status_seconds === 'number' ? raw.status_seconds : 60
225 const where = launch.target
226 return {
227 enabled: raw.enabled === true,
228 band: raw.band !== false,
229 shortcuts: raw.shortcuts !== false,
230 morningHint: raw.morning_hint !== false,
231 voice: raw.voice === true,
232 statusSeconds: Math.max(15, seconds),
233 fullMinutes: typeof raw.full_minutes === 'number' ? Math.max(1, raw.full_minutes) : 10,
234 target: where === 'tab' || where === 'workspace' || where === 'auto' ? where : 'area',
235 surface: raw.surface === 'pane' ? 'pane' : 'chat',
236 snapshotMinutes: typeof raw.snapshot_minutes === 'number' ? Math.max(0, raw.snapshot_minutes) : 60,
237 control: typeof raw._control === 'string' && raw._control ? raw._control : null,
238 // own setting before language.conversation; without both, English
239 language: languageOf(typeof raw.language === 'string' && raw.language ? raw.language : raw._language),
240 }
241}
242
243export function languageOf(value: unknown): 'de' | 'en' {
244 return typeof value === 'string' && value.toLowerCase().startsWith('de') ? 'de' : 'en'
245}
246
247export type TaskInfo = { slug: string; label: string; area: string; areaNames: string[]; priority: string | null; blockedBy: string | null
248 stale: boolean; age: number; type: string | null }
249
250export function parseTasks(out: string): TaskInfo[] {
251 return (JSON.parse(out) as Record<string, unknown>[]).map(t => ({
252 slug: asText(t.slug),
253 label: asText(t.label),
254 area: asText(t.area),
255 areaNames: [asText(t.area), ...(Array.isArray(t.area_aliases) ? t.area_aliases.map(asText) : [])],
256 priority: asTextOrNull(t.priority),
257 blockedBy: asTextOrNull(t.blocked_by),
258 stale: t.stale === true,
259 age: typeof t.age === 'number' ? t.age : 0,
260 type: asTextOrNull(t.type),
261 }))
262}
263
264export function parseTeams(out: string): Team[] {
265 return (JSON.parse(out) as Record<string, unknown>[]).map(t => ({
266 id: asText(t.id),
267 label: asText(t.label),
268 forTypes: Array.isArray(t.for_types) ? t.for_types.map(asText) : [],
269 roles: ((t.roles as Record<string, unknown>[] | undefined) ?? []).map(r => ({
270 id: asText(r.id),
271 name: asText(r.name),
272 mode: r.mode === 'report' ? 'report' as const : 'go' as const,
273 })),
274 }))
275}
276
277/**
278 * All active tasks onto the card: rows of the briefing with a task get
279 * their area, the remaining tasks come in as rows of their own (dormant ones extra).
280 */
281export function withTasks(v: View, list: TaskInfo[]): View {
282 const bySlug = new Map(list.map(t => [t.slug, t]))
283 const seen = new Set<string>()
284 const buckets = v.buckets.filter(b => b.id !== 'tasks' && b.id !== 'resting').map(b => ({
285 ...b,
286 rows: b.rows.map(r => {
287 const t = r.task ? bySlug.get(r.task) : undefined
288 if (!t) return r
289 seen.add(t.slug)
290 // An inbox entry shows the area of its task, but not its priority.
291 return { ...r, area: t.area, areaNames: t.areaNames, priority: r.inboxId ? r.priority : r.priority ?? t.priority, taskType: t.type }
292 }),
293 }))
294 const toRow = (t: TaskInfo, bucket: string): Row => {
295 const why = t.blockedBy ? [T().blocked(t.blockedBy)] : t.stale ? [T().quietFor(t.age)] : []
296 const base = { bucket, title: t.label, why, sources: ['tasks'], inboxId: null, gate: null, task: t.slug,
297 ref: null, url: null, priority: t.priority, urgency: null, area: t.area, areaNames: t.areaNames, taskType: t.type, hasAction: false,
298 inboxState: null, inboxKey: null, mark: null }
299 return { ...base, key: rowKey(base) }
300 }
301 const rest = list.filter(t => !seen.has(t.slug))
302 const active = rest.filter(t => !t.blockedBy && !t.stale).map(t => toRow(t, 'tasks'))
303 const resting = rest.filter(t => t.blockedBy || t.stale).map(t => toRow(t, 'resting'))
304 if (active.length) buckets.push({ id: 'tasks', title: T().bucketTasks, rows: active })
305 if (resting.length) buckets.push({ id: 'resting', title: T().bucketResting, rows: resting })
306 return { ...v, buckets }
307}
308
309// ---------------------------------------------------------------- Order of the card
310
311export function prioRank(row: Row): number {
312 const m = row.priority ? /^P(\d)$/.exec(row.priority) : null
313 return m ? Number(m[1]) : 4
314}
315
316export function urgencyRank(row: Row): number {
317 return row.urgency === 'now' ? 0 : row.urgency === 'today' ? 1 : 2
318}
319
320/** Which block an open row belongs in. */
321export function classify(row: Row): SectionId {
322 if (row.urgency === 'now' || row.urgency === 'today' || row.bucket === 'do') return 'now'
323 if (row.bucket === 'drop' || row.bucket === 'waiting' || row.bucket === 'resting') return 'later'
324 if (row.inboxId) return row.gate === 'your-yes' || row.gate === 'free' ? 'give' : 'later'
325 if (row.task || row.ref) return 'work'
326 return 'later'
327}
328
329/** The inbox entry that a tab filed for its task (key tab-<slug>-...) */
330export function filedBy(tab: Tab, v: View | null): Row | undefined {
331 if (!tab.slug || !v) return undefined
332 const prefix = `tab-${tab.slug}-`
333 // The tab files with --task <slug>; only without a task does the key count (open-bridge is the start of open-bridge-x).
334 return v.buckets.flatMap(b => b.rows).find(r => r.inboxId !== null && (r.inboxKey ?? '').startsWith(prefix)
335 && (r.task === null || r.task === tab.slug))
336}
337
338/** Does the tab really wait for you? A finished tab whose result sits in the inbox no longer does. */
339export function isWaiting(tab: Tab, v: View | null): boolean {
340 if (tab.state === 'needs-you') return true
341 return tab.state === 'waiting' && filedBy(tab, v) === undefined
342}
343
344export function tabFor(row: Row, list: Tab[]): Tab | undefined {
345 const mine = list.filter(t => (row.task !== null && t.slug === row.task) || (row.inboxId !== null && t.item === row.inboxId))
346 return mine.find(t => ATTENTION.has(t.state)) ?? mine[mine.length - 1]
347}
348
349export type TeamState = { team: Team; started: number; next: Team['roles'][number] | null; isReady: boolean
350 waitingFor: string | null }
351
352/** From here a tab counts as long: waits an hour for you, or works an hour without end. */
353export const LONG_MS = 3600000
354
355export function duration(ms: number): string {
356 const min = Math.floor(ms / 60000)
357 return min < 60 ? `${min} min` : `${Math.floor(min / 60)}:${pad(min % 60)} h`
358}
359
360/** Does a tab wait or run for a conspicuously long time? Then the text for it, else null. */
361export function longText(tab: Tab, seen: Record<string, TabSeen>, now: number): string | null {
362 const s = seen[tab.ref]
363 if (!s || s.state !== tab.state || now - s.since < LONG_MS) return null
364 if (ATTENTION.has(tab.state)) return T().waitingFor(duration(now - s.since))
365 return tab.state === 'working' ? T().runningFor(duration(now - s.since)) : null
366}
367
368/** A freshly started tab needs a moment until cmux reports it; until then the role counts as "starting". */
369export const START_GRACE = 90000
370
371/**
372 * How far a task's team has got. The card remembers by itself what it started
373 * (role names like "Review" exist in several recipes, the tab name alone does not say the team
374 * for sure). The next role is due only once the previous one's tab waits; if it is missing
375 * for longer than START_GRACE (closed), it carries on anyway.
376 */
377export function teamState(row: Row, runs: Record<string, TeamRun>, list: Team[], tabList: Tab[], now: number): TeamState | null {
378 const run = row.task ? runs[row.task] : undefined
379 const team = run ? list.find(t => t.id === run.team) : undefined
380 if (!run || !team) return null
381 const started = team.roles.filter(r => run.roles.includes(r.id)).length
382 const next = team.roles[started] ?? null
383 const last = team.roles[started - 1]
384 const lastTab = last ? tabList.find(t => t.slug === row.task && t.role === last.id) : undefined
385 const isReady = started === 0 || (lastTab ? ATTENTION.has(lastTab.state) : now - run.at > START_GRACE)
386 return { team, started, next, isReady, waitingFor: isReady || !last ? null : last.name }
387}
388
389export type Section = { id: SectionId; all: Row[]; shown: Row[] }
390/** numbered: all rows in card order; numbers: row -> its fixed number (independent of expanding) */
391export type Layout = { sections: Section[]; hiddenRows: Row[]; numbered: Row[]; numbers: Map<string, number> }
392
393/**
394 * The card as a pure function of the state: the same calculation for drawing
395 * and for the shortcuts, so that "3a" hits the row that the 3 stands before.
396 */
397export function layout(
398 v: View | null, done: string[], hide: Record<string, string>, today: string,
399 open: SectionId | null, all: boolean, tabList: Tab[],
400): Layout {
401 const groups: Record<SectionId, Row[]> = { now: [], give: [], work: [], later: [] }
402 const hiddenRows: Row[] = []
403 const rows = v ? v.buckets.flatMap(b => b.rows.filter(r => !done.includes(r.key))) : []
404 for (const row of rows) {
405 if ((hide[row.key] ?? '') > today) hiddenRows.push(row)
406 else groups[classify(row)].push(row)
407 }
408 const order = (a: Row, b: Row) => urgencyRank(a) - urgencyRank(b) || prioRank(a) - prioRank(b)
409 const sections = (['now', 'give', 'work', 'later'] as const).map(id => {
410 const list = [...groups[id]].sort(order)
411 if (all || open === id) return { id, all: list, shown: list }
412 // Tasks compact: only P0 to P2 and what already has a tab
413 const pool = id === 'work' ? list.filter(r => prioRank(r) <= 2 || tabFor(r, tabList)) : list
414 return { id, all: list, shown: pool.slice(0, CAP[id]) }
415 })
416 const numbered = sections.flatMap(s => s.all)
417 return { sections, hiddenRows, numbered, numbers: new Map(numbered.map((r, i) => [r.key, i + 1])) }
418}
419
420/**
421 * Two runs into one view: what `fresh` left out (skipped) comes from `base`, everything else from `fresh`.
422 * Rows are taken over by `base` only if all their sections were left out in `fresh`.
423 */
424export function mergeView(base: View, fresh: View): View {
425 const gone = new Set(fresh.skipped)
426 const keys = new Set(fresh.buckets.flatMap(b => b.rows.map(r => r.key)))
427 const ids = [...new Set([...fresh.buckets.map(b => b.id), ...base.buckets.map(b => b.id)])]
428 const buckets = ids.map(id => {
429 const b = fresh.buckets.find(o => o.id === id) ?? { ...base.buckets.find(o => o.id === id)!, rows: [] }
430 const old = base.buckets.find(o => o.id === id)
431 const extra = (old?.rows ?? []).filter(r => r.sources.length > 0 && r.sources.every(src => gone.has(src))
432 && !keys.has(r.key))
433 return { ...b, rows: [...b.rows, ...extra] }
434 }).filter(b => b.rows.length > 0)
435 buckets.sort((a, b) => BUCKET_ORDER.indexOf(a.id) - BUCKET_ORDER.indexOf(b.id))
436 const baseSections = (base.pages ?? []).flatMap(pg => pg.sections)
437 const pages = (fresh.pages ?? []).map(pg => ({
438 ...pg,
439 sections: pg.sections.map(q => (gone.has(q.id) ? baseSections.find(f => f.id === q.id) ?? q : q)),
440 }))
441 // The headline counts every row and names the next date: a run that left out sections the earlier
442 // one had would undercount, so the earlier headline stays until a run as complete as it comes.
443 const freshHasInbox = !fresh.skipped.some(id => id === 'inbox')
444 const freshHasCalendar = !fresh.skipped.some(id => id === 'calendar')
445 const freshIsPartial = fresh.skipped.some(id => !(base.skipped ?? []).includes(id))
446 return {
447 ...fresh,
448 headline: freshIsPartial ? base.headline : fresh.headline,
449 status: freshHasInbox ? fresh.status : base.status,
450 agenda: freshHasCalendar ? fresh.agenda : base.agenda,
451 isFull: fresh.isFull || base.isFull,
452 fullMs: Math.max(fresh.fullMs || 0, base.fullMs || 0),
453 // A saved view of an older version does not know skipped yet.
454 skipped: fresh.skipped.filter(id => (base.skipped ?? []).includes(id)),
455 buckets,
456 pages,
457 }
458}
459
460export function toTab(t: Record<string, unknown>): Tab {
461 return {
462 name: asText(t.name), workspace: asText(t.workspace), ref: asText(t.ref), wsRef: asText(t.ws_ref),
463 state: asText(t.state), last: asText(t.last), slug: asTextOrNull(t.slug), item: asTextOrNull(t.item),
464 team: asTextOrNull(t.team), role: asTextOrNull(t.role),
465 }
466}
467
468export function refNumber(ref: unknown): number {
469 const m = /(\d+)$/.exec(asText(ref))
470 return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER
471}
472
473export function askAbout(row: Row): string {
474 const where = row.ref ?? row.url ?? row.task ?? row.inboxId ?? ''
475 return T().askAbout(row.title, where)
476}
477
478export function followUp(row: Row): string {
479 return T().followUp(row.title, row.why.join(', '))
480}
481
482export function overview(v: View, rows: Row[]): string {
483 const lines = rows.slice(0, 25).map((r, i) => `${i + 1}. [${classify(r)}] ${short(r.title, 140)}`)
484 return [T().overviewHead(v.collectedAt, v.headline), ...lines, T().overviewTail].join('\n')
485}
486
487/** Plan for today: appointments, inbox, tasks and waiting tabs as one assignment to Claude */
488export function planDay(v: View, rows: Row[], waiting: Tab[]): string {
489 const agenda = v.agenda.map(a => `- ${a.when} ${short(a.title, 80)}`)
490 const lines = rows.slice(0, 30).map((r, i) =>
491 `${i + 1}. [${classify(r)}${r.priority ? ` ${r.priority}` : ''}${r.mark ? ` ${r.mark.label}` : ''}] ${short(r.title, 120)}`)
492 const tabs = waiting.map(t => `- ${t.workspace} / ${t.name}: ${stateWord(t.state)}`)
493 return [
494 T().planHead(v.collectedAt, v.headline),
495 ...(agenda.length ? [T().planAgenda, ...agenda] : []),
496 T().planRows, ...lines,
497 ...(tabs.length ? [T().planTabs, ...tabs] : []),
498 T().planTail,
499 ].join('\n')
500}
501
502/** End of day with the open agent tabs: what of them stays open overnight belongs in the report. */
503export function evening(list: Tab[], seen: Record<string, TabSeen>, now: number): string {
504 const open = list.filter(t => t.state !== 'shell')
505 if (open.length === 0) return T().evening
506 const lines = open.map(t => `- ${t.workspace} / ${t.name}: ${stateWord(t.state)}` +
507 `${seen[t.ref] ? T().eveningSince(duration(now - seen[t.ref]!.since)) : ''}${t.last ? T().eveningLast(short(t.last, 80)) : ''}`)
508 return [T().evening, T().eveningTabs, ...lines].join('\n')
509}
510hooks/text.ts 757 lines1import type { SectionId, SideTab, Target } from '../types'
2
3/**
4 * Every text the mod shows or sends to Claude, in German and English.
5 * The language comes from bridge-config.yaml (briefing.claude_code_ui.language, else
6 * language.conversation); until the configuration has been read, English applies.
7 */
8export type Strings = {
9 // States and names
10 state: Record<string, string>
11 urgency: Record<string, string>
12 section: Record<SectionId, string>
13 where: Record<Target, string>
14 /** Short button texts under 120 columns, per action id */
15 shortLabel: Record<string, string>
16 side: Record<SideTab, string>
17 help: [string, string][]
18 briefing: string
19 bucketTasks: string
20 bucketResting: string
21 empty: string
22 blocked: (by: string) => string
23 quietFor: (days: number) => string
24 waitingFor: (d: string) => string
25 runningFor: (d: string) => string
26
27 // Results of an action (some of them already report themselves)
28 resDo: string
29 resLater: string
30 resDrop: string
31 resDone: string
32 resStarted: string
33 resPartly: string
34 resFailed: string
35 resHidden: string
36 resAsked: string
37 noInbox: string
38 nothingToStart: string
39 nothingToDo: string
40 noTask: string
41 allRolesStarted: string
42 waitsForRole: (name: string) => string
43 prevRole: string
44
45 // Buttons
46 btnDo: string
47 btnAdvise: string
48 btnDrop: string
49 btnDone: string
50 btnNudge: string
51 btnAskClaude: string
52 btnApproved: string
53 btnToTab: string
54 btnYes: string
55 btnNo: string
56 btnCloseTab: string
57 btnGoOn: string
58 btnOpenTab: string
59 btnAsk: string
60 btnOpen: string
61 btnTomorrow: string
62 btnMonday: string
63 btnWeek: string
64 btnUntil: string
65 btnCollect: string
66 btnNote: string
67 btnOwnWs: string
68 btnDoOwnWs: string
69 btnEachOwnWs: string
70 btnAskChat: string
71 btnPickAll: string
72 btnLess: string
73 btnMore: string
74 btnCompact: string
75 btnAll: string
76 btnReload: string
77 btnSort: string
78 btnPlan: string
79 emptyHint: string
80 approveAll: (n: number) => string
81 approveAsk: (n: number) => string
82 approveYes: string
83 approveNo: string
84 sourceAsOf: (time: string) => string
85 planHead: (at: string, headline: string) => string
86 planAgenda: string
87 planRows: string
88 planTabs: string
89 planTail: string
90 btnEvening: string
91 btnSidebar: string
92 btnSave: string
93 btnList: string
94 btnClose: string
95 btnShow: string
96 btnShowAgain: string
97 btnClear: string
98 btnSend: string
99 btnReopen: string
100 btnDashboard: string
101 btnView: string
102 tabsClose: string
103 tabsAll: (n: number) => string
104
105 // Card and sidebar
106 asOf: string
107 loading: string
108 loadingWhat: (what: string) => string
109 noTracker: string
110 resultFiled: string
111 resultFiledLong: string
112 toTabLabel: string
113 toTabPlaceholder: string
114 msgPlaceholder: string
115 tabInfo: (ws: string, name: string, state: string) => string
116 laterPrefix: string
117 hidePrefix: string
118 datePlaceholder: string
119 noDate: (text: string) => string
120 urgencyPrefix: string
121 prioPrefix: string
122 teamPrefix: string
123 teamProgress: (label: string, started: number, all: number) => string
124 teamStillWorking: (name: string) => string
125 teamNew: string
126 contextPrefix: string
127 notePlaceholder: string
128 adoptPrefix: string
129 moreRows: (n: number) => string
130 moreWork: string
131 moreArrow: (n: number) => string
132 helpTitle: string
133 skippedSection: string
134 unreadable: (why: string) => string
135 startingTab: string
136 nothingHere: string
137 waitCount: (n: number) => string
138 longCount: (n: number) => string
139 workingCount: (n: number) => string
140 tabsWaiting: (n: number) => string
141 counts: (now: number, give: number, work: number, later: number) => string
142 inboxRule: string
143 backup: string
144 backupAt: (time: string) => string
145 /** how often the layout is saved, from snapshot_minutes (0: only on Save) */
146 backupEvery: (minutes: number) => string
147 picked: (n: number) => string
148 hiddenUntil: (date: string) => string
149 hiddenCount: (n: number) => string
150 agenda: string
151 alarmUnder: (n: number, title: string) => string
152 restoreHint: string
153 olderCard: (when: string) => string
154 earlier: string
155 closedCard: (n: number) => string
156 bandShort: (n: number) => string
157 bandLong: (n: number) => string
158 bandLongCount: (n: number) => string
159 bandCountsShort: (now: number, give: number) => string
160 bandCountsLong: (now: number, give: number) => string
161
162 // Feedback
163 alreadyLoading: string
164 loadTasks: string
165 loadQuick: (guess: string) => string
166 loadRest: (guess: string) => string
167 seconds: (n: number) => string
168 loadFailed: (err: string) => string
169 viewUnreadable: (err: string) => string
170 statusFailed: string
171 tabsStale: (time: string, why: string) => string
172 tabToast: (name: string, state: string) => string
173 voiceWaiting: (name: string) => string
174 saved: string
175 saveFailed: (err: string) => string
176 listUnreadable: (err: string) => string
177 approveFailed: (err: string) => string
178 approvedNote: string
179 approvedPending: (title: string) => string
180 laterFailed: (err: string) => string
181 laterBack: (until: string) => string
182 hiddenNote: (until: string, title: string) => string
183 urgencySet: (word: string, title: string) => string
184 urgencyFailed: (err: string) => string
185 prioFailed: (err: string) => string
186 dropFailed: (err: string) => string
187 dropped: string
188 dropInboxNote: string
189 doneFailed: (err: string) => string
190 doneInboxNote: string
191 launchTeam: (role: string) => string
192 launchContext: string
193 launchStart: (word: string, n: number, where: string) => string
194 ownWs: string
195 partlyStarted: (n: number) => string
196 startFailed: string
197 adopted: (tab: string, title: string) => string
198 adoptFailed: (err: string) => string
199 noteOnly: string
200 noteAdded: (title: string, line: string) => string
201 noteFailed: (err: string) => string
202 jumpFailed: (err: string) => string
203 notAsking: (name: string) => string
204 keyFailed: (err: string) => string
205 saidYes: string
206 saidNo: string
207 tabClosed: (name: string) => string
208 closeFailed: (err: string) => string
209 sentTo: (name: string, text: string) => string
210 sendFailed: (err: string) => string
211 goOnMessage: string
212
213 // What the mod sends to Claude
214 askAbout: (title: string, where: string) => string
215 askInfo: (section: string, title: string, extra: string) => string
216 followUp: (title: string, why: string) => string
217 overviewHead: (at: string, headline: string) => string
218 overviewTail: string
219 evening: string
220 eveningTabs: string
221 eveningSince: (d: string) => string
222 eveningLast: (last: string) => string
223
224 // Commands, band, shortcuts
225 cmdDesc: string
226 cmdOffDesc: string
227 cmdDisabled: string
228 /** bridge-config.yaml exists but could not be read; the reason follows */
229 readFailed: (err: string) => string
230 /** must contain "(briefing-ui <id>)": that is how the card recognizes itself */
231 cmdOutput: (id: string) => string
232 bandOff: string
233 morning: string
234 scMissing: (n: number) => string
235 scHasTab: (n: number, title: string) => string
236 scApproved: string
237 scWaiting: string
238 scOnlyYou: string
239 scResult: (said: string) => string
240}
241
242const de: Strings = {
243 state: { 'needs-you': 'braucht dich', waiting: 'wartet', working: 'arbeitet', shell: 'Terminal' },
244 urgency: { now: 'jetzt', today: 'heute', later: 'später' },
245 section: { now: 'Wartet auf dich', give: 'Zum Weggeben', work: 'Deine Aufgaben', later: 'Später' },
246 where: { area: 'im Bereich', tab: 'als Tab hier', workspace: 'im eigenen Workspace', auto: 'dort, wo es hinpasst' },
247 shortLabel: { advise: 'Berate', nudge: 'Nachfass.', ask: 'fragen' },
248 side: { all: 'Alles', now: 'Jetzt', give: 'Ja', work: 'Aufgaben', later: 'Später', tabs: 'Tabs' },
249 help: [
250 ['Reiter oben', 'Briefing: was wartet und was du weggibst · daneben die Reiter deines Briefing-Profils (view.pages in workflow/briefings/<id>.yaml, sonst je Art: Status, Termine, Tracker, Heute, Weitere) · Zahl: was zählt, ✗ rot Befunde, ✓ alles grün · ohne Tokens; GitHub und die Systemprüfungen kommen mit dem vollen Lauf (⟳, beim Öffnen, wenn der letzte älter ist als full_minutes) · „Stand 08:46“: so alt sind die Zeilen aus dem Zwischenspeicher, ⟳ fragt frisch'],
251 ['Getestet', 'nur mit Claude Code und cmux; andere Agenten und Terminals folgen später'],
252 ['Kopfzeile', '⟳ neu laden · alles: jeden Block aufklappen · einordnen: Claude priorisiert (Tokens) · Plan für heute: Claude legt die Reihenfolge in deine freien Zeiten (Tokens) · Feierabend · Leiste: Seitenleiste öffnen · ? Hilfe · ✕ Karte zuklappen, „aufklappen“ holt sie zurück'],
253 ['Farben', '● rot braucht dich, gelb wartet, grün arbeitet · P0 rot, P1 gelb, P2 cyan · ! jetzt dringend'],
254 ['Seitenleiste', 'auch die Karte unter 90 Spalten · Reiter oben: Alles (alle Blöcke untereinander, › öffnet einen), Jetzt, Ja, Aufgaben, Später, Tabs · Zeile anklicken: ganzer Text und Knöpfe, „mehr …“ für Später, Prio, Team, Kontext · ◂ ▸ blättert · ⟳ neu laden · ✕ schließt'],
255 ['Blöcke', 'Wartet auf dich: wartende Tabs, „jetzt“ und „heute“ · Zum Weggeben: braucht nur dein Ja oder darf allein · Deine Aufgaben: P0 bis P2, Bereich davor · Später: Rest, Blockiertes, Ausgeblendetes'],
256 ['Markierung', 'farbiges Wort vor der Zeile (z. B. ein Kunde): view.marks im Briefing-Profil, nie im Code'],
257 ['alle freigeben', 'unter Zum Weggeben: gibt jeder Freigabe mit Aktion auf einmal dein Ja, nach einer Rückfrage'],
258 ['Mach du', 'Eintrag mit Aktion: gilt als Ja, sie läuft beim nächsten Durchlauf des Posteingangs · sonst neuer Tab im Workspace des Bereichs, der es erledigt'],
259 ['Tab zuordnen (▾)', 'ein schon laufender Tab im Bereich einer Aufgabe wird ihr zugeordnet, danach steht dort „zum Tab“'],
260 ['Berate mich', 'neuer Tab liest den Kontext, berichtet kurz und wartet auf dich'],
261 ['Erledigt · Weg', 'Eintrag schließen (nur du konntest es tun) · Eintrag verwerfen'],
262 ['zum Tab · weiter · ✉', 'zum Tab springen · „Mach weiter.“ schicken · eigene Nachricht an den Tab · braucht dich (fragt um Erlaubnis): Ja oder Nein direkt beantworten · Ergebnis da: der Tab hat sein Ergebnis in den Posteingang gelegt, zählt nicht mehr als wartend, „Tab schließen“ räumt ihn weg'],
263 ['Leitstand', 'nur die Sitzung im Leitstand-Workspace (workplace.control in bridge-config.yaml) fragt die Tabs ab, meldet und sichert; die anderen lesen ihren Stand mit und bleiben still · ist der Tab-Stand alt, steht rot warum'],
264 ['▾ (Leiste: Titel)', 'Details, Später/Ausblenden bis Datum, Dringlichkeit, Prio, eigener Workspace, im Chat fragen, Link'],
265 ['[ ]', 'markieren; oben erscheint „N markiert: Mach du · Berate mich“'],
266 ['+N weitere', 'einen Block aufklappen · „alles“ klappt alle auf'],
267 ['Team (▾)', 'mehrere Rollen-Tabs auf einer Aufgabe, nacheinander: Bauen (Umsetzung, Prüfung), TDD, Recherche (Belege, Gegenposition, Zusammenfassung), Antwort (Verlauf, Entwurf), Betrieb (Diagnose, Behebung) · ▶ startet die nächste Rolle, wenn die vorige wartet · Übergabe in team.md'],
268 ['Lange · Sicherung', '„lange“: Tab wartet oder läuft seit über einer Stunde, einmal gemeldet · Workspaces und Tabs werden in cmux regelmäßig gesichert (snapshot_minutes, sonst stündlich), nur bei Änderung; „Liste“ zeigt die letzten'],
269 ['Kontext (▾)', 'Notiz: selbst ergänzen, landet in STATUS.md oder am Eintrag · Kontext sammeln lassen: ein Tab sucht in Log, Posteingang, Commits, Issues, Mails, Protokollen und notiert die Fakten mit Quelle'],
270 ['Kürzel', 'in die Eingabezeile, ohne Claude zu fragen: 3a Mach du · 3b Später · 3c Weg · 3v Berate mich · 3w eigener Workspace · mehrere: 1a 4v'],
271 ['Tokens', 'nur „einordnen“, „Plan für heute“, „Feierabend“, „im Chat fragen“, „Nachfassen“ und jeder gestartete Tab · alles andere lokal'],
272 ['Einstellungen', 'briefing.claude_code_ui in bridge-config.yaml (Band, Kürzel, Stimme, Ziel beim Starten, wie oft abgefragt wird, Frist für den vollen Lauf, Sprache)'],
273 ],
274 briefing: 'Briefing',
275 bucketTasks: 'Aufgaben',
276 bucketResting: 'Ruhend oder blockiert',
277 empty: 'nichts',
278 blocked: by => `blockiert: ${by}`,
279 quietFor: days => `seit ${days} Tagen ruhig`,
280 waitingFor: d => `wartet seit ${d}`,
281 runningFor: d => `läuft seit ${d}`,
282
283 resDo: 'Mach du',
284 resLater: 'Später',
285 resDrop: 'Weg',
286 resDone: 'Erledigt',
287 resStarted: 'gestartet',
288 resPartly: 'teilweise gestartet',
289 resFailed: 'fehlgeschlagen',
290 resHidden: 'ausgeblendet',
291 resAsked: 'gefragt',
292 noInbox: 'kein Posteingangs-Eintrag',
293 nothingToStart: 'nichts zu starten',
294 nothingToDo: 'nichts zu tun',
295 noTask: 'keine Aufgabe',
296 allRolesStarted: 'alle Rollen gestartet',
297 waitsForRole: name => `wartet auf ${name}`,
298 prevRole: 'die vorige Rolle',
299
300 btnDo: 'Mach du',
301 btnAdvise: 'Berate mich',
302 btnDrop: 'Weg',
303 btnDone: 'Erledigt',
304 btnNudge: 'Nachfassen',
305 btnAskClaude: 'Claude fragen',
306 btnApproved: 'Ja gegeben',
307 btnToTab: 'zum Tab',
308 btnYes: 'Ja',
309 btnNo: 'Nein',
310 btnCloseTab: 'Tab schließen',
311 btnGoOn: 'weiter',
312 btnOpenTab: 'Tab öffnen',
313 btnAsk: 'fragen',
314 btnOpen: 'öffnen',
315 btnTomorrow: 'morgen',
316 btnMonday: 'Montag',
317 btnWeek: '1 Woche',
318 btnUntil: 'bis',
319 btnCollect: 'sammeln lassen',
320 btnNote: 'notieren',
321 btnOwnWs: 'eigener Workspace',
322 btnDoOwnWs: 'Mach du im eigenen Workspace',
323 btnEachOwnWs: 'je eigener Workspace',
324 btnAskChat: 'im Chat fragen',
325 btnPickAll: 'alle markieren',
326 btnLess: 'weniger',
327 btnMore: 'mehr …',
328 btnCompact: 'kompakt',
329 btnAll: 'alles',
330 btnReload: '⟳ neu',
331 btnSort: 'einordnen',
332 btnPlan: 'Plan für heute',
333 emptyHint: 'Nichts wartet auf dich. Was hier steht, legt dein Briefing-Profil fest (workflow/briefings/<id>.yaml); wie du es aufbaust: docs/briefing-dashboard.md',
334 approveAll: n => `alle ${n} freigeben`,
335 approveAsk: n => `Wirklich alle ${n} freigeben? Jede führt ihre Aktion beim nächsten Durchlauf aus.`,
336 approveYes: 'Ja, alle',
337 approveNo: 'Abbrechen',
338 sourceAsOf: time => `Stand ${time}`,
339 planHead: (at, headline) => `Plan für heute aus dem Dashboard (${at}): ${headline}`,
340 planAgenda: 'Termine:',
341 planRows: 'Offen (Block, Priorität, Markierung):',
342 planTabs: 'Agenten-Tabs, die auf mich warten:',
343 planTail: 'Mach mir einen Plan für heute: eine Reihenfolge mit Uhrzeiten in den freien Zeiten zwischen den Terminen, ' +
344 'was du parallel in Tabs erledigen kannst, was ich selbst tun muss, und was bis morgen warten kann. ' +
345 'Kurz, als Tabelle. Nichts ausführen und nichts nach außen ohne mein Ja.',
346 btnEvening: 'Feierabend',
347 btnSidebar: 'Leiste',
348 btnSave: 'jetzt sichern',
349 btnList: 'Liste',
350 btnClose: 'zu',
351 btnShow: 'zeigen',
352 btnShowAgain: 'wieder zeigen',
353 btnClear: 'leeren',
354 btnSend: 'senden',
355 btnReopen: 'aufklappen',
356 btnDashboard: 'Dashboard',
357 btnView: 'ansehen ›',
358 tabsClose: 'Tabs zu',
359 tabsAll: n => `alle ${n} Tabs`,
360
361 asOf: 'Stand',
362 loading: 'lädt …',
363 loadingWhat: what => `lädt ${what}`,
364 noTracker: '(ohne Tracker)',
365 resultFiled: 'Ergebnis da',
366 resultFiledLong: 'Ergebnis liegt im Posteingang, der Tab kann zu',
367 toTabLabel: 'an den Tab',
368 toTabPlaceholder: 'z. B. go on',
369 msgPlaceholder: 'Nachricht an den Tab',
370 tabInfo: (ws, name, state) => `Tab: ${ws} / ${name}, ${state}`,
371 laterPrefix: 'Später: ',
372 hidePrefix: 'Ausblenden bis: ',
373 datePlaceholder: '14.10.',
374 noDate: text => `kein Datum: ${text} (z. B. 14.10. oder 2026-10-14)`,
375 urgencyPrefix: 'Dringlichkeit: ',
376 prioPrefix: 'Prio: ',
377 teamPrefix: 'Team: ',
378 teamProgress: (label, started, all) => `Team ${label} ${started}/${all}: `,
379 teamStillWorking: name => `${name} arbeitet noch `,
380 teamNew: 'neu: ',
381 contextPrefix: 'Kontext: ',
382 notePlaceholder: 'Notiz: was fehlt, wer, bis wann',
383 adoptPrefix: 'Läuft schon? Tab zuordnen: ',
384 moreRows: n => `+ ${n} weitere`,
385 moreWork: ' (P3, ohne Prio)',
386 moreArrow: n => `+ ${n} weitere ›`,
387 helpTitle: 'Hilfe',
388 skippedSection: 'kommt mit dem vollen Briefing (⟳)',
389 unreadable: why => `nicht lesbar: ${why}`,
390 startingTab: 'startet …',
391 nothingHere: 'nichts hier',
392 waitCount: n => `● ${n} warten`,
393 longCount: n => `(${n} lange)`,
394 workingCount: n => `● ${n} arbeiten`,
395 tabsWaiting: n => `● ${n} Tabs warten`,
396 counts: (now, give, work, later) => `${now} jetzt · ${give} weggeben · ${work} Aufgaben · ${later} später`,
397 inboxRule: 'Posteingang',
398 backup: 'Sicherung',
399 backupAt: time => `zuletzt ${time}`,
400 backupEvery: min => (min === 60 ? 'stündlich' : min > 0 ? `alle ${min} Min.` : 'nur von Hand'),
401 picked: n => `${n} markiert`,
402 hiddenUntil: date => `bis ${date}`,
403 hiddenCount: n => ` · ${n} ausgeblendet`,
404 agenda: 'Termine: ',
405 alarmUnder: (n, title) => `✗ ${n} Befunde unter ${title}`,
406 restoreHint: 'Wiederherstellen: im Chat „stell die cmux-Workspaces aus der Sicherung wieder her“ (cmux-Skill, erst Probelauf)',
407 olderCard: when => `Briefing von ${when}, das aktuelle steht weiter unten`,
408 earlier: 'früher',
409 closedCard: n => `Briefing zugeklappt · ${n} Tabs warten`,
410 bandShort: n => `● ${n} warten`,
411 bandLong: n => `Briefing: ${n} Tabs warten`,
412 bandLongCount: n => ` · ${n} lange`,
413 bandCountsShort: (now, give) => ` · ${now} jetzt · ${give} Ja `,
414 bandCountsLong: (now, give) => ` · ${now} jetzt · ${give} zum Weggeben `,
415
416 alreadyLoading: 'lädt schon, danach noch einmal ganz',
417 loadTasks: 'Aufgaben',
418 loadQuick: guess => `Posteingang und Aufgaben${guess}`,
419 loadRest: guess => `alles, mit Trackern und Prüfungen${guess}`,
420 seconds: n => ` (~${n} s)`,
421 loadFailed: err => `Briefing laden fehlgeschlagen: ${err}`,
422 viewUnreadable: err => `Briefing nicht lesbar: ${err}`,
423 statusFailed: 'Tab-Abfrage gescheitert',
424 tabsStale: (time, why) => `Tab-Stand seit ${time} nicht aktuell: ${why}`,
425 tabToast: (name, state) => `Tab „${name}“ ${state}`,
426 voiceWaiting: name => `${name} wartet auf dich`,
427 saved: 'Workspaces gesichert (nur wenn sich etwas geändert hat)',
428 saveFailed: err => `Sicherung ging nicht: ${err}`,
429 listUnreadable: err => `nicht lesbar: ${err}`,
430 approveFailed: err => `Mach du ging nicht: ${err}`,
431 approvedNote: 'Ja gegeben, es läuft beim nächsten Durchlauf',
432 approvedPending: title => `läuft beim nächsten Durchlauf: ${title}`,
433 laterFailed: err => `Später fehlgeschlagen: ${err}`,
434 laterBack: until => `Später, zurück am ${until}`,
435 hiddenNote: (until, title) => `ausgeblendet bis ${until}: ${title}`,
436 urgencySet: (word, title) => `Dringlichkeit ${word}: ${title}`,
437 urgencyFailed: err => `Dringlichkeit ging nicht: ${err}`,
438 prioFailed: err => `Priorität ging nicht: ${err}`,
439 dropFailed: err => `Weg fehlgeschlagen: ${err}`,
440 dropped: 'Verworfen',
441 dropInboxNote: 'im Briefing-Dashboard verworfen',
442 doneFailed: err => `Erledigt fehlgeschlagen: ${err}`,
443 doneInboxNote: 'im Briefing-Dashboard als erledigt markiert',
444 launchTeam: role => `Team, Rolle ${role}`,
445 launchContext: 'Kontext sammeln',
446 launchStart: (word, n, where) => `${word}: starte ${n} Tab(s) ${where} …`,
447 ownWs: 'eigener Workspace',
448 partlyStarted: n => `${n} gestartet, Rest fehlgeschlagen`,
449 startFailed: 'Start fehlgeschlagen',
450 adopted: (tab, title) => `Tab „${tab}“ gehört jetzt zu ${title}`,
451 adoptFailed: err => `Zuordnen ging nicht: ${err}`,
452 noteOnly: 'Notiz geht nur an Aufgaben und Posteingangs-Einträge',
453 noteAdded: (title, line) => `Notiz an ${title}: ${line}`,
454 noteFailed: err => `Notiz ging nicht: ${err}`,
455 jumpFailed: err => `Hinspringen ging nicht: ${err}`,
456 notAsking: name => `${name} fragt nicht mehr, nichts gedrückt`,
457 keyFailed: err => `Taste ging nicht: ${err}`,
458 saidYes: 'Ja gesendet',
459 saidNo: 'Nein gesendet',
460 tabClosed: name => `Tab „${name}“ geschlossen`,
461 closeFailed: err => `Schließen ging nicht: ${err}`,
462 sentTo: (name, text) => `an ${name}: ${text}`,
463 sendFailed: err => `Senden fehlgeschlagen: ${err}`,
464 goOnMessage: 'Mach weiter.',
465
466 askAbout: (title, where) => `Briefing-Punkt: ${title}${where ? ` (${where})` : ''}. Schau es dir an und schlag den nächsten Schritt vor. Nichts nach außen ohne mein Ja.`,
467 askInfo: (section, title, extra) => `${section}: ${title}${extra ? ` (${extra})` : ''}. Schau es dir an und schlag den nächsten Schritt vor. Nichts nach außen ohne mein Ja.`,
468 followUp: (title, why) => `Entwirf eine kurze Nachfass-Nachricht für: ${title}${why ? ` (${why})` : ''}. Nur als Entwurf, nichts senden.`,
469 overviewHead: (at, headline) => `Mein Briefing-Stand aus dem Dashboard (${at}): ${headline}`,
470 overviewTail: 'Ordne kurz ein: was zuerst, was kann weg, was gebe ich an einen Tab ab. Keine Aktion ohne mein Ja.',
471 evening: 'Feierabend-Ritual: Lies den heutigen Tagesblock in work/log.md ganz und python3 scripts/inbox.py list. ' +
472 'Zeig mir kurz: was heute erledigt ist, was verschoben ist, was offen bleibt, und schlag vor, was morgen ' +
473 'zuerst kommt. Dann schreib eine Logzeile mit gemessener Uhrzeit. Nichts nach außen.',
474 eveningTabs: 'Offene Agenten-Tabs, sag zu jedem: abschließen, morgen weiter, oder was ihm fehlt:',
475 eveningSince: d => ` seit ${d}`,
476 eveningLast: last => ` (zuletzt: ${last})`,
477
478 cmdDesc: 'Briefing als Dashboard: wer wartet, was gebe ich weg, woran arbeite ich; per Klick Tabs starten',
479 cmdOffDesc: 'Briefing-Band für diese Sitzung ausblenden (Dashboard bleibt über /briefing-ui)',
480 cmdDisabled: 'briefing-ui ist aus: briefing.claude_code_ui.enabled in bridge-config.yaml auf true setzen.',
481 readFailed: err => `bridge-config.yaml ließ sich nicht lesen: ${err}`,
482 cmdOutput: id => `Briefing-Dashboard (briefing-ui ${id}). Ohne den Mod steht hier nur dieser Satz.`,
483 bandOff: 'Briefing-Band ausgeblendet. /briefing-ui öffnet das Dashboard weiterhin.',
484 morning: 'Guten Morgen. /briefing-ui zeigt, wer wartet und was du weggeben kannst.',
485 scMissing: n => `${n}: gibt es nicht`,
486 scHasTab: (n, title) => `${n} (${title}) hat schon einen Tab, dorthin gewechselt`,
487 scApproved: 'Ja ist schon gegeben',
488 scWaiting: 'wartet auf jemand anderen, Nachfassen über die Karte',
489 scOnlyYou: 'nur du, v für Berate mich',
490 scResult: said => `Briefing ohne Claude: ${said}`,
491}
492
493const en: Strings = {
494 state: { 'needs-you': 'needs you', waiting: 'waiting', working: 'working', shell: 'terminal' },
495 urgency: { now: 'now', today: 'today', later: 'later' },
496 section: { now: 'Waiting for you', give: 'To hand off', work: 'Your tasks', later: 'Later' },
497 where: { area: 'in its area', tab: 'as a tab here', workspace: 'in its own workspace', auto: 'where it fits' },
498 shortLabel: { advise: 'Advise', nudge: 'Follow up', ask: 'Ask' },
499 side: { all: 'All', now: 'Now', give: 'Yes', work: 'Tasks', later: 'Later', tabs: 'Tabs' },
500 help: [
501 ['Tabs on top', 'Briefing: what waits and what you hand off · beside it the tabs of your briefing profile (view.pages in workflow/briefings/<id>.yaml, else by kind: Status, Dates, Trackers, Today, More) · number: what counts, ✗ red findings, ✓ all green · no tokens; GitHub and the health checks arrive with the full run (⟳, on open when the last is older than full_minutes) · "as of 08:46": how old cached rows are, ⟳ asks fresh'],
502 ['Tested', 'only with Claude Code and cmux; other agents and terminals come later'],
503 ['Header', '⟳ reload · all: open every section · Sort it: Claude prioritises (tokens) · Plan my day: Claude fits the order into your free slots (tokens) · End of day · Sidebar: open the sidebar · ? help · ✕ collapse the card, "Expand" brings it back'],
504 ['Colours', '● red needs you, yellow waits, green works · P0 red, P1 yellow, P2 cyan · ! urgent now'],
505 ['Sidebar', 'also the card below 90 columns · tabs at the top: All (every section stacked, › opens one), Now, Yes, Tasks, Later, Tabs · click a line: full text and buttons, "more …" for later, priority, team, context · ◂ ▸ pages · ⟳ reload · ✕ closes'],
506 ['Sections', 'Waiting for you: tabs that wait, "now" and "today" · To hand off: needs only your yes, or may run alone · Your tasks: P0 to P2, area in front · Later: the rest, blocked, hidden'],
507 ['Mark', 'coloured word in front of a row (a customer, say): view.marks in the briefing profile, never in code'],
508 ['approve all', 'under To hand off: gives every approval with an action your yes at once, after one question'],
509 ['Do it', 'an item with an action counts as your yes, it runs on the next inbox pass · otherwise a new tab in the area workspace does it'],
510 ['Assign tab (▾)', 'a tab already running in the task\'s area is linked to it, then "Go to tab" shows'],
511 ['Advise me', 'a new tab reads the context, reports briefly, then waits for you'],
512 ['Done · Drop', 'close an item only you could do · discard the item'],
513 ['Go to tab · go on · ✉', 'jump to the tab · send "Keep going." · your own message to the tab · needs you (asks for permission): answer Yes or No right here · Result filed: the tab put its result in the inbox, no longer counts as waiting, "Close tab" closes it'],
514 ['Control session', 'only the session in the control workspace (workplace.control in bridge-config.yaml) polls the tabs, notifies and saves the layout; the others read its state and stay quiet · a stale tab state says why in red'],
515 ['▾ (sidebar: title)', 'details, later or hide until a date, urgency, priority, own workspace, ask in chat, link'],
516 ['[ ]', 'select; "N selected: Do it · Advise me" appears at the top'],
517 ['+N more', 'open one section · "all" opens every section'],
518 ['Team (▾)', 'several role tabs on one task, one after the other: Build (implement, review), TDD, Research (evidence, counter, summary), Reply (context, draft), Ops (diagnose, fix) · ▶ starts the next role once the previous one waits · handoff in team.md'],
519 ['Long · Backup', '"long": a tab has waited or run for over an hour, reported once · inside cmux, workspaces and tabs are saved regularly (snapshot_minutes, hourly by default), only on change; "List" shows the latest'],
520 ['Context (▾)', 'Note: add it yourself, it goes into STATUS.md or onto the item · let it collect: a tab searches log, inbox, commits, issues, mails, minutes and notes the facts with sources'],
521 ['Shortcuts', 'type them into the prompt, no Claude turn: 3a do it · 3b later · 3c drop · 3v advise me · 3w own workspace · several: 1a 4v'],
522 ['Tokens', 'only "Sort it", "Plan my day", "End of day", "ask in chat", "Follow up" and every launched tab · everything else is local'],
523 ['Settings', 'briefing.claude_code_ui in bridge-config.yaml (band, shortcuts, voice, launch target, how often to poll, how old the full run may be, language)'],
524 ],
525 briefing: 'Briefing',
526 bucketTasks: 'Tasks',
527 bucketResting: 'Resting or blocked',
528 empty: 'nothing',
529 blocked: by => `blocked: ${by}`,
530 quietFor: days => `quiet for ${days} days`,
531 waitingFor: d => `waiting for ${d}`,
532 runningFor: d => `running for ${d}`,
533
534 resDo: 'Do it',
535 resLater: 'Later',
536 resDrop: 'Dropped',
537 resDone: 'Done',
538 resStarted: 'started',
539 resPartly: 'partly started',
540 resFailed: 'failed',
541 resHidden: 'hidden',
542 resAsked: 'asked',
543 noInbox: 'not an inbox item',
544 nothingToStart: 'nothing to start',
545 nothingToDo: 'nothing to do',
546 noTask: 'not a task',
547 allRolesStarted: 'all roles started',
548 waitsForRole: name => `waiting for ${name}`,
549 prevRole: 'the previous role',
550
551 btnDo: 'Do it',
552 btnAdvise: 'Advise me',
553 btnDrop: 'Drop',
554 btnDone: 'Done',
555 btnNudge: 'Follow up',
556 btnAskClaude: 'Ask Claude',
557 btnApproved: 'Yes given',
558 btnToTab: 'Go to tab',
559 btnYes: 'Yes',
560 btnNo: 'No',
561 btnCloseTab: 'Close tab',
562 btnGoOn: 'go on',
563 btnOpenTab: 'Open tab',
564 btnAsk: 'ask',
565 btnOpen: 'open',
566 btnTomorrow: 'tomorrow',
567 btnMonday: 'Monday',
568 btnWeek: '1 week',
569 btnUntil: 'until',
570 btnCollect: 'let it collect',
571 btnNote: 'add note',
572 btnOwnWs: 'own workspace',
573 btnDoOwnWs: 'Do it in its own workspace',
574 btnEachOwnWs: 'each in its own workspace',
575 btnAskChat: 'ask in chat',
576 btnPickAll: 'select all',
577 btnLess: 'less',
578 btnMore: 'more …',
579 btnCompact: 'compact',
580 btnAll: 'all',
581 btnReload: '⟳ reload',
582 btnSort: 'Sort it',
583 btnPlan: 'Plan my day',
584 emptyHint: 'Nothing waits for you. What shows here is up to your briefing profile (workflow/briefings/<id>.yaml); how to build it: docs/briefing-dashboard.md',
585 approveAll: n => `approve all ${n}`,
586 approveAsk: n => `Approve all ${n}? Each runs its action on the next pass.`,
587 approveYes: 'Yes, all',
588 approveNo: 'Cancel',
589 sourceAsOf: time => `as of ${time}`,
590 planHead: (at, headline) => `Plan for today from the dashboard (${at}): ${headline}`,
591 planAgenda: 'Appointments:',
592 planRows: 'Open (block, priority, mark):',
593 planTabs: 'Agent tabs waiting for me:',
594 planTail: 'Make me a plan for today: an order with times in the free slots between appointments, ' +
595 'what you can do in parallel in tabs, what I have to do myself, and what can wait until tomorrow. ' +
596 'Short, as a table. Run nothing and send nothing out without my yes.',
597 btnEvening: 'End of day',
598 btnSidebar: 'Sidebar',
599 btnSave: 'save now',
600 btnList: 'List',
601 btnClose: 'close',
602 btnShow: 'show',
603 btnShowAgain: 'show again',
604 btnClear: 'clear',
605 btnSend: 'send',
606 btnReopen: 'Expand',
607 btnDashboard: 'Dashboard',
608 btnView: 'view ›',
609 tabsClose: 'hide tabs',
610 tabsAll: n => `all ${n} tabs`,
611
612 asOf: 'As of',
613 loading: 'loading …',
614 loadingWhat: what => `loading ${what}`,
615 noTracker: '(without trackers)',
616 resultFiled: 'Result filed',
617 resultFiledLong: 'The result is in the inbox, the tab can be closed',
618 toTabLabel: 'to the tab',
619 toTabPlaceholder: 'e.g. go on',
620 msgPlaceholder: 'Message to the tab',
621 tabInfo: (ws, name, state) => `Tab: ${ws} / ${name}, ${state}`,
622 laterPrefix: 'Later: ',
623 hidePrefix: 'Hide until: ',
624 datePlaceholder: '2026-10-14',
625 noDate: text => `not a date: ${text} (e.g. 2026-10-14 or 14.10.)`,
626 urgencyPrefix: 'Urgency: ',
627 prioPrefix: 'Priority: ',
628 teamPrefix: 'Team: ',
629 teamProgress: (label, started, all) => `Team ${label} ${started}/${all}: `,
630 teamStillWorking: name => `${name} is still working `,
631 teamNew: 'new: ',
632 contextPrefix: 'Context: ',
633 notePlaceholder: 'Note: what is missing, who, by when',
634 adoptPrefix: 'Already running? Assign tab: ',
635 moreRows: n => `+ ${n} more`,
636 moreWork: ' (P3, no priority)',
637 moreArrow: n => `+ ${n} more ›`,
638 helpTitle: 'Help',
639 skippedSection: 'arrives with the full briefing (⟳)',
640 unreadable: why => `could not read: ${why}`,
641 startingTab: 'starting …',
642 nothingHere: 'nothing here',
643 waitCount: n => `● ${n} waiting`,
644 longCount: n => `(${n} long)`,
645 workingCount: n => `● ${n} working`,
646 tabsWaiting: n => `● ${n} tabs waiting`,
647 counts: (now, give, work, later) => `${now} now · ${give} to hand off · ${work} tasks · ${later} later`,
648 inboxRule: 'Inbox',
649 backup: 'Backup',
650 backupAt: time => `last at ${time}`,
651 backupEvery: min => (min === 60 ? 'hourly' : min > 0 ? `every ${min} min` : 'only by hand'),
652 picked: n => `${n} selected`,
653 hiddenUntil: date => `until ${date}`,
654 hiddenCount: n => ` · ${n} hidden`,
655 agenda: 'Dates: ',
656 alarmUnder: (n, title) => `✗ ${n} findings under ${title}`,
657 restoreHint: 'To restore, ask in chat "restore the cmux workspaces from the backup" (cmux skill, dry run first)',
658 olderCard: when => `Briefing from ${when}, the current one is further down`,
659 earlier: 'earlier',
660 closedCard: n => `Briefing collapsed · ${n} tabs waiting`,
661 bandShort: n => `● ${n} waiting`,
662 bandLong: n => `Briefing: ${n} tabs waiting`,
663 bandLongCount: n => ` · ${n} long`,
664 bandCountsShort: (now, give) => ` · ${now} now · ${give} yes `,
665 bandCountsLong: (now, give) => ` · ${now} now · ${give} to hand off `,
666
667 alreadyLoading: 'already loading, a full reload follows',
668 loadTasks: 'tasks',
669 loadQuick: guess => `inbox and tasks${guess}`,
670 loadRest: guess => `everything, with trackers and checks${guess}`,
671 seconds: n => ` (~${n} s)`,
672 loadFailed: err => `Could not load the briefing: ${err}`,
673 viewUnreadable: err => `Could not read the briefing: ${err}`,
674 statusFailed: 'tab check failed',
675 tabsStale: (time, why) => `Tab state not current since ${time}: ${why}`,
676 tabToast: (name, state) => `Tab "${name}" ${state}`,
677 voiceWaiting: name => `${name} is waiting for you`,
678 saved: 'Workspaces saved (only if something changed)',
679 saveFailed: err => `Backup failed: ${err}`,
680 listUnreadable: err => `could not read: ${err}`,
681 approveFailed: err => `Do it failed: ${err}`,
682 approvedNote: 'Yes given, it runs on the next pass',
683 approvedPending: title => `runs on the next pass: ${title}`,
684 laterFailed: err => `Later failed: ${err}`,
685 laterBack: until => `Later, back on ${until}`,
686 hiddenNote: (until, title) => `hidden until ${until}: ${title}`,
687 urgencySet: (word, title) => `Urgency ${word}: ${title}`,
688 urgencyFailed: err => `Urgency failed: ${err}`,
689 prioFailed: err => `Priority failed: ${err}`,
690 dropFailed: err => `Drop failed: ${err}`,
691 dropped: 'Dropped',
692 dropInboxNote: 'dropped in the briefing dashboard',
693 doneFailed: err => `Done failed: ${err}`,
694 doneInboxNote: 'marked done in the briefing dashboard',
695 launchTeam: role => `Team, role ${role}`,
696 launchContext: 'Collect context',
697 launchStart: (word, n, where) => `${word}: starting ${n} tab(s) ${where} …`,
698 ownWs: 'own workspace',
699 partlyStarted: n => `${n} started, the rest failed`,
700 startFailed: 'Start failed',
701 adopted: (tab, title) => `Tab "${tab}" now belongs to ${title}`,
702 adoptFailed: err => `Assigning failed: ${err}`,
703 noteOnly: 'Notes only go to tasks and inbox items',
704 noteAdded: (title, line) => `Note on ${title}: ${line}`,
705 noteFailed: err => `Note failed: ${err}`,
706 jumpFailed: err => `Could not switch to the tab: ${err}`,
707 notAsking: name => `${name} is no longer asking, nothing pressed`,
708 keyFailed: err => `Key press failed: ${err}`,
709 saidYes: 'Yes sent',
710 saidNo: 'No sent',
711 tabClosed: name => `Tab "${name}" closed`,
712 closeFailed: err => `Closing failed: ${err}`,
713 sentTo: (name, text) => `to ${name}: ${text}`,
714 sendFailed: err => `Sending failed: ${err}`,
715 goOnMessage: 'Keep going.',
716
717 askAbout: (title, where) => `Briefing item: ${title}${where ? ` (${where})` : ''}. Take a look and suggest the next step. Nothing goes out without my yes.`,
718 askInfo: (section, title, extra) => `${section}: ${title}${extra ? ` (${extra})` : ''}. Take a look and suggest the next step. Nothing goes out without my yes.`,
719 followUp: (title, why) => `Draft a short follow-up message for: ${title}${why ? ` (${why})` : ''}. Draft only, do not send anything.`,
720 overviewHead: (at, headline) => `My briefing state from the dashboard (${at}): ${headline}`,
721 overviewTail: 'Sort it briefly: what comes first, what can go, what do I hand to a tab. No action without my yes.',
722 evening: 'End of day: read today\'s day block in work/log.md in full and run python3 scripts/inbox.py list. ' +
723 'Show me briefly what got done today, what was postponed and what stays open, and suggest what comes ' +
724 'first tomorrow. Then write a log row with the measured time. Nothing goes out.',
725 eveningTabs: 'Open agent tabs, say for each: wrap up, continue tomorrow, or what it is missing:',
726 eveningSince: d => ` for ${d}`,
727 eveningLast: last => ` (last: ${last})`,
728
729 cmdDesc: 'Briefing as a dashboard: who waits, what I hand off, what I work on; start tabs with a click',
730 cmdOffDesc: 'Hide the briefing band for this session (the dashboard stays available via /briefing-ui)',
731 cmdDisabled: 'briefing-ui is off: set briefing.claude_code_ui.enabled to true in bridge-config.yaml.',
732 readFailed: err => `Could not read bridge-config.yaml: ${err}`,
733 cmdOutput: id => `Briefing dashboard (briefing-ui ${id}). Without the mod, this sentence is all you see here.`,
734 bandOff: 'Briefing band hidden. /briefing-ui still opens the dashboard.',
735 morning: 'Good morning. /briefing-ui shows who is waiting and what you can hand off.',
736 scMissing: n => `${n}: does not exist`,
737 scHasTab: (n, title) => `${n} (${title}) already has a tab, switched there`,
738 scApproved: 'yes already given',
739 scWaiting: 'waiting for someone else, follow up from the card',
740 scOnlyYou: 'only you can do this, v for Advise me',
741 scResult: said => `Briefing without Claude: ${said}`,
742}
743
744/** Both tables, for the help (own switcher) and the tests */
745export const TABLES: Record<'de' | 'en', Strings> = { de, en }
746
747let current: Strings = en
748
749/** de... means German, everything else English */
750export function setLanguage(lang: string): void {
751 current = lang.toLowerCase().startsWith('de') ? de : en
752}
753
754export function T(): Strings {
755 return current
756}
757types/index.d.ts 192 lines1/** tab: in the caller's workspace · workspace: a new one per item · area: in the workspace of its area ·
2 * auto: a small model picks the area or a dedicated workspace per item (workplace.router) */
3export type Target = 'tab' | 'workspace' | 'area' | 'auto'
4/** report: read and maintain · go: get started · context: look for missing context and note it */
5export type Mode = 'report' | 'go' | 'context'
6
7export type UiConfig = {
8 enabled: boolean
9 band: boolean
10 shortcuts: boolean
11 morningHint: boolean
12 voice: boolean
13 statusSeconds: number
14 target: Target
15 surface: 'chat' | 'pane'
16 /** every how many minutes the cmux layout is saved, 0 = never */
17 snapshotMinutes: number
18 /** Name of the control workspace (workplace.control.name); only the session in it polls and reports */
19 control: string | null
20 /** UI language: briefing.claude_code_ui.language, else language.conversation, else en */
21 language: 'de' | 'en'
22 /** How old the full run may be when the dashboard opens before it runs again (full_minutes) */
23 fullMinutes: number
24}
25
26/** Mark from view.marks in the profile (customer, project): word and color live only there, never in code */
27export type Mark = { label: string; color: string | null }
28
29export type Row = {
30 key: string
31 bucket: string
32 title: string
33 why: string[]
34 sources: string[]
35 inboxId: string | null
36 gate: string | null
37 task: string | null
38 ref: string | null
39 url: string | null
40 priority: string | null
41 urgency: string | null
42 /** Area (workspace) of a task */
43 area: string | null
44 /** Name and alias names of the area, as cmux workspaces may be called */
45 areaNames: string[]
46 /** Kind of task (feature, research, ...), for the team suggestion */
47 taskType: string | null
48 /** Inbox entry with an executable action: a yes runs it; without one it needs a tab */
49 hasAction: boolean
50 /** State of the inbox entry: approved means the yes is given and the runner executes it */
51 inboxState: string | null
52 /** Key of the inbox entry; tab-<slug>-... means the task's tab filed it */
53 inboxKey: string | null
54 mark: Mark | null
55}
56
57/** One row of the status tabs, ready from collect (pages): columns When, Title, Detail, Link */
58export type Info = {
59 title: string
60 /** State or place, to the right */
61 detail: string
62 /** Time or figure up front */
63 when: string
64 tone: 'bad' | 'warn' | 'dim' | null
65 /** Web address or file:// link to a file of the bridge */
66 url: string | null
67 /** Task the row belongs to: "to the tab" or "open tab" */
68 task: string | null
69 /** Finding or issue: "ask" brings it into the conversation */
70 ask: boolean
71 mark: Mark | null
72}
73/** A section on a tab; skipped means: only in the full run. weight and bad count toward the number on the tab */
74export type InfoSection = {
75 id: string; kind: string; title: string; status: string
76 /** Reason if the section could not be read (invalid token, timeout, ...) */
77 reason: string
78 /** each row is a finding (red), empty means all green */
79 alarm: boolean
80 /** Text for an empty section */
81 empty: string
82 items: Info[]; count: number; weight: number; bad: number
83 /** From the cache (cache_minutes): when the source last answered; null = fresh */
84 asOf: string | null
85}
86/** A tab next to the briefing, as the profile describes it (view.pages) */
87export type InfoPage = { id: string; title: string; sections: InfoSection[] }
88/** briefing or the id of a tab from the profile */
89export type PageId = string
90
91export type Bucket = { id: string; title: string; rows: Row[] }
92
93export type View = {
94 headline: string
95 collectedAt: string
96 /** Time of the collection in ms, for the cache of the full version */
97 collectedMs: number
98 isFull: boolean
99 /** Time of the last full collection (tracker, calendar), for the cache */
100 fullMs: number
101 /** Sections this collection left out (quick version) */
102 skipped: string[]
103 buckets: Bucket[]
104 agenda: { when: string; title: string }[]
105 status: { title: string; count: number; isHealthy: boolean }[]
106 pages: InfoPage[]
107}
108
109export type Tab = {
110 name: string
111 workspace: string
112 ref: string
113 wsRef: string
114 state: string
115 last: string
116 /** Task the tab works on */
117 slug: string | null
118 /** Inbox entry the tab works on */
119 item: string | null
120 /** Team recipe and role, if the tab is one role of a team */
121 team: string | null
122 role: string | null
123}
124
125/** Since when a tab has been in its state, and whether it has already been reported for it */
126export type TabSeen = { state: string; since: number; isWarned: boolean }
127
128export type TeamRole = { id: string; name: string; mode: 'report' | 'go' }
129export type Team = { id: string; label: string; forTypes: string[]; roles: TeamRole[] }
130/** What the card started for a task: team, roles in order, time of the last start */
131export type TeamRun = { team: string; roles: string[]; at: number }
132
133/** now: waits for you · give: to hand off · work: your tasks · later: later */
134export type SectionId = 'now' | 'give' | 'work' | 'later'
135/** The tabs of the sidebar: everything stacked, the four blocks individually, and the tabs */
136export type SideTab = 'all' | SectionId | 'tabs'
137
138declare module 'claude-code' {
139 interface PluginState {
140 'briefing-ui': {
141 config: UiConfig | null
142 view: View | null
143 loading: string | null
144 tabs: Tab[]
145 selected: string[]
146 settled: string[]
147 expanded: string | null
148 notes: string[]
149 isBandHidden: boolean
150 messageTo: string | null
151 /** Row key -> hidden until (YYYY-MM-DD, exclusive) */
152 hidden: Record<string, string>
153 openSection: SectionId | null
154 showAll: boolean
155 showTabs: boolean
156 /** Id of the newest card in the chat; older ones draw as a single line */
157 cardId: string | null
158 isClosed: boolean
159 showHelp: boolean
160 helpLang: 'de' | 'en'
161 teams: Team[]
162 /** Task -> what the card started from the team */
163 teamOf: Record<string, TeamRun>
164 /** Tab ref -> since when in which state */
165 tabSeen: Record<string, TabSeen>
166 /** Last save of the cmux layout (time) and the list of saves, when expanded */
167 lastSnapshot: string | null
168 snapshots: string[] | null
169 /** Sidebar: selected tab, page, whether "more" under the open row is expanded */
170 sideTab: SideTab
171 sidePage: number
172 sideMore: boolean
173 page: PageId
174 /** Duration of the last quick run and second run (rest) in ms, for the loading indicator */
175 loadTook: Record<string, number>
176 /** Why the tab state is not current, and since when; null = current */
177 tabsError: string | null
178 /** This session is the control workspace (in the workspace from workplace.control): only it polls and reports.
179 * null = not yet known or without cmux (then as before: each session on its own) */
180 isControl: boolean | null
181 /** Tasks whose tab is starting right now: a second click does not start a second one */
182 starting: string[]
183 /** "release all N" pressed: the confirmation prompt is showing */
184 confirmAll: boolean
185 /** why bridge-config.yaml could not be read; null when it was read */
186 configError: string | null
187 /** collect is asked for the triage view: an earlier answer had no view */
188 askTriage: boolean
189 }
190 }
191}
192