SLOPSHOPPER

briefing-ui

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…

newpanebandrowscommandtoast
★ 12v0.24.0MITupdated 2026-10-09bks-lab/open-bridge/mods/briefing-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · briefing-ui
│ ┃ briefing-ui ✕ › fix the failing auth test and add an audit log call │ ┃ As of …⟳ ? ✕ │ ┃ Briefing 0 ⏺ Read(src/auth.ts) │ ┃ All Now 0 Yes 0 Tasks 0 Later 0 Tabs 0 ⎿ Read 6 lines │ ┃ ● 0 waiting ● 0 working ⏺ Update(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ──────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ──────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ nothing here │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · briefing-ui
As of …⟳ ? ✕ Briefing 0 All Now 0 Yes 0 Tasks 0 Later 0 Tabs 0 ● 0 waiting ● 0 working ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── nothing here
README

BKS open-bridge

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

License: MIT CI Release

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

BKS open-bridge: a coding agent answers good morning from plain markdown and YAML in git (click for the 1:25 film on YouTube)

<a id="get-started"></a>

Set it up

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.

Not ready to set anything up? Look at a running one first

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.

Who it is for

  • For people who run several clients, repos or roles with a coding agent, from a solo operator wearing several hats to a small consultancy.
  • For anyone tired of re-explaining who they are, which client they are on, and what happened yesterday, every session, in every tool.
  • If you work in one repo, a CLAUDE.md is probably enough. open-bridge pays off once you juggle several repos, clients or roles.
  • Not for people who want a hosted app. There is nothing to host; the value is files your agent reads.

What you get

Four pieces do the daily work:

PieceWhat it is
Registryecosystem.yaml: your repos, clients and workspaces in one file, read as a table of contents with one entry fetched on demand (context-index)
Task managementwork/: one folder per task with a STATUS.md, a generated board, and an append-only daily log (work-system)
Skillsone skills/ tree of plain-language verbs (/briefing, /debrief, /archive, …) that every supported tool loads (commands)
Standing ordersalways-on rules in protocols/standing-orders/, loaded every session and scoped per sub-agent

And capabilities you switch on when you need them:

CapabilityOne line
Org overlayssubscribe to your organisation's shared config by git URL, without a fork
Workloadsone file per scheduled job or daemon on your machines, checked against the live service manager
Inboxeverything that needs you waits in one place until the live source says it is done, and the briefing shows it first
Workplaceplan 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 machinea machine that stays on runs your watchers and files what it finds into the inbox while your laptop sleeps
Secretsreference URIs instead of values, and a skill that resolves them without printing them
Memorydurable facts as one file each, versioned with the rest of the instance
Tracker syncreconcile tasks with GitHub Project boards, gated, never automatic
Meeting debrieftranscript to protocol, proposed tasks and a draft recap mail
Bridge-Agentsa persistent A2A endpoint that fronts a persona to the outside world, under a human gate
Workspacesbind the repos and config overlays one engagement touches into a named container
Learning looplessons land in the skill they concern; improvement proposals wait for your review in /bridge-learn
Where things livea question in your own words on the left, the file that answers it on the right

How it compares

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.mdsession-history recallmemory-MCP serverNotion/Linear + MCPnotes vault + agentBKS open-bridge
Survives across sessionspartly: static instructionsyesyesyesyesyes
Plain files you own, diffable, no lock-inyesa local index over past sessionsusually a DB or vendor storeno, SaaSyesyes
Same context in Claude Code, Codex, Copilot CLIpartlyyes, for the tools it indexesper-tool wiringper-tool wiringdepends on the setupyes: one skills/ tree
Shipped work structure (board, log, task status)nono, it recalls, it does not organisenoyou build it yourselfnotes and daily pagesyes
Separate per-client worldsnonot its purposenomanual disciplineone personal vaultyes: one instance per client or company
Shared company layer for a teamnonot its purposenoshared workspace, SaaSnoyes: an org overlay every instance subscribes to
Safety gates written in (propose-confirm, push guard)nonot its purposenononoyes

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.

Safety

  • Propose, then confirm. The agent proposes, you decide. Changes to its own configuration go through a human gate.
  • Outward and destructive actions are gated per action. Sending a message, merging a PR, deleting, rotating a credential: each needs an explicit yes.
  • Secrets never live in the repo. Only reference URIs; the values stay in your vault, and CI checks for committed secrets (secrets).
  • The substrate itself sends nothing. No telemetry, no analytics, no hosted service. What does leave your machine is what you use or wire up: your model provider sees the session, and an overlay sync, a tracker sync or a Bridge-Agent talks to the endpoints you configure.
  • It is inspectable. 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.

A day with it

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)

How it works

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>

Status

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.

Returning?

Contributing

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.

License, trademark, acknowledgments

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.

Source 4 files
hooks/register.tsx 1830 lines
1import { 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 lines
1import 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}
510
hooks/text.ts 757 lines
1import 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}
757
types/index.d.ts 192 lines
1/** 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