SLOPSHOPPER

mokkan

Cross-session reminders: /mokkan toggles a live pane with your TODOs, Reminders and Archived tabs, /mokkan <command> runs push, in, edit, ack, done and the…

newpanetoastpromptprocesstimer
★ 4v0.7.0MITupdated 2026-10-04vicmpen/mokkan/claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mokkan
│ ┃ mokkan ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ mokkan │ ⏺ Read(src/auth.ts) │ ┃ │ TODOs 0 │ Reminders 0 │ Archived 0 │ ⎿ Read 6 lines │ ┃ │──────────────────────────────────────────│ ⏺ Update(src/auth.ts) │ ┃ │──────────── │ ⎿ Added 2 lines, removed 1 line │ ┃ │ No todos. │ ⏺ Bash(bun test) │ ┃ │ t adds one. │ ⎿ 3 pass, 1 fail │ ┃ │ │ │ ┃ │ ctrl+x tab to use keys │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ loading… │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mokkan
╭──────────────────────────────────────────────────────────╮ │ mokkan │ │ TODOs 0 │ Reminders 0 │ Archived 0 │ │────────────────────────────────────────────────────── │ │ No todos. │ │ t adds one. │ │ │ │ ctrl+x tab to use keys │ │ loading… │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰──────────────────────────────────────────────────────────╯
README

<h1><img src="assets/banner.svg" width="960" alt="mokkan"></h1>

A Claude Code mod for reminders that follow you between agent sessions. mokkan docks your reminder stack in a pane beside the Claude Code transcript, and the same list reaches every other session you open: Claude Code, Codex and any terminal, on any machine. Push a note in one session; it surfaces in the next one and stays there until you say you've seen it.

/plugin marketplace add vicmpen/mokkan
/plugin install mokkan@mokkan

Then /mokkan opens the pane, and closes it again; /mokkan push call the bank runs a command straight away. New to mods? Read Getting started with Claude Code mods.

One push. Every session.

The server (https://api.mokkan.dev) keeps the only copy of your list. Each session asks it what's due, not the model remembering to check, so a note pushed on your laptop shows up on your desktop within seconds:

  • The pane (/mokkan) in Claude Code lists it on its next refresh, every 15 seconds while it's open, and a scheduled reminder that comes due while it's open arrives as a toast. Nothing is injected into the conversation.
  • Codex checks at the start and end of every task, through the mokkan skill.
  • Any terminal has the same list: mokkan prints it, mokkan ui opens it full-screen.
mokkan push "check the flaky login test"
mokkan in 2h "ask Maria about the release notes"
mokkan                      # the list, top of the stack first
mokkan pop                  # remove the top one (dequeue removes the oldest)

Acknowledge once, from any session, and every session knows. A pop is a single atomic step on the server, so two sessions can never take the same item, and a popped item moves to done instead of disappearing.

Nothing closes until you say so.

Showing a reminder doesn't mean you saw it. mokkan keeps it lit until you acknowledge it, and a scheduled reminder that nobody acknowledges, or that comes due while no pane is open, is emailed to you. Todos (no due time) are never toasted or emailed.

StateWhat it means
scheduledmokkan in 1h30m … waits on the server, off the list, until its time comes.
dueOn the list and unseen. An open pane shows it as a toast within 15 seconds. With no pane open, it goes out by email.
deliveredShown as a toast in the pane. If no ack comes, the email goes out anyway.
acknowledgedYou've seen it. It stays on the stack until you pop, dequeue or finish it.

When is a reminder emailed?

Only a reminder with a due time is ever emailed, and at most once. An open pane tells the server that a session is active each time it refreshes.

When it comes dueWhat happens
A pane is openThe pane shows it as a toast within 15 seconds. If you haven't acknowledged it 3 minutes after the toast, it is emailed.
No pane has been open in the last 5 minutesThe server waits 5 minutes after the due time. If a pane opens in that window, it shows the toast and the case above applies. Otherwise it is emailed 5 minutes after the due time.
You acknowledge it first (pane a, mokkan ack, Codex)No email, and the credit held for it is given back.

The timings are the server's defaults. If you run your own server, they are ACK_GRACE_SECONDS (180), HEARTBEAT_ACTIVE_SECONDS (300) and NO_SESSION_EMAIL_DELAY_SECONDS (300). Codex and the terminal (mokkan, mokkan ui) read the same list; Codex checks at the start and end of each task through its skill.

The mod: a pane in Claude Code

The plugin ships a mod: a small TypeScript module that runs inside your Claude Code session, sees its events as they happen, and draws a pane. /mokkan opens it, and closes it when it is open. In the fullscreen layout (/tui fullscreen, the default in most terminals) the pane docks beside the transcript from 110 columns; on the main-screen layout it sits above the prompt. Esc hands the keyboard back and ctrl+x tab takes it again. While the pane has the keyboard, a key it doesn't use does nothing instead of landing in the prompt.

It is laid out like mokkan ui: the header with your credits, TODOs 2 │ Reminders 3 │ Archived 4, the list, the last result, the keys, and the sync state on the bottom line. The pane draws its own rounded border, pale green while it has the keyboard and grey while it doesn't. Three tabs, each with its own count: TODOs holds the open todos (no due time), Reminders the open reminders (a due time, scheduled ones included), both in stack order, and Archived the finished ones. The pane opens on Reminders while one is due, and on TODOs otherwise. A row's bar is cyan for a todo and magenta for a reminder; · marks an acknowledged one and ✓ an archived one. Rows are numbered within their tab. A reminder's time sits on the right and always says which way it points: in 40m, Wed 17:00, overdue 40m (red), 2h ago. The selected row gets its history underneath (todo · added 3h ago · shown 1h ago · acked 5m ago). ↑ ↓ and Tab walk the pane's buttons, and the row they land on is selected, marked with ▸ and bold; a click or a row's number selects it directly. Rows past the ninth are typed as two digits within a second (1 then 2 is row 12), and with ten or more rows 0 joins the keys for rows 10, 20 and so on. Selecting never changes anything. While the pane doesn't have the keyboard it shows ctrl+x tab to use keys in place of the commands:

KeyLabelAction
ttodoadd a todo: type what to remember (1 credit)
rreminderschedule a reminder: 2h call the bank, the first word is a duration (1 credit, +1 held for the email)
eeditedit the selected one's text; it can't be left empty (every 3rd edit costs 1 credit)
wwhenset its due time (2h), or clear it to make it a todo again
darchive / reopenarchive it (mokkan done); in Archived, reopen it
aackacknowledge it: you've seen it, so its email stops
vview reminders / view archived / view todosgo to the next tab, TODOs → Reminders → Archived; a click on a tab goes straight to it
bbuyopen Stripe Checkout in your browser to add credits (not on mobile)
s / l / qsync / log out / closesync now / log out / close the pane
hhelp / backshow what mokkan is, the glyphs, ack vs archive, and the costs in place of the list; h again goes back
l / rlog in / registerwhen logged out (the password is masked)

Archiving with Enter and logging out ask first, naming what they act on and what each answer does: archive "call the bank"?, with y: archive · n: keep underneath. While a command runs, other keys wait. The line above the commands shows each result with the reminder it touched (added · call mom, due in 2h · call the bank). The pane stays on its tab: a new or changed one that belongs on another tab says where it went (added to TODOs · call mom, moved to Reminders, due in 2h · renew the cert), errors start with error: in red and wrap so the fix at their end (Run: mokkan buy) stays readable, and the line clears itself after 15 seconds. The header's balance turns yellow under 10 credits (· 7 credits · low) and red at 0; b opens Stripe Checkout to add more. When the server can't be reached the pane keeps the last list and marks it offline · synced 12:04, the time of the last good sync; online the bottom line reads synced 12:04, and while a command runs it names it (archiving…).

The mod runs the plugin's own copy of the CLI and polls the server every 15 seconds while it is open and after every action. Each poll tells the server a session is active, and every reminder with a due time that has come due is shown as a toast and marked delivered. Todos are never toasted. Mods ship inside plugins, so there is nothing extra to install: the plugin's hooks/hooks.json names the module under modules, and that is all it holds.

Install

mokkan needs Node.js 20.3 or later. Choose the channel that matches the tools you use. All channels share the same account and the same local login (~/.config/mokkan/).

Claude Code (plugin)

/plugin marketplace add vicmpen/mokkan
/plugin install mokkan@mokkan

From a shell, run claude plugin marketplace add vicmpen/mokkan, then claude plugin install mokkan@mokkan.

The plugin provides:

  • /mokkan: on its own it opens the pane (see below), and closes it when it is open. With a command it runs it and prints the output straight away, without a model turn: /mokkan push call the bank, /mokkan in 2h stretch, /mokkan list. ui and watch need a terminal, and login and register are done in the pane (l, r) or a terminal.
  • The mokkan skill: Claude uses it when you ask it in words to remember something or to change a reminder. The slash-command menu lists /mokkan under the skill's full name, /mokkan:mokkan; typed, the two are the same.
  • A mod: the pane, with your stack beside the transcript and the same actions as mokkan ui (see below).

The plugin includes its own copy of the CLI, so you don't need the npm package, but node (20.3 or later) must be on your PATH: the plugin's command and pane run the CLI with it. To run register, login or buy, which ask for a password or open a browser, you need a terminal. Use npx @vicmpen/mokkan-cli … there, or install the npm package.

Terminal (npm)

npm install -g @vicmpen/mokkan-cli
mokkan help

Codex (skill)

Install the CLI with npm install -g @vicmpen/mokkan-cli, then copy or link the skill directory from this repository:

git clone https://github.com/vicmpen/mokkan
mkdir -p ~/.codex/skills
cp -r mokkan/codex ~/.codex/skills/mokkan

The npm package contains the same files under $(npm root -g)/mokkan/codex. Codex has no hooks, so the skill tells Codex to check for due reminders at the start and end of each task.

/mokkan-cli without the plugin (optional)

/mokkan is the plugin's. In sessions that don't load the plugin, the npm package gives you /mokkan-cli: copy claude-command/mokkan-cli.md from the package or from this repository to ~/.claude/commands/mokkan-cli.md. It runs mokkan from your PATH through Claude and takes the same arguments as /mokkan: /mokkan-cli push call the bank. Don't name it mokkan.md: a user-level /mokkan hides the plugin's from the slash-command menu.

Account

mokkan register you@example.com     # shows the privacy policy summary, emails you a code, then asks for a password (10+ characters)
mokkan login you@example.com
mokkan logout
mokkan status                       # who you are, server, balance
mokkan delete-account               # deletes the account and everything on it (see Privacy)

Registration and login ask for a password without showing it, so run them in a terminal. When you try them from Claude Code, the command only prints the terminal command you need. Registering shows a summary of the privacy policy first and goes on only when you answer y; the pane shows the same summary before it asks for your email.

Commands

mokkan [list] [--all]                  active list, top first (--all adds scheduled reminders)
mokkan push <text>                     add to the top
mokkan pop | dequeue                   remove from the top (LIFO) | from the bottom (FIFO)
mokkan in <duration> <text>            schedule: 30s, 10m, 2h, 1d, 1h30m
mokkan edit <n|id> [--all] [--in 2h | --at <iso> | --clear-due] [new text...]
mokkan ack <id...> | all               acknowledge reminders you have seen
mokkan done <id...> | undone <id...>   finish reminders anywhere on the list (like pop, by id) | reopen finished ones
mokkan pending | done                  due and not yet shown | finished reminders
mokkan balance | buy                   credits
mokkan feedback <text>                 send feedback to the mokkan developer (free)
mokkan ui                              full-screen view with keyboard actions (see below)
mokkan privacy [--json]                the privacy policy's version, link and summary (no login needed)
mokkan accept                          accept the current privacy policy (see Privacy)
mokkan delete-account                  delete your account, in a terminal (see Privacy)

Add --json for machine-readable output. mokkan help lists everything.

Exit codes: 0 success, 1 a mistake in the command or not logged in, 2 the server is unreachable or failing (or rate limiting), 3 not enough credits (run mokkan buy), 4 the privacy policy must be accepted first (run mokkan accept in a terminal).

Acknowledging and finishing are different acts. mokkan ack says "seen": it stops the email for a due reminder and releases its reserved credit, but the reminder stays on the list. mokkan done <id> finishes a reminder anywhere on the list, exactly as pop finishes the top one; it moves to the history that mokkan done shows. mokkan undone <id> puts a finished reminder back where it was. A reopened reminder that had a due time comes back acknowledged, so it is never emailed again. Both are free.

mokkan edit changes one reminder in place. Give it the number shown by mokkan list (with --all, the number shown by mokkan list --all) or an id prefix of at least 4 characters. The new text is the rest of the words: mokkan edit 2 --in 2h call mom at 5; it can't be empty. --at needs a full ISO-8601 time with a zone (2026-10-01T09:00:00Z). Put all your changes in one call, because every call counts as one edit. A due time can be changed only while the reminder is still scheduled or due and its email has not been sent. If the list changed since you last read it, a numbered edit stops with "The list changed" and edits nothing.

Terminal UI

mokkan ui opens a full-screen view of your stack in the terminal: the same three tabs as the pane (TODOs │ Reminders │ Archived; Reminders includes scheduled ones, and it opens on Reminders while one is due), your credit balance, and key hints. Todos (pushed, no due time) show □; reminders with a due time show ◷ scheduled, ● due (yellow), ○ shown, · acknowledged, with the time on the right (in 40m, 17:00, Wed 17:00, 12 Oct, overdue 40m in red, 40m ago). The selected row has a detail line under it (todo · added 3h ago · shown 1h ago · acked 5m ago). It refreshes every 10 seconds and after every action. The footer uses the pane's labels (t todo · r reminder · … · p pop top · o pop oldest).

KeyAction
↑ ↓, Home, End, 1–9move the selection (selecting never changes anything)
tadd a todo: type what to remember, Enter adds it (1 credit)
rschedule a reminder: 2h call the bank, the first word is a duration
eedit the selected one's text; it can't be left empty (every 3rd edit costs 1 credit)
wset its due time: a duration (turns a todo into a reminder), or clear to make it a todo again
darchive the selected one (mokkan done); in Archived, reopen it (the footer says reopen there)
a / Aacknowledge the selected one / all
v, Tabgo to the next tab: TODOs → Reminders → Archived. t, r and w leave you where you are; the message says when the item went to another tab (→ Reminders)
p / opop top / pop oldest (mokkan pop / mokkan dequeue), after a confirmation that names the reminder: y: pop n: keep
ssync now
bbuy credits (opens Stripe Checkout in your browser; if that fails, run mokkan buy --no-open for the link)
q, Ctrl-Cquit

When the privacy policy has changed, mokkan ui shows its summary and link in place of the list: y accepts it and goes back to the list, n quits, and d leaves the full screen and runs mokkan delete-account.

Due ones on the tab you are looking at count as shown: acknowledge them with a, or their email goes out after the server's grace period. When you are not logged in, mokkan ui opens on a login screen; mokkan register and mokkan logout stay terminal commands. The view needs a real terminal, so it does not work through /mokkan:mokkan. It has no mouse support, measures wide characters and emoji as well as it can, and is untested on Windows.

Credits and pricing

mokkan uses prepaid credits. €5 buys 500 credits (1 credit = 1 cent). A new account starts with 50 free credits, and credits never expire.

ActionCost
push1 credit
edit1 credit per 3 edits (every 3rd edit is charged)
Scheduled reminder email1 credit, charged only when the email is actually sent
pop, dequeue, done, undone, ack, list, pending, status, balancefree

A reminder with a due time keeps 1 credit in reserve for its email until the email is sent or you acknowledge the reminder (mokkan ack releases it). So scheduling needs the push cost plus 1 credit for every reminder that is still waiting, plus 1 for the new one. When your balance is too low, a paid command exits with code 3 and tells you to run mokkan buy. Reading and acknowledging always stay free.

mokkan buy prints a Stripe Checkout link and opens it in your browser (only https://…stripe.com links are opened). After you pay, mokkan balance shows the new balance and your recent transactions.

Uninstall

If you set up the status line that 0.2.0 offered, remove it first, because it runs mokkan: mokkan statusline --remove in a terminal (or /mokkan statusline --remove). This takes it out of settings.json and ~/.tmux.conf after backups and deletes the copy in ~/.local/share/mokkan/. Then:

  • Claude Code plugin: /plugin uninstall mokkan@mokkan.
  • npm: npm uninstall -g @vicmpen/mokkan-cli.
  • Your login stays in ~/.config/mokkan/; mokkan logout (or deleting that directory) removes it.

Privacy

The privacy policy is at mokkan.dev/privacy. In short:

  • Your email address, your reminders and your credit transactions are stored on the mokkan server (api.mokkan.dev). This lets them sync between sessions and lets scheduled reminders be emailed to you.
  • Payments go through Stripe Checkout. mokkan never sees your card details.
  • On your machine, mokkan keeps your login tokens in ~/.config/mokkan/credentials.json (mode 0600). Hook errors are logged to hook.log in the same directory.
  • MOKKAN_SERVER_URL points the CLI at a different server.

Every account accepts the current policy: when you register, and again whenever a new version is published. Until you accept a new version, commands stop with exit code 4 and say how to accept; reminders you already have are still emailed when they come due, and mokkan ack keeps working.

  • mokkan privacy prints the policy's version, link and summary (--json for the pane); it needs no login.
  • mokkan accept shows the summary and asks. In a terminal, any command that meets the new policy asks the same way: y accepts and runs the command, n quits, d deletes your account instead. Without a terminal it needs --yes, and --version <v> accepts only that version. Only you accept it: Claude (/mokkan) and Codex are told never to run accept and to send you to a terminal (mokkan accept, or npx @vicmpen/mokkan-cli accept if mokkan isn't installed).
  • mokkan delete-account, in a terminal, deletes your account and everything on it: your reminders, history, sessions and credits. It says what goes (This deletes you@example.com, its 12 reminders and 42 unspent credits.), then asks for your password and for the word delete. Unspent credits are lost. Payment records are kept without any link to you, for accounting. It works whether or not you have accepted the current policy, and it removes credentials.json and hook.log from ~/.config/mokkan/. It does not run from the pane, Claude or Codex.

Why "mokkan"

*Mokkan* (木簡) are the thin wooden slips that clerks in 7th- and 8th-century Japan used for notes, labels and records. When a slip had done its job, they shaved the surface clean and wrote the next note on the same wood.

The terminal has kept that habit for fifty years: small plain-text tools that do one job and stay out of the way. mokkan is a slip for the age of agents. One line of text, an id in square brackets, and gone when you're done with it.

Support

Contact and support: <info@mokkan.dev>. You can also open an issue at https://github.com/vicmpen/mokkan/issues.

License

MIT. See LICENSE.


Development

npm ci
npm test               # vitest
npm run typecheck
npm run build          # tsc -> dist/ (the npm package's bin)
npm run bundle         # esbuild -> claude-plugin/scripts/mokkan.mjs (commit it)
npm run check:bundle   # fails if the committed bundle is stale (also a test)
  • src/ holds the mokkan CLI, written in TypeScript with no runtime dependencies.
  • claude-plugin/ is the Claude Code plugin, and .claude-plugin/marketplace.json makes this repository its marketplace.
  • hooks/pane.tsx is the mod: a hooks module exporting register(on, options), named under modules in hooks/hooks.json. It answers /mokkan: the engine keeps that bare name for the plugin's skill and runs a typed /mokkan as /mokkan:mokkan, so the mod registers no command and hooks command.run on the skill's name instead (and on a user-level /mokkan, should one exist). It hooks session.start, command.run, ui.render, ui.focus and prompt.edit (to drop a key that leaves the focused pane), keeps its values in $.state under the contract in types/index.d.ts, and runs the CLI through $.process.run. npm run test:plugin runs claude plugin validate and claude plugin test on it (tests/pane.test.tsx); vitest does not load it. While developing, claude --plugin-dir claude-plugin loads it with hot reloading: every save reloads the module in place.
  • skills/mokkan/SKILL.md is the skill. A typed /mokkan never reaches its text (the mod answers it), so it runs only when Claude invokes it. It runs node "${CLAUDE_PLUGIN_ROOT}/scripts/mokkan.mjs" through ! bash expansion (quoted, so a plugin path with spaces works; the allowed-tools rule carries the same quotes).
  • scripts/mokkan.mjs is the bundled CLI. It is committed because plugins are installed straight from git.
  • Check the plugin with claude plugin validate . and claude plugin validate claude-plugin.
  • claude-command/mokkan-cli.md is the skill's command as a user-level /mokkan-cli that runs mokkan from PATH, for sessions without the plugin. A test checks that its instructions match the plugin skill's.
  • codex/SKILL.md is the Codex skill.
  • install.sh sets up a dev checkout. It runs npm ci and the build, then links ~/.local/bin/mokkan, ~/.codex/skills/mokkan and ~/.claude/commands/mokkan-cli.md (removing its old ~/.claude/commands/mokkan.md link), and prints the claude --plugin-dir command.
  • It only creates or refreshes symlinks that point into this checkout. Any other file at those paths is left alone with a warning: ... is not ours; skipped line.
  • MOKKAN_INSTALL_SKIP_BUILD=1 skips the install and build steps.
  • ASSUMPTIONS.md records every decision and the reason for it.

Releases: keep version in package.json equal to the one in claude-plugin/.claude-plugin/plugin.json, because plugin users stay on a version until it changes. A test checks this. npm publish runs the build, typecheck, tests and bundle check first.

Shell q

Source 2 files
hooks/pane.tsx 921 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { MokkanMessage, MokkanMode, MokkanPolicy, MokkanReminder, MokkanTab, MokkanView } from '../types'
5
6const PANE = 'mokkan'
7const COLUMNS = 44
8const REFRESH_MS = 15_000
9const MESSAGE_MS = 15_000
10const EMPTY: MokkanView = { reminders: [], done: [], balance: null, failure: null, fetchedAt: null }
11const NORMAL: MokkanMode = { kind: 'normal' }
12const view = atom({ plugin: 'mokkan', key: 'view' } as const, EMPTY)
13const mode = atom({ plugin: 'mokkan', key: 'mode' } as const, NORMAL)
14const selected = atom({ plugin: 'mokkan', key: 'selected' } as const, null as string | null)
15const tab = atom({ plugin: 'mokkan', key: 'tab' } as const, 'todos' as MokkanTab)
16const message = atom({ plugin: 'mokkan', key: 'message' } as const, null as MokkanMessage)
17const busy = atom({ plugin: 'mokkan', key: 'busy' } as const, null as string | null)
18const syncing = atom({ plugin: 'mokkan', key: 'syncing' } as const, false)
19const help = atom({ plugin: 'mokkan', key: 'help' } as const, false)
20const privacy = atom({ plugin: 'mokkan', key: 'privacy' } as const, null as MokkanPolicy | null)
21/** The states pop and dequeue take from (`mokkan list` without `--all`): never a scheduled one. */
22const ACTIVE = new Set<MokkanReminder['state']>(['due', 'delivered', 'acknowledged'])
23/** Pale green: the pane's own border while it holds the keys (the engine's frame takes no colour from a plugin). */
24const FOCUS_BORDER = '#a8d8a8'
25const DURATION = /^(?=\d)(?:\d+d)?(?:\d+h)?(?:\d+m)?(?:\d+s)?$/
26/** The tabs in `v` order, with their labels. */
27const TABS: [MokkanTab, string][] = [['todos', 'TODOs'], ['reminders', 'Reminders'], ['archived', 'Archived']]
28const label = (t: MokkanTab) => TABS.find(([k]) => k === t)![1]
29/** The tab `v` goes to next. */
30const after = (t: MokkanTab) => TABS[(TABS.findIndex(([k]) => k === t) + 1) % TABS.length]![0]
31/** A tab's rows: a todo has no due time, a reminder has one; Archived is `mokkan done`. */
32const rowsOf = (v: MokkanView, t: MokkanTab) =>
33  t === 'archived' ? v.done : v.reminders.filter(r => (r.due_at === null) === (t === 'todos'))
34/** A reminder whose time has come and that nobody has acked yet. */
35const isDue = (r: MokkanReminder, now: number) => r.due_at !== null && r.state !== 'acknowledged' && Date.parse(r.due_at) <= now
36
37/** The newest refresh; an older one's result is dropped. */
38let seq = 0
39/** True while a CLI action runs: every key waits for it. */
40let running = false
41/** The id of the last message said. */
42let said = 0
43/** The digit just typed while rows 10+ exist, so a second one within a second makes a two-digit row: 1 then 2 is row 12. */
44let pending: { digit: number; at: number } | null = null
45/** True from the pane's opening to its first good list, which picks the tab it opens on. */
46let landing = false
47/** The poll that refreshes the pane every REFRESH_MS: running from the pane's opening to its closing. */
48let poll: Timer | null = null
49/** The CLI's argv, looked up once per load of this module. */
50let resolved: Promise<string[]> | null = null
51/** Whether the pane held the keys at its last draw, and when a draw last saw it let them go: a key no Button binds leaves the pane and reaches the prompt around that redraw, just before or just after it. */
52let heldKeys = false
53let lostAt = 0
54/** A key this soon after the pane lost the keys is one that leaked; typing after Esc comes later. */
55const LEAK_MS = 100
56
57/** The CLI: this plugin's own bundle, then a dev checkout next to it, then `mokkan` on PATH. */
58function cli($: EngineInterface): Promise<string[]> {
59  return (resolved ??= find($))
60}
61
62async function find($: EngineInterface): Promise<string[]> {
63  const root = $.plugin.root
64  for (const path of [`${root}/scripts/mokkan.mjs`, `${root}/../dist/cli.js`]) {
65    try {
66      await $.fs.stat(path)
67      return ['node', path]
68    } catch {}
69  }
70  return ['mokkan']
71}
72
73/** Exit code 4: the account must accept an updated privacy policy before the server does anything more for it. */
74const PRIVACY_REQUIRED = 4
75
76/**
77 * `code` is the CLI's exit code: 1 a user error (logged out included), 2 the server or network, 4 the privacy policy to
78 * accept; null when it never ran. A 4 shows the acceptance view; registration, having no account yet, handles its own.
79 */
80async function run($: EngineInterface, args: string[], env?: Record<string, string>): Promise<{ ok: boolean; code: number | null; out: string }> {
81  let r: { ok: boolean; code: number | null; out: string }
82  try {
83    const { exitCode, stdout, stderr } = await $.process.run([...(await cli($)), ...args, '--json'], { timeoutMs: 15_000, env })
84    r = { ok: exitCode === 0, code: exitCode, out: exitCode === 0 ? stdout : (stderr || stdout).trim() }
85  } catch (err) {
86    return { ok: false, code: null, out: err instanceof Error ? err.message : String(err) }
87  }
88  if (r.code === PRIVACY_REQUIRED && args[0] !== 'register') await gate($)
89  return r
90}
91
92/** `mokkan privacy --json`: needs no login; null, said on the status line, when it fails. */
93async function policy($: EngineInterface): Promise<MokkanPolicy | null> {
94  const r = await run($, ['privacy'])
95  const p = r.ok ? json<MokkanPolicy>(r.out) : null
96  if (typeof p?.version === 'string' && typeof p.url === 'string' && Array.isArray(p.summary)) return p
97  await say($, firstLine(r.out) || 'could not fetch the privacy policy', 'error')
98  return null
99}
100
101/** The acceptance view in place of the list, with the policy as it is now; whatever was being typed or asked is dropped. */
102async function gate($: EngineInterface): Promise<void> {
103  const p = await policy($)
104  if (!p) return
105  await update($, privacy, () => p)
106  await update($, mode, () => NORMAL)
107  await update($, help, () => false)
108}
109
110/** `/mokkan <words>`: the CLI's own text, as the slash command shows it (`--exit-zero` puts its errors on stdout too). */
111async function runLine($: EngineInterface, line: string): Promise<string> {
112  try {
113    const { stdout } = await $.process.run([...(await cli($)), '--argline', line, '--exit-zero'], { timeoutMs: 15_000 })
114    return stdout.trim() || 'No output from mokkan.'
115  } catch (err) {
116    return `mokkan failed: ${err instanceof Error ? err.message : String(err)}`
117  }
118}
119
120/** Verbs that need a terminal of their own: `/mokkan` says where to run them instead. */
121const TERMINAL_ONLY: Record<string, string> = {
122  ui: 'mokkan ui is a full-screen view: run it in a terminal. Here, /mokkan alone opens the pane.',
123  watch: 'mokkan watch runs until stopped: run it in a terminal.',
124  login: 'Log in from the pane (/mokkan, then l), or run mokkan login in a terminal.',
125  register: 'Register from the pane (/mokkan, then r), or run mokkan register in a terminal.',
126}
127
128const firstLine = (out: string) => out.split('\n')[0] ?? ''
129const json = <T,>(out: string): T | null => {
130  try {
131    return JSON.parse(out) as T
132  } catch {
133    return null
134  }
135}
136const pick = (r: MokkanReminder): MokkanReminder => ({
137  id: r.id, text: r.text, state: r.state, due_at: r.due_at,
138  created_at: r.created_at, delivered_at: r.delivered_at ?? null, acknowledged_at: r.acknowledged_at ?? null, done_at: r.done_at ?? null,
139})
140
141async function refresh($: EngineInterface): Promise<void> {
142  const mine = ++seq
143  await update($, syncing, () => true)
144  try {
145    // One process for the whole look: the list, the history, the balance and a heartbeat (which makes an open pane
146    // an active session: the server then waits for it to show what comes due), and delivering what is due.
147    const sync = await run($, ['sync', '--source', 'claude-code-pane', '--deliver'])
148    if (mine !== seq) return
149    const got = sync.ok ? json<{ reminders: MokkanReminder[]; done: MokkanReminder[] | null; balance: number | null; delivered: string[] | null }>(sync.out) : null
150    // The policy to accept (run showed it): the account and its list stand, so the cached list stays for after.
151    if (sync.code === PRIVACY_REQUIRED) return
152    const before = await read($, view)
153    let next: MokkanView = got
154      ? {
155          reminders: got.reminders.map(pick),
156          done: got.done?.map(pick) ?? before.done,
157          balance: got.balance ?? before.balance,
158          failure: null,
159          fetchedAt: await $.clock.now(),
160        }
161      : sync.code === 1 // a user error on a bare sync: not logged in, or the session expired
162        ? { ...EMPTY, failure: { kind: 'loggedOut', text: firstLine(sync.out) } }
163        : { ...before, failure: { kind: 'offline', text: firstLine(sync.out) || 'mokkan failed' } }
164    // A timed reminder that is due has been shown nowhere: toast it, and sync marked it delivered, so the server emails
165    // it only if it isn't acked within its grace period. A todo is due the moment it is pushed: never.
166    if (got) {
167      for (const r of next.reminders) if (r.due_at !== null && r.state === 'due') $.ui.toast(`mokkan: due · ${r.text}`, { timeoutMs: MESSAGE_MS })
168      const delivered = got.delivered ?? []
169      if (delivered.length > 0) {
170        const at = new Date(await $.clock.now()).toISOString()
171        next = { ...next, reminders: next.reminders.map(r => (delivered.includes(r.id) ? { ...r, state: 'delivered', delivered_at: at } : r)) }
172      }
173    }
174    await update($, view, () => next)
175    if (got || next.failure?.kind === 'loggedOut') await update($, privacy, () => null)
176    // The last good list paints the pane at once the next time it opens, in this session or another.
177    if (got) await $.store.set('view', next)
178    else if (next.failure?.kind === 'loggedOut') await $.store.delete('view')
179    // It opens on Reminders while one is due, else on TODOs.
180    if (got && landing) {
181      landing = false
182      await land($, next)
183    }
184    // A row that left the tab (archived, reopened, or moved by w) takes the selection with it.
185    const [sel, tb] = await Promise.all([read($, selected), read($, tab)])
186    if (sel !== null && !rowsOf(next, tb).some(r => r.id === sel)) await update($, selected, () => null)
187  } finally {
188    if (mine === seq) await update($, syncing, () => false)
189  }
190}
191
192async function land($: EngineInterface, v: MokkanView): Promise<void> {
193  const now = await $.clock.now()
194  await update($, tab, () => (v.reminders.some(r => isDue(r, now)) ? 'reminders' : 'todos'))
195}
196
197/** Before the first look of this session: the last good list from `$.store`, which every session shares. */
198async function paintCached($: EngineInterface): Promise<void> {
199  const v = await read($, view)
200  if (v.fetchedAt !== null || v.failure !== null) return
201  const cached = (await $.store.get('view')) as MokkanView | undefined
202  if (!Array.isArray(cached?.reminders) || !Array.isArray(cached.done)) return
203  await update($, view, () => cached)
204  landing = false
205  await land($, cached)
206}
207
208/** Refreshes every REFRESH_MS while the pane is open; `ui.close` stops it. */
209function startPoll($: EngineInterface): void {
210  poll ??= $.clock.every(REFRESH_MS, () => void refresh($))
211}
212
213async function say($: EngineInterface, text: string, tone: NonNullable<MokkanMessage>['tone'] = 'ok'): Promise<void> {
214  const id = ++said
215  await update($, message, (): MokkanMessage => ({ text: tone === 'error' ? `error: ${text}` : text, tone, id }))
216  $.clock.after(MESSAGE_MS, () => update($, message, m => (m?.id === id ? null : m)))
217}
218
219/** Runs one CLI call at a time: the header shows `verb` until it returns, and keys pressed meanwhile are dropped. */
220async function work<T>($: EngineInterface, verb: string, f: () => Promise<T>): Promise<T | undefined> {
221  if (running) return undefined
222  running = true
223  try {
224    await update($, busy, () => verb)
225    return await f()
226  } finally {
227    running = false
228    await update($, busy, () => null)
229  }
230}
231
232/** Runs a mutating command, reports on the status line, and refreshes the pane; true when it worked. */
233async function act($: EngineInterface, verb: string, args: string[], done: string, first?: () => Promise<unknown>): Promise<boolean> {
234  const ok = await work($, verb, async () => {
235    await first?.()
236    const r = await run($, args)
237    // A 4 has shown the acceptance view, which says it all; a refresh would only ask again.
238    if (r.code === PRIVACY_REQUIRED) return null
239    await say($, r.ok ? done : firstLine(r.out) || 'failed', r.ok ? 'ok' : 'error')
240    return r.ok
241  })
242  if (ok !== undefined && ok !== null) void refresh($)
243  return ok === true
244}
245
246async function open($: EngineInterface, asked: boolean): Promise<void> {
247  await $.ui.open(asked ? { id: PANE, title: 'mokkan', columns: COLUMNS, focus: true } : { id: PANE, title: 'mokkan', columns: COLUMNS })
248  landing = true
249  await paintCached($)
250  startPoll($)
251  void refresh($)
252}
253
254const reveal = ($: EngineInterface, id: string) => $.ui.scroll({ in: PANE, to: { key: `row-${id}` } }).catch(() => {})
255
256
257/** Display width: wide and emoji graphemes take 2 cells, East-Asian-ambiguous ones 1. */
258const JOINS = /[\p{M}\u200D\uFE00-\uFE0F\u{1F3FB}-\u{1F3FF}\u{E0020}-\u{E007F}]/u
259const FLAG = /\p{Regional_Indicator}/u
260function graphemes(text: string): string[] {
261  const out: string[] = []
262  for (const ch of text) {
263    const last = out[out.length - 1]
264    if (last !== undefined && (JOINS.test(ch) || last.endsWith('\u200D') || (FLAG.test(ch) && FLAG.test(last) && [...last].length === 1))) out[out.length - 1] = last + ch
265    else out.push(ch)
266  }
267  return out
268}
269function cells(g: string): number {
270  if (/\p{Emoji_Presentation}|\uFE0F|\p{Regional_Indicator}/u.test(g)) return 2
271  const c = g.codePointAt(0) ?? 0
272  const wide = (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf && c !== 0x303f) || (c >= 0xac00 && c <= 0xd7a3)
273    || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x20000 && c <= 0x3fffd)
274  return wide ? 2 : 1
275}
276function fit(text: string, width: number): string {
277  const gs = graphemes(text)
278  if (gs.reduce((n, g) => n + cells(g), 0) <= width) return text
279  let out = ''
280  let used = 0
281  for (const g of gs) {
282    if (used + cells(g) > width - 1) break
283    out += g
284    used += cells(g)
285  }
286  return `${out}…`
287}
288/** Wraps to `first` cells on the first line and `rest` on the others, at the last space; a word wider than its line is cut. */
289function wrap(text: string, first: number, rest: number): string[] {
290  const lines: string[] = []
291  let line: string[] = []
292  let used = 0
293  for (const g of graphemes(text)) {
294    if (used + cells(g) > (lines.length === 0 ? first : rest) && line.length > 0) {
295      const space = g === ' ' ? line.length : line.lastIndexOf(' ')
296      const carried = space > 0 ? line.splice(space).slice(1) : []
297      lines.push(line.join(''))
298      line = carried
299      used = carried.reduce((n, c) => n + cells(c), 0)
300      if (g === ' ') continue
301    }
302    line.push(g)
303    used += cells(g)
304  }
305  return [...lines, line.join('')]
306}
307
308const span = (ms: number) => {
309  const s = Math.max(0, Math.floor(ms / 1000))
310  return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86_400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86_400)}d`
311}
312const ago = (iso: string | null, now: number) => (iso ? ` ${span(now - Date.parse(iso))} ago` : '')
313const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
314const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
315const hm = (d: Date) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
316
317/** The time column, reminders only: when it fires, how overdue it is, or how long ago it was shown. */
318function when(r: MokkanReminder, now: number): string {
319  if (r.due_at === null || r.state === 'done') return ''
320  const at = Date.parse(r.due_at)
321  const d = new Date(at)
322  if (at <= now) return r.state === 'due' || r.state === 'scheduled' ? `overdue ${span(now - at)}` : `${span(now - at)} ago`
323  if (at - now < 3_600_000) return `in ${Math.ceil((at - now) / 60_000)}m`
324  if (d.toDateString() === new Date(now).toDateString()) return hm(d)
325  if (at - now < 7 * 86_400_000) return `${DAYS[d.getDay()]} ${hm(d)}`
326  return `${d.getDate()} ${MONTHS[d.getMonth()]}`
327}
328
329/** The mark's color is the kind (cyan a todo, magenta a reminder); its shape `·` once acked, `✓` once done, a bar before. */
330type Kind = 'cyan' | 'magenta'
331const kind = (r: MokkanReminder): Kind => (r.due_at === null ? 'cyan' : 'magenta')
332const mark = (r: MokkanReminder): [string, boolean] =>
333  r.state === 'done' ? ['✓', true] : r.state === 'acknowledged' ? ['·', true] : ['▎', false]
334
335/** The selected row's history, `todo · added 3h ago · shown 1h ago · acked 5m ago`, in lines of `width` broken between parts. */
336function detail(r: MokkanReminder, now: number, width: number): string[] {
337  const parts = r.state === 'done'
338    ? [`archived${ago(r.done_at, now)}`, `added${ago(r.created_at, now)}`]
339    : [r.due_at === null ? 'todo' : 'reminder', `added${ago(r.created_at, now)}`, r.delivered_at && `shown${ago(r.delivered_at, now)}`, r.acknowledged_at && `acked${ago(r.acknowledged_at, now)}`]
340  const lines: string[] = []
341  for (const part of parts.filter((x): x is string => Boolean(x))) {
342    const last = lines[lines.length - 1]
343    if (last !== undefined && [...`${last} · ${part}`].length <= width) lines[lines.length - 1] = `${last} · ${part}`
344    else lines.push(part)
345  }
346  return lines
347}
348
349const FIELD: Record<Extract<MokkanMode, { kind: 'input' }>['purpose'], { label: string; hint: string; submit: string }> = {
350  push: { label: 'new todo', hint: 'what to remember · 1 credit', submit: 'add' },
351  in: { label: 'new reminder', hint: '2h call the bank · 1 credit, +1 held for the email', submit: 'schedule' },
352  edit: { label: 'edit', hint: 'new text · every 3rd edit costs 1 credit', submit: 'save' },
353  time: { label: 'due in', hint: '2h, or clear to make it a todo', submit: 'set' },
354  feedback: { label: 'feedback', hint: 'to the mokkan developer · free · shift+enter for a new line', submit: 'send' },
355}
356
357/** The help view (`h`): what mokkan is, the glyphs, the acts that are easy to mix up, and the costs. */
358const HELP_INTRO = 'A stack of todos and reminders that every Claude Code, Codex and terminal session shares.'
359const HELP_GLYPHS: [string, Kind | 'dim', string][] = [
360  ['▎', 'cyan', 'todo: a note with no time'],
361  ['▎', 'magenta', 'reminder: a note with a due time'],
362  ['·', 'dim', 'acked'],
363  ['✓', 'dim', 'archived'],
364]
365const HELP_TEXT = [
366  'A due reminder nobody acks is emailed to you. Ack (a) says you\'ve seen it: the email stops and it stays on the stack.',
367  'TODOs and Reminders each list their own kind; v cycles the tabs. w moves a row between them: a time makes it a reminder, clear makes it a todo.',
368  'Archive (d) finishes the selected one, as Enter on it does after asking. Finished ones move to Archived, where d reopens them.',
369  '↑↓, Tab or a row\'s number select it: type 1 then 2 for row 12. ctrl+x tab gives the pane the keys; Esc gives them back.',
370  'A todo costs 1 credit. A reminder costs 1, plus 1 held for its email and given back if you ack it first. Every 3rd edit costs 1; the rest is free.',
371  'b opens Stripe Checkout to add credits. In a terminal, mokkan ui opens this full screen.',
372  'f sends feedback to the mokkan developer, free; shift+enter starts a new line.',
373  'Privacy policy: mokkan.dev/privacy. To delete your account and everything on it, run mokkan delete-account in a terminal (or npx @vicmpen/mokkan-cli delete-account).',
374]
375/** Under the acceptance view: the pane can't delete an account, so it names the terminal command, both ways (the pane's own CLI is never the one on PATH). */
376const DELETE_INSTEAD = 'delete instead: in a terminal run mokkan delete-account (or npx @vicmpen/mokkan-cli delete-account)'
377
378/** `group`: the key hints start a new line before it, one line per kind of key. */
379type Action = { label: string; hotkey: string; needsField?: true; group?: true; run: () => unknown }
380
381export const register: Register = on => {
382  // The password as typed, in this module alone: never in $.state, never drawn, cleared after each attempt.
383  let secret = ''
384
385  // A hot reload cancels the poll but leaves the pane open: start it again.
386  on('session.start', async ($, e, next) => {
387    if ((await $.ui.panes()).some(p => p.id === PANE)) startPoll($)
388    return next(e)
389  })
390
391  // Closed by /mokkan, by the person or by an unload: nothing polls, and no key is taken for it.
392  on('ui.close', { id: PANE }, ($, e, next) => {
393    poll?.cancel()
394    poll = null
395    heldKeys = false
396    lostAt = 0
397    return next(e)
398  })
399
400  // No session.start follows a /clear, /resume or /branch: an open pane reloads here instead of on the next tick.
401  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
402    if ((await $.ui.panes()).some(p => p.id === PANE)) void refresh($)
403    return next(e)
404  })
405
406  // `/mokkan` alone toggles the pane; `/mokkan <verb> …` runs the CLI and shows its output, with no model turn.
407  // The engine keeps the bare `/mokkan` for this plugin's skill, so typing it runs `mokkan:mokkan`: the hook answers that
408  // (and a user-level `/mokkan`, should one exist). Claude still uses the skill when asked in words.
409  on('command.run', { command: ['mokkan:mokkan', 'mokkan'] }, async ($, e) => {
410    const line = e.args.trim()
411    const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
412    if (line) {
413      const verb = line.split(/\s+/)[0]!
414      if (TERMINAL_ONLY[verb]) return { text: TERMINAL_ONLY[verb] }
415      const text = await runLine($, line)
416      if (isOpen) void refresh($)
417      return { text }
418    }
419    if (isOpen) {
420      await $.ui.close({ id: PANE })
421      return { text: 'mokkan pane closed.' }
422    }
423    await open($, true)
424    const { isFullscreen, columns } = e.presentation
425    const where = isFullscreen ? (columns >= 110 ? 'docked beside the transcript' : `inline: the fullscreen layout docks from 110 columns, this terminal is ${columns}`) : 'inline: the main-screen layout never docks a pane'
426    return { text: `mokkan pane opened, ${where}. Esc hands the keys back; ctrl+x tab takes them again. /mokkan again closes it.` }
427  })
428
429  // The engine's focus ring walks the pane (↑/↓, Tab, clicks); a row it lands on becomes the ▸ selection too.
430  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
431    const el = e.element
432    if (el?.startsWith('row-')) {
433      await update($, selected, () => el.slice(4))
434      void reveal($, el.slice(4))
435    }
436    return next(e)
437  })
438
439  // A key no Button binds leaves the pane for the prompt: one typed while the pane held the keys is dropped, and the pane takes them back.
440  // ctrl/cmd combinations and pastes go on; Esc never reaches here.
441  on('prompt.edit', async ($, e, next) => {
442    // Most keys never had the pane hold them: they reach the prompt with no call to the engine.
443    if (!heldKeys && lostAt === 0) return next(e)
444    if (e.key === undefined || e.key.ctrl || e.key.meta || [...e.inputText].length !== 1) return next(e)
445    const [panes, now] = await Promise.all([$.ui.panes(), $.clock.now()])
446    const leaked = panes.some(p => p.id === PANE) && (heldKeys || (lostAt > 0 && now - lostAt < LEAK_MS))
447    if (!leaked) return next(e)
448    void $.ui.open({ id: PANE, title: 'mokkan', columns: COLUMNS, focus: true })
449    return { text: e.text, cursor: e.cursor }
450  })
451
452  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
453    if (e.surface === 'terminal') {
454      const holds = e.props.isFocused !== false
455      if (heldKeys && !holds) lostAt = await $.clock.now()
456      heldKeys = holds
457    }
458    const t = $.ui.resolve(e)
459    const { Box, Text, Button } = t
460    const Input = e.surface !== 'mobile' && 'Input' in t ? t.Input : null // mobile has no Input
461    const [v, m, sel, tb, msg, verb, sync, helping, gated, now] = await Promise.all([
462      read($, view), read($, mode), read($, selected), read($, tab), read($, message), read($, busy), read($, syncing), read($, help), read($, privacy), $.clock.now(),
463    ])
464    // The terminal gets the pane's own border, two columns of the body; the remote surfaces draw their own focus.
465    const framed = e.surface === 'terminal'
466    const width = (e.props.bodyColumns ?? e.viewport?.columns ?? COLUMNS) - (framed ? 2 : 0)
467    const docked = e.props.placement === 'dock'
468    const bodyRows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 24
469    const focused = e.surface !== 'terminal' || e.props.isFocused !== false
470    const loggedOut = v.failure?.kind === 'loggedOut'
471    const offline = v.failure?.kind === 'offline'
472    const list = rowsOf(v, tb)
473    const current = list.find(r => r.id === sel)
474
475    /** Every key goes through here: dropped while a CLI call runs; it clears the message said before it. */
476    const go = <A extends unknown[]>(f: (...a: A) => unknown) => (...a: A) => {
477      if (running) return
478      const before = said
479      void update($, message, was => (was && was.id <= before ? null : was))
480      return f(...a)
481    }
482    const needOne = () => say($, 'select a row first (its number or ↑↓)', 'note')
483    const cancel = () => { secret = ''; return update($, mode, () => NORMAL) }
484    const switchView = async (to?: MokkanTab) => {
485      await update($, tab, was => to ?? after(was))
486      await update($, selected, () => null)
487    }
488    // The ring follows a click or a digit too, so ↑/↓ and ←/→ walk on from the selected row.
489    const select = async (r: MokkanReminder) => {
490      await update($, selected, () => r.id)
491      void reveal($, r.id)
492      void $.ui.focus({ requestId: PANE, key: `row-${r.id}` }).catch(() => {})
493    }
494    const askDone = (r: MokkanReminder) =>
495      update($, mode, (): MokkanMode => ({ kind: 'confirm', action: r.state === 'done' ? 'undone' : 'done', target: r.text, targetId: r.id }))
496    /** A press on the row already selected (Enter under the focus, a click, its digit) asks to finish it, or reopen it. */
497    const press = (r: MokkanReminder) => (r.id === sel ? askDone(r) : select(r))
498    /** A digit key selects its row at once; within a second of the one before, the two make rows 10–99. */
499    const typed = async (d: number) => {
500      const at = await $.clock.now()
501      const was = pending
502      pending = null
503      const two = was && at - was.at < 1000 ? list[was.digit * 10 + d - 1] : undefined
504      if (two) return select(two)
505      if (d === 0) return say($, 'type 1 then 0 for row 10', 'note')
506      const r = list[d - 1]
507      if (!r) return
508      if (list.length < d * 10) return press(r)
509      const mine = pending = { digit: d, at }
510      // The selected row's own digit asks only once no second digit follows it.
511      if (r.id === sel) $.clock.after(1000, async () => {
512        if (pending !== mine || running) return
513        pending = null
514        const [still, md] = await Promise.all([read($, selected), read($, mode)])
515        if (still === r.id && md.kind === 'normal') await askDone(r)
516      })
517      return select(r)
518    }
519    const toggleDone = (id: string, text: string, reopen: boolean, first?: () => Promise<unknown>) => reopen
520      ? act($, 'reopening…', ['undone', id], `reopened · ${text}`, first)
521      : act($, 'archiving…', ['done', id], `archived · ${text}`, first)
522    const field = async (purpose: 'push' | 'in' | 'edit' | 'time' | 'feedback') => {
523      const needsOne = purpose === 'edit' || purpose === 'time'
524      if (needsOne && (!current || current.state === 'done')) return needOne()
525      const next: MokkanMode = { kind: 'input', purpose, targetId: current?.id, value: purpose === 'edit' ? current?.text ?? '' : '' }
526      await update($, mode, () => next)
527      void $.ui.focus({ requestId: PANE, key: 'field' }).catch(() => {})
528    }
529    const confirm = async (action: 'pop' | 'dequeue' | 'logout') => {
530      if (action === 'logout') {
531        const r = await work($, 'checking account…', () => run($, ['status']))
532        if (r === undefined) return
533        const email = r.ok ? json<{ me?: { email?: string } }>(r.out)?.me?.email : undefined
534        return update($, mode, (): MokkanMode => ({ kind: 'confirm', action, target: email ?? '' }))
535      }
536      const takes = v.reminders.filter(r => ACTIVE.has(r.state))
537      const target = action === 'pop' ? takes[0] : takes[takes.length - 1]
538      if (!target) return say($, 'the stack is empty', 'note')
539      return update($, mode, (): MokkanMode => ({ kind: 'confirm', action, target: target.text }))
540    }
541    // Registration shows the policy's summary first, as `mokkan register` does in a terminal.
542    const startAuth = async (flow: 'login' | 'register') => {
543      secret = ''
544      const p = flow === 'register' ? await work($, 'fetching the privacy policy…', () => policy($)) : null
545      if (flow === 'register' && !p) return
546      await update($, mode, (): MokkanMode => ({ kind: 'auth', flow, step: p ? 'policy' : 'email', email: '', otp: '', masked: '', policy: p ?? null }))
547      if (!p) void $.ui.focus({ requestId: PANE, key: 'auth' }).catch(() => {})
548    }
549    /** y on the summary: on to the email, or back to the password when a changed policy was shown again after the code. */
550    const continueAuth = async () => {
551      await update($, mode, (was): MokkanMode => (was.kind === 'auth' ? { ...was, step: was.otp ? 'password' : 'email' } : was))
552      void $.ui.focus({ requestId: PANE, key: 'auth' }).catch(() => {})
553    }
554    const accept = async (p: MokkanPolicy) => {
555      const accepted = await work($, 'accepting…', async () => {
556        const r = await run($, ['accept', '--yes', '--version', p.version])
557        // 4: the policy changed while it was shown; run has shown the new one.
558        if (r.code === PRIVACY_REQUIRED) return false
559        if (!r.ok) {
560          await say($, firstLine(r.out) || 'failed', 'error')
561          return false
562        }
563        await update($, privacy, () => null)
564        await say($, 'privacy policy accepted')
565        return true
566      })
567      if (accepted) await refresh($)
568    }
569
570    const toggleHelp = () => update($, help, was => !was)
571    // As `mokkan ui`'s b: the CLI opens the checkout itself, and only a Stripe address.
572    const buy = () => work($, 'opening checkout…', async () => {
573      const r = await run($, ['buy'])
574      if (r.code === PRIVACY_REQUIRED) return
575      if (!r.ok) return say($, firstLine(r.out) || 'failed', 'error')
576      await say($, json<{ opened?: boolean }>(r.out)?.opened === true
577        ? 'Opened Stripe Checkout in your browser; the balance updates after payment.'
578        : 'Could not open a browser here. Run: mokkan buy --no-open (prints the link).')
579    })
580    const keys: Record<string, Action> = gated
581      ? {
582          accept: { label: 'accept', hotkey: 'y', run: () => accept(gated) },
583          close: { label: 'close', hotkey: 'n', run: () => $.ui.close({ id: PANE }) },
584        }
585      : helping
586      ? {
587          help: { label: 'back', hotkey: 'h', run: toggleHelp },
588          close: { label: 'close', hotkey: 'q', run: () => $.ui.close({ id: PANE }) },
589        }
590      : loggedOut
591      ? {
592          login: { label: 'log in', hotkey: 'l', needsField: true, run: () => startAuth('login') },
593          register: { label: 'register', hotkey: 'r', needsField: true, run: () => startAuth('register') },
594          refresh: { label: 'sync', hotkey: 's', run: () => refresh($) },
595          help: { label: 'help', hotkey: 'h', run: toggleHelp },
596          close: { label: 'close', hotkey: 'q', run: () => $.ui.close({ id: PANE }) },
597        }
598      // Each key is its label's first letter; a line per kind: add, the selected row, the stack, the rest.
599      : {
600          push: { label: 'todo', hotkey: 't', needsField: true, run: () => field('push') },
601          in: { label: 'reminder', hotkey: 'r', needsField: true, run: () => field('in') },
602          edit: { label: 'edit', hotkey: 'e', needsField: true, group: true, run: () => field('edit') },
603          time: { label: 'when', hotkey: 'w', needsField: true, run: () => field('time') },
604          done: { label: tb === 'archived' ? 'reopen' : 'archive', hotkey: 'd', run: () => (current ? toggleDone(current.id, current.text, current.state === 'done') : needOne()) },
605          ack: { label: 'ack', hotkey: 'a', run: () => (current && current.state !== 'done' ? act($, 'acking…', ['ack', current.id], `acked · ${current.text}`) : needOne()) },
606          // Pop and dequeue are off for now.
607          // pop: { label: 'pop top', hotkey: 'p', group: true, run: () => confirm('pop') },
608          // dequeue: { label: 'pop oldest', hotkey: 'o', run: () => confirm('dequeue') },
609          view: { label: `view ${label(after(tb)).toLowerCase()}`, hotkey: 'v', group: true, run: () => switchView() },
610          refresh: { label: 'sync', hotkey: 's', run: () => refresh($) },
611          // The checkout opens on the machine running Claude Code: of no use to someone on a phone.
612          ...(e.surface !== 'mobile' ? { buy: { label: 'buy', hotkey: 'b', run: () => buy() } } : {}),
613          logout: { label: 'log out', hotkey: 'l', run: () => confirm('logout') },
614          ...(list.length >= 10 ? { zero: { label: '10, 20…', hotkey: '0', run: () => typed(0) } } : {}),
615          feedback: { label: 'feedback', hotkey: 'f', needsField: true, run: () => field('feedback') },
616          help: { label: 'help', hotkey: 'h', run: toggleHelp },
617          close: { label: 'close', hotkey: 'q', run: () => $.ui.close({ id: PANE }) },
618        }
619    /** The key hints as `mokkan ui` draws its footer: `t: todo · r: reminder · …`, a line per group, wrapped to the width. */
620    const hints = () => {
621      const offered = Object.entries(keys).filter(([, a]) => !a.needsField || Input)
622      const lines: [string, Action][][] = [[]]
623      let used = 0
624      for (const entry of offered) {
625        const w = entry[1].hotkey.length + 2 + entry[1].label.length
626        if (used > 0 && (entry[1].group || used + 3 + w > width - 1)) { lines.push([]); used = 0 }
627        used += (used > 0 ? 3 : 0) + w
628        lines[lines.length - 1]!.push(entry)
629      }
630      return (
631        <Box key="legend" flexDirection="column">
632          {lines.map((line, i) => (
633            <Box key={`legend-${i}`}>
634              <Text> </Text>
635              {line.map(([key, a], j) => (
636                <Box key={`cell-${key}`}>
637                  {j > 0 && <Text dimColor> · </Text>}
638                  <Button key={key} hotkey={a.hotkey} plain role={key === 'close' ? 'dismiss' : undefined} onPress={go(() => a.run())}>{a.label}</Button>
639                </Box>
640              ))}
641            </Box>
642          ))}
643        </Box>
644      )
645    }
646
647    const typedSecret = (shown: string) => {
648      // The field shows bullets for what is kept; an edit is taken only at the tail (typing, pasting, backspace).
649      const had = [...secret]
650      const got = [...shown]
651      const n = Math.min(had.length, got.length)
652      const tail = got.slice(n)
653      if (!got.slice(0, n).every((c, i) => c === '•' || c === had[i]) || tail.includes('•')) {
654        secret = ''
655        void say($, 'editing inside the password erased it; type it again', 'error')
656      } else secret = had.slice(0, n).join('') + tail.join('')
657      const masked = '•'.repeat([...secret].length)
658      return update($, mode, was => (was.kind === 'auth' ? { ...was, masked } : was))
659    }
660    /** The policy changed during registration: its new summary, then on with what was typed (the code stands). */
661    const showAgain = async (was: Extract<MokkanMode, { kind: 'auth' }>) => {
662      const p = await work($, 'fetching the privacy policy…', () => policy($))
663      if (!p) return update($, mode, () => was)
664      await update($, mode, (): MokkanMode => ({ ...was, step: 'policy', policy: p }))
665      await say($, 'the privacy policy changed: read it again', 'note')
666    }
667    const authStep = async (raw: string) => {
668      if (m.kind !== 'auth') return
669      const text = raw.trim()
670      if (m.step === 'email') {
671        if (!text) return cancel()
672        if (m.flow === 'login') return update($, mode, (): MokkanMode => ({ ...m, step: 'password', email: text }))
673        const r = await work($, 'sending code…', () => run($, ['register', text, '--start', '--accept-privacy', m.policy?.version ?? '']))
674        if (!r) return
675        if (!r.ok) {
676          await update($, mode, (): MokkanMode => ({ ...m, email: raw }))
677          return say($, firstLine(r.out) || 'could not send the code', 'error')
678        }
679        await say($, `code sent to ${text}`)
680        return update($, mode, (): MokkanMode => ({ ...m, step: 'otp', email: text }))
681      }
682      if (m.step === 'otp') {
683        if (!text) return cancel()
684        return update($, mode, (): MokkanMode => ({ ...m, step: 'password', otp: text }))
685      }
686      const password = secret
687      secret = ''
688      if (!password) return cancel()
689      const retry = () => update($, mode, was => (was.kind === 'auth' ? { ...was, masked: '' } : was))
690      if (m.flow === 'register' && password.length < 10) {
691        await retry()
692        return say($, 'the password needs 10+ characters', 'error')
693      }
694      const r = await work($, m.flow === 'login' ? 'logging in…' : 'registering…', () => m.flow === 'login'
695        ? run($, ['login', m.email], { MOKKAN_PASSWORD: password })
696        : run($, ['register', '--complete', m.email, '--otp', m.otp, '--accept-privacy', m.policy?.version ?? ''], { MOKKAN_PASSWORD: password }))
697      if (!r) return
698      if (r.code === PRIVACY_REQUIRED) return showAgain({ ...m, masked: '' })
699      if (!r.ok) {
700        await retry()
701        return say($, firstLine(r.out) || `${m.flow} failed`, 'error')
702      }
703      await say($, `${m.flow === 'login' ? 'logged in' : 'registered and logged in'} as ${m.email}`)
704      await update($, mode, () => NORMAL)
705      await refresh($)
706    }
707    const submit = async (raw: string) => {
708      if (m.kind !== 'input') return
709      const text = raw.trim()
710      const keep = () => update($, mode, was => (was.kind === 'input' ? { ...was, value: raw } : was))
711      if (!text && m.purpose === 'edit') {
712        await keep()
713        return say($, 'the text can’t be empty', 'error')
714      }
715      if (!text) return cancel()
716      const id = m.targetId ?? ''
717      const was = v.reminders.find(r => r.id === id)
718      const target = was?.text ?? ''
719      const [dur = '', ...rest] = text.split(/\s+/)
720      if (m.purpose === 'in' && (!DURATION.test(dur) || rest.length === 0)) {
721        await keep()
722        return say($, 'start with a duration: 2h call the bank', 'error')
723      }
724      if (m.purpose === 'time' && text !== 'clear' && !DURATION.test(text)) {
725        await keep()
726        return say($, 'type a duration like 2h, or clear', 'error')
727      }
728      // The pane stays on its tab; a row that lands on another one says where it went.
729      const [verbing, args, done]: [string, string[], string] =
730        m.purpose === 'push' ? ['adding…', ['push', text], tb === 'todos' ? `added · ${text}` : `added to TODOs · ${text}`]
731        : m.purpose === 'in' ? ['scheduling…', ['in', dur, rest.join(' ')], `${tb === 'reminders' ? 'scheduled' : 'added to Reminders, due'} in ${dur} · ${rest.join(' ')}`]
732        : m.purpose === 'edit' ? ['saving…', ['edit', id, text], `edited · ${text}`]
733        : m.purpose === 'feedback' ? ['sending…', ['feedback', text], 'feedback sent · thank you']
734        : text === 'clear' ? ['setting due…', ['edit', id, '--clear-due'], `${was?.due_at === null ? 'now a todo' : 'moved to TODOs'} · ${target}`]
735        : ['setting due…', ['edit', id, '--in', text], `${was?.due_at === null ? 'moved to Reminders, due' : 'due'} in ${text} · ${target}`]
736      if (await act($, verbing, args, done)) await update($, mode, () => NORMAL)
737      else await keep()
738    }
739
740    const rule = <Text dimColor>{'─'.repeat(Math.max(1, width))}</Text>
741    // The clock time of the last good fetch, not its age: the pane redraws on changes, not by the second, so an age would go stale.
742    const syncedAt = v.fetchedAt === null ? '' : `synced ${hm(new Date(v.fetchedAt))}`
743    const synced = sync ? 'syncing…' : offline ? `offline${syncedAt && ` · ${syncedAt}`}` : syncedAt || 'loading…'
744    const credits = v.balance === null ? '' : ` · ${v.balance} ${Math.abs(v.balance) === 1 ? 'credit' : 'credits'}`
745    // At 0 the red count says it; a paid command's error then names the fix (`Run: mokkan buy`, or b here).
746    const balance = v.balance === null ? '' : v.balance > 0 && v.balance < 10 ? `${credits} · low` : credits
747    const wide = (text: string) => graphemes(text).reduce((n, g) => n + cells(g), 0)
748
749    return (
750      <Box flexDirection="column" minHeight={docked ? bodyRows : undefined}
751        {...(framed ? { borderStyle: 'round', borderColor: focused ? FOCUS_BORDER : 'gray', borderDimColor: !focused } : {})}>
752        {/* ` mokkan · 42 credits`, the views, a rule; the sync state sits at the bottom, clear of the engine's close mark. */}
753        <Box>
754          <Text bold> mokkan</Text>
755          {!loggedOut && <Text color={v.balance === null || v.balance >= 10 ? undefined : v.balance <= 0 ? 'red' : 'yellow'} dimColor={v.balance === null || v.balance >= 10}>{balance}</Text>}
756        </Box>
757        {helping && <Text bold> Help</Text>}
758        {gated && <Text bold> Privacy policy updated</Text>}
759        {!loggedOut && !helping && !gated && (
760          <Box>
761            <Text> </Text>
762            {TABS.map(([key, name], i) => (
763              <Box key={`view-${key}`}>
764                {i > 0 && <Text dimColor> │ </Text>}
765                <Button key={`tab-${key}`} plain dimColor={key !== tb} onPress={go(() => switchView(key))}>{`${name} ${rowsOf(v, key).length}`}</Button>
766              </Box>
767            ))}
768          </Box>
769        )}
770        {rule}
771        {gated ? (
772          <Box key="privacy" flexDirection="column" paddingLeft={1}>
773            {gated.summary.map((line, i) => <Text key={`summary-${i}`} wrap="wrap">{line}</Text>)}
774            <Text> </Text>
775            <Text wrap="wrap">{gated.url}</Text>
776            <Text> </Text>
777            <Text dimColor wrap="wrap">{DELETE_INSTEAD}</Text>
778          </Box>
779        ) : helping ? (
780          <Box key="help" flexDirection="column" paddingLeft={1}>
781            <Text wrap="wrap">{HELP_INTRO}</Text>
782            <Text> </Text>
783            {HELP_GLYPHS.map(([glyph, tone, label]) => (
784              <Box key={`glyph-${label}`}>
785                <Text color={tone === 'dim' ? undefined : tone} dimColor={tone === 'dim'}>{`${glyph} `}</Text>
786                <Text>{label}</Text>
787              </Box>
788            ))}
789            {HELP_TEXT.map((text, i) => (
790              <Box key={`help-${i}`} flexDirection="column">
791                <Text> </Text>
792                <Text wrap="wrap">{text}</Text>
793              </Box>
794            ))}
795          </Box>
796        ) : loggedOut ? (
797          <Box flexDirection="column">
798            <Text> Not logged in.</Text>
799            {m.kind !== 'auth' && <Text dimColor>{Input ? ' l logs in, r registers.' : ' Log in from a terminal: mokkan login'}</Text>}
800          </Box>
801        ) : (
802          <Box flexDirection="column">
803            {list.length === 0 && (offline && v.fetchedAt === null
804              ? <Text color="red" wrap="truncate">{` ${v.failure?.text ?? ''}`}</Text>
805              : tb === 'archived' ? (
806                <Box flexDirection="column">
807                  <Text dimColor> Nothing archived yet.</Text>
808                  <Text dimColor> d archives the selected row.</Text>
809                </Box>
810              )
811              : (
812                <Box flexDirection="column">
813                  <Text dimColor>{tb === 'todos' ? ' No todos.' : ' No reminders.'}</Text>
814                  {Input && <Text dimColor>{tb === 'todos' ? ' t adds one.' : ' r schedules one.'}</Text>}
815                </Box>
816              ))}
817            {list.map((r, i) => {
818              const n = i + 1
819              // `▸ ▎ 1: ` is 7 cells; with rows 10+ every number takes their width, ` 9: ` above `10: `.
820              const digits = String(list.length).length
821              const lead = 4 + digits + 2
822              const isSel = r.id === sel
823              const time = when(r, now)
824              const [glyph, settled] = mark(r)
825              // `▸ ▎ 1: text`, the time flush right; the selected row has the pointer and bold marks, as in `mokkan ui`.
826              const room = Math.max(4, width - lead - (time ? wide(time) + 2 : 0))
827              // The selected row wraps its whole text under the text column; the others are cut to one line.
828              const [text = '', ...more] = isSel ? wrap(r.text, room, Math.max(4, width - lead)) : [fit(r.text, room)]
829              // No gap when an untimed row's text fills the width: one more cell would wrap the row.
830              const gap = Math.max(time ? 1 : 0, width - lead - wide(text) - wide(time))
831              return (
832                <Box key={r.id} flexDirection="column">
833                  <Box>
834                    <Text bold={isSel}>{isSel ? '▸ ' : '  '}</Text>
835                    <Text bold={isSel} color={kind(r)} dimColor={settled && !isSel}>{`${glyph} `}</Text>
836                    {/* 1–9 are the Button's own hotkeys; 10+ draw their number, typed as two digits. */}
837                    {n <= 9 ? digits > 1 && <Text>{' '.repeat(digits - 1)}</Text> : <Text bold={isSel}>{`${String(n).padStart(digits)}: `}</Text>}
838                    <Button key={`row-${r.id}`} plain hotkey={n <= 9 ? String(n) : undefined} dimColor={(r.state === 'acknowledged' || r.state === 'done') && !isSel} onPress={go(() => (n <= 9 ? typed(n) : press(r)))}>
839                      {text}
840                    </Button>
841                    {gap > 0 && <Text>{' '.repeat(gap)}</Text>}
842                    {time && <Text bold={isSel} color={time.startsWith('overdue') ? 'red' : undefined} dimColor={!time.startsWith('overdue') && !isSel}>{time}</Text>}
843                  </Box>
844                  {more.map((line, j) => <Text key={`more-${j}`}>{`${' '.repeat(lead)}${line}`}</Text>)}
845                  {isSel && detail(r, now, width - lead).map((line, j) => <Text key={`detail-${j}`} dimColor>{`${' '.repeat(lead)}${fit(line, width - lead)}`}</Text>)}
846                </Box>
847              )
848            })}
849          </Box>
850        )}
851
852        {docked && <Box flexGrow={1} />}
853        {/* An error wraps, so the fix at its end (`Run: mokkan buy`) is never cut; the rest stay one line. */}
854        <Box key="status" paddingLeft={1}>
855          <Text color={msg ? { ok: 'green', note: 'yellow', error: 'red' }[msg.tone] : undefined} wrap={msg?.tone === 'error' ? 'wrap' : 'truncate-end'}>{msg?.text ?? ' '}</Text>
856        </Box>
857
858        {m.kind === 'input' && Input && (
859          <Box flexDirection="column" paddingLeft={1}>
860            <Input key="field" autoFocus label={FIELD[m.purpose].label} placeholder={FIELD[m.purpose].hint} value={m.value} submitLabel={FIELD[m.purpose].submit} onSubmit={go(submit)} />
861            <Box gap={1}>
862              <Button key="cancel" plain onPress={go(cancel)}>cancel</Button>
863              <Text dimColor>(Tab to it, then Enter)</Text>
864            </Box>
865          </Box>
866        )}
867        {m.kind === 'auth' && Input && (
868          <Box flexDirection="column" paddingLeft={1}>
869            {m.step === 'policy' && m.policy && (
870              <Box flexDirection="column">
871                {m.policy.summary.map((line, i) => <Text key={`summary-${i}`} wrap="wrap">{line}</Text>)}
872                <Text wrap="wrap">{m.policy.url}</Text>
873                <Box>
874                  <Button key="yes" hotkey="y" plain onPress={go(continueAuth)}>continue</Button>
875                  <Text dimColor> · </Text>
876                  <Button key="no" hotkey="n" plain onPress={go(cancel)}>cancel</Button>
877                </Box>
878              </Box>
879            )}
880            {m.step === 'email' && <Input key="auth" autoFocus label={`${m.flow} email`} placeholder="you@example.com" value={m.email} submitLabel={m.flow === 'login' ? 'next' : 'send code'} onSubmit={go(authStep)} />}
881            {m.step === 'otp' && <Input key="auth" autoFocus label="one-time code" placeholder={`the code emailed to ${m.email}`} value={m.otp} submitLabel="next" onSubmit={go(authStep)} />}
882            {m.step === 'password' && <Input key="auth" autoFocus label={m.flow === 'login' ? `password for ${m.email}` : 'new password (10+ characters)'} placeholder="hidden as you type" value={m.masked} submitLabel={m.flow === 'login' ? 'log in' : 'register'} onInput={typedSecret} onSubmit={go(authStep)} />}
883            {m.step !== 'policy' && (
884              <Box gap={1}>
885                <Button key="cancel" plain onPress={go(cancel)}>cancel</Button>
886                <Text dimColor>(Tab to it, then Enter)</Text>
887              </Box>
888            )}
889          </Box>
890        )}
891        {m.kind === 'confirm' && (
892          <Box flexDirection="column">
893            {/* The question names its target across the width, the answers their outcome below: `y: pop · n: keep`. */}
894            <Text color="yellow">{m.action === 'logout'
895              ? (m.target ? ` log out ${fit(m.target, width - 11)}?` : ' log out of mokkan here?')
896              : m.action === 'done' ? ` archive "${fit(m.target, width - 13)}"?`
897              : m.action === 'undone' ? ` reopen "${fit(m.target, width - 12)}"?`
898              : ` pop "${fit(m.target, width - 9)}"?`}</Text>
899            <Box>
900              <Text> </Text>
901              <Button key="yes" hotkey="y" plain onPress={go(() => m.action === 'logout'
902                ? act($, 'logging out…', ['logout'], 'logged out', () => update($, mode, () => NORMAL))
903                : m.action === 'done' || m.action === 'undone'
904                ? toggleDone(m.targetId ?? '', m.target, m.action === 'undone', () => update($, mode, () => NORMAL))
905                : act($, 'popping…', [m.action], `popped · ${m.target}`, () => update($, mode, () => NORMAL)))}>{m.action === 'logout' ? 'log out' : m.action === 'done' ? 'archive' : m.action === 'undone' ? 'reopen' : 'pop'}</Button>
906              <Text dimColor> · </Text>
907              <Button key="no" hotkey="n" plain onPress={go(() => update($, mode, () => NORMAL))}>{m.action === 'logout' ? 'stay' : 'keep'}</Button>
908            </Box>
909          </Box>
910        )}
911        {m.kind === 'normal' && (focused
912          ? hints()
913          : <Text dimColor> ctrl+x tab to use keys</Text>)}
914        {(verb || !loggedOut) && (
915          <Text key="sync" color={!verb && offline && !sync ? 'yellow' : undefined} dimColor={Boolean(verb) || !offline || sync}>{` ${verb ?? synced}`}</Text>
916        )}
917      </Box>
918    )
919  })
920}
921
types/index.d.ts 66 lines
1export type MokkanReminder = {
2  id: string
3  text: string
4  state: 'scheduled' | 'due' | 'delivered' | 'acknowledged' | 'done'
5  /** Null for a todo (`push`), set for a reminder (`in`, or `t` on a todo). */
6  due_at: string | null
7  created_at: string
8  delivered_at: string | null
9  acknowledged_at: string | null
10  done_at: string | null
11}
12
13export type MokkanView = {
14  /** Every open reminder (`mokkan list --all`), top of the stack first. */
15  reminders: MokkanReminder[]
16  /** Finished reminders (`mokkan done`), newest first. */
17  done: MokkanReminder[]
18  balance: number | null
19  /** Why the last refresh failed: `offline` keeps the last good lists, `loggedOut` clears them. */
20  failure: { kind: 'offline' | 'loggedOut'; text: string } | null
21  /** When the lists were last fetched whole; null until then and after a logout. */
22  fetchedAt: number | null
23}
24
25/** The list shown: the open todos (no due time), the open reminders (a due time), or the finished ones. */
26export type MokkanTab = 'todos' | 'reminders' | 'archived'
27
28/** The last result, drawn on the status line until 15 s pass or the next key: green, yellow for a nudge, red for an error; `id` counts the messages said. */
29export type MokkanMessage = { text: string; tone: 'ok' | 'note' | 'error'; id: number } | null
30
31/** The privacy policy as `mokkan privacy --json` gives it: the version accepted, where the full text is, and its summary lines. */
32export type MokkanPolicy = { version: string; url: string; summary: string[] }
33
34/** What the pane's field is for: a new todo or reminder, or a change to the pointed one. */
35export type MokkanMode =
36  | { kind: 'normal' }
37  | { kind: 'input'; purpose: 'push' | 'in' | 'edit' | 'time' | 'feedback'; targetId?: string; value: string }
38  /** `target` names what the action takes: a reminder's text, or the account; `targetId` the reminder done and undone change. */
39  | { kind: 'confirm'; action: 'pop' | 'dequeue' | 'logout' | 'done' | 'undone'; target: string; targetId?: string }
40  /**
41   * Login or registration: the email, the emailed code (register only), then the password.
42   * `masked` is the bullets drawn for the password; the text itself never enters state.
43   * Registration first shows `policy` (step `policy`), and passes its version to both register steps; null for a login.
44   */
45  | { kind: 'auth'; flow: 'login' | 'register'; step: 'policy' | 'email' | 'otp' | 'password'; email: string; otp: string; masked: string; policy: MokkanPolicy | null }
46
47declare module 'claude-code' {
48  interface PluginState {
49    mokkan: {
50      view: MokkanView
51      mode: MokkanMode
52      selected: string | null
53      tab: MokkanTab
54      message: MokkanMessage
55      /** The verb of the CLI call in flight (`pushing…`), drawn in the header; action keys wait for it. */
56      busy: string | null
57      /** True while a refresh is in flight. */
58      syncing: boolean
59      /** True while the help view (`h`) replaces the list. */
60      help: boolean
61      /** The policy the account must accept (a CLI call exited 4): its acceptance view replaces the list until accepted. */
62      privacy: MokkanPolicy | null
63    }
64  }
65}
66