SLOPSHOPPER

Oh My Toolbelt

A toolbelt above the Claude Code prompt: clock, date, weather and a pomodoro on the left; your saved prompts, commands and one-press actions on the right; a…

newpanebandspinnerrowscommand
★ 1v0.1.0Apache-2.0updated 2026-10-25calmtiger86/oh-my-toolbelt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · oh-my-toolbelt
│ ┃ Toolbelt ✕ › fix the failing auth test and add an audit log call │ ┃ Prompts Notes Status tab-tools Guide │ ┃ ● oh-my-toolbelt: oh-my-toolbelt: versions unread: ENOENT: no such fi │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ ▸ Layout │ ⎿ Read 6 lines │ ┃ │ Icon group: Work ▾ │ ⏺ Update(src/auth.ts) │ ┃ │ The list before each name sets its icon. │ ⎿ Added 2 lines, removed 1 line │ ┃ │ This group decides which icons they │ ⏺ Bash(bun test) │ ┃ │ list. │ ⎿ 3 pass, 1 fail │ ┃ │ │ │ ┃ │ Left │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Clock ↑ ↓ → Hide │ │ ┃ │ Date ↑ ↓ → Hide │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ 🍅 ▾ Pomodoro ↑ ↓ → Hide │ │ ┃ │ │ › /toolbelt │ ┃ │ Right │ │ ┃ │ None ▾ Review ↑ ↓ ← Hide │ │ ┃ │ None ▾ /usage ↑ ↓ ← Hide │ │ ┃ │ a: 📎 ▾ Attach ↑ ↓ ← Hide │ │ ┃ │ h: ▢ ▾ Hide conversation ↑ ↓ ← Hide │ │ ┃ │ e: ⇩ ▾ Export ↑ ↓ ← Hide │ │ ┃ │ n: 📝 ▾ Notes ↑ ↓ ← Hide │ │ ┃ │ b: ⚙ ▾ Edit ↑ ↓ ← Hide │ │ ┃ │ │ │ ┃ │ … (all tools) is always at the right │ │ ┃ │ end. │ ◂ ◷ 8:53 AM │ ▦ 10/09/2025 │ 🍅 [ Revi ] [ /usa ] ❘ [ 📎 ] [ ▢ ] [ ⇩ ] [ 📝 ] [ ⚙ ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◂ ◷ 8:53 AM │ ▦ 10/09/2025 │ 🍅 [ Revi ] [ /usa ] ❘ [ 📎 ] [ ▢ ] [ ⇩ ] [ 📝 ] [ ⚙ ] ⟨Claude Code's own drawing⟩
Pane · Toolbelt
Prompts Notes Status tab-tools Guide ╭──────────────────────────────────────────────────────────╮ │ ▸ Layout │ │ Icon group: Work ▾ │ │ The list before each name sets its icon. This group │ │ decides which icons they list. │ │ │ │ Left │ │ Clock ↑ ↓ → Hide │ │ Date ↑ ↓ → Hide │ │ 🍅 ▾ Pomodoro ↑ ↓ → Hide │ │ │ │ Right │ │ None ▾ Review ↑ ↓ ← Hide │ │ None ▾ /usage ↑ ↓ ← Hide │ │ a: 📎 ▾ Attach ↑ ↓ ← Hide │ │ h: ▢ ▾ Hide conversation ↑ ↓ ← Hide │ │ e: ⇩ ▾ Export ↑ ↓ ← Hide │ │ n: 📝 ▾ Notes ↑ ↓ ← Hide │ │ b: ⚙ ▾ Edit ↑ ↓ ← Hide │ │ │ │ … (all tools) is always at the right end. │ │ │ │ Hidden tools │ │ Nothing here. │ │ │ │ [ Reset layout ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▸ Buttons (set: default) │ │ Set: default ▾ Delete set │ │ [ + New set ] │ │ Only the chosen set's buttons show on the toolbelt. │ │ │ │ Review Prompt · Insert · Hotkey: - Edit Delete │ │ /usage Command · Run · Hotkey: - Edit Delete │ │ │ │ [ + New button ] │ ╰──────────────────────────────────────────────────────────╯
README

<b>English</b> · <a href="README.ko.md">한국어</a> · <a href="README.ja.md">日本語</a>

<img src="assets/logo.svg" width="112" alt="Oh My Toolbelt logo">

<h1 align="center">Oh My Toolbelt</h1>

<b>New to the terminal? Claude Code, made easy.</b><br> Your go-to prompts as buttons, files attached from a picker, passing thoughts kept as notes. All on one toolbelt, right above the prompt box.

When you use Claude Code in a terminal, an unfamiliar screen and missing conveniences can often leave you at a loss. Oh My Toolbelt is a toolbelt built around the things a non-developer found awkward in the terminal.

Add a single toolbelt row above the prompt box, and the commands and prompts you use most become buttons. Attaching a file no longer means typing its path: you pick it in a file picker. An idea that comes up mid-task goes into a note, and from there straight into the prompt box. Prompts you sent earlier are in a list too; click one to jump right back to it. When you step away, Hide covers your work, and Export saves the session's conversation to a file in one go.

The little things that trip you up when the terminal is new, eased by one toolbelt. Most of it works the same in the desktop app, too.

<a href="LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-d77757"></a> <img alt="Claude Code 2.1.309 or later" src="https://img.shields.io/badge/Claude%20Code-2.1.309%2B-1f1f22"> <img alt="Terminal and desktop app" src="https://img.shields.io/badge/runs%20in-terminal%20%7C%20desktop%20app-555"> <img alt="English, Korean, Japanese" src="https://img.shields.io/badge/languages-en%20%7C%20ko%20%7C%20ja-555">

The toolbelt under a Claude Code conversation: clock, date, weather and a running pomodoro on the left; prompt buttons, Attach, Voice, Light, Hide, Export, Notes and Edit on the right

From a terminal:

claude plugin install oh-my-toolbelt --marketplace calmtiger86/oh-my-toolbelt

One line. If Claude Code is already open, type /reload-plugins there to turn it on. If the install ends with userConfig options not yet set, that is fine: every setting starts at its default. It starts quiet: no network until you pick a weather city, no clipboard history until you turn it on, and nothing you write in it leaves your computer unless you send it as a prompt.

In the desktop app: open + → Add plugins (or Plugins → Browse plugins), add the marketplace from its repository, calmtiger86/oh-my-toolbelt, then pick oh-my-toolbelt in the list and install it. Running the terminal command above once works too: the terminal and the desktop app's local sessions read the same settings.

Requirements: Claude Code 2.1.309 or later, in a terminal or in the desktop app's Code tab. VS Code and mobile do not draw a row above the prompt.

From a terminal:

claude plugin install oh-my-toolbelt --marketplace calmtiger86/oh-my-toolbelt

Inside Claude Code:

/plugin install oh-my-toolbelt --marketplace calmtiger86/oh-my-toolbelt

It asks to add the marketplace (y) and the scope (Enter), then shows the settings: press Enter on each row to keep its default, then Save.

To update: claude plugin update oh-my-toolbelt, then restart Claude Code; what changed is in CHANGELOG.md. To remove: claude plugin uninstall oh-my-toolbelt.

Features

FeatureWhat it does
Your buttonsSave the prompts and commands you use most as buttons. A press puts one in the prompt box or sends it
Prompt listThe prompts you sent pile up in a numbered list; click one to go back to it
AttachPick files in your file picker; they go into the prompt box as @ mentions (terminal)
NotesJot down a thought and put it in the prompt box later. All your sessions share the same notes
Clipboard historyKeeps what you copy from the conversation so you can put it back (off by default; only in the desktop app and in the terminal's fullscreen mode)
HideOne press covers the conversation
ExportSaves the conversation as a Markdown, HTML or text file; in the terminal, as PDF too once you pick a Chromium-based browser as the PDF browser and PDF as the Export format, both in the Status tab
Clock · Date · WeatherThe clock and date stay in view; pick a city and the weather shows too (weather is off by default)
PomodoroShows a focus round with a progress bar and tells you when it ends
VoiceTurns Claude Code's voice input on and off (terminal)
Light / DarkSwitches the theme
Fold◂ folds the whole toolbelt into a single ▸
PanelOpens from Edit or /toolbelt, with the settings, the button layout and a step-by-step guide

Quick start

  1. Look under the conversation: the toolbelt is the row right above the prompt box.
  2. Press Edit, open the Guide tab and follow part 1. It takes two minutes.
  3. Make it yours in the Status tab: pick a weather city, turn on the Clipboard tab, choose 12 or 24 hours.

The panel's Prompts, Notes, Clipboard and Status tabs

In detail

New here? Start with the guide. Press Edit on the toolbelt and open the Guide tab: it walks you through each feature step by step and tells you what to press. The guide speaks the language you set (English, Korean or Japanese).

On the toolbelt

Information on the left, buttons on the right. When the window is narrow, the buttons shrink to their icons and the least needed ones move into ….

ToolWhat it does
◂ / ▸Fold the toolbelt into a single ▸, and open it again. It stays folded next time
Clock · Date12- or 24-hour, in your computer's time zone, UTC or that of one of 53 cities, seconds if you like; the date in your language's order
WeatherOff until you pick one of 63 cities. Current conditions, refreshed every 30 minutes by default
PomodoroPress it to start a focus round, again to pause, once more to go on. Breaks are timed only when Pomodoro auto break is on. A notification when a round ends, and a sound on macOS if you turn it on
Your buttonsA saved prompt to put in the prompt box or send, or a slash command or skill to run. Group them into sets and switch between sets
AttachYour system's file picker; the files go into the prompt box as @ mentions (terminal)
VoiceTurns Claude Code's voice input on and off, where your Claude Code has /voice (terminal)
Light / DarkSwitches Claude Code's theme to its light or dark pair
HideCovers the conversation until you press it again
ExportSaves the conversation in the format picked in the Status tab (Markdown at first)
NotesOpens the notes
EditOpens the panel

The panel

Press Edit or type /toolbelt to open it beside the conversation.

TabWhat it holds
PromptsEvery prompt you sent in this conversation, numbered (the newest 200). Click one to jump to it. Point at one to see its turn on the toolbelt row: how long it took, the tools it used and the files it changed. In the desktop app a row opens in place: read the whole answer, put the prompt back in the prompt box, or export that answer alone
NotesA notepad shared by all your sessions. Insert puts a note at the cursor of the prompt box. Up to 500 notes (256 KiB); past that the oldest go
ClipboardOff by default. What you copy by dragging over the conversation, newest first (the last 30; a selection over 20,000 characters is skipped); Insert puts it back. Kept in memory until Claude Code closes, never written to a file
StatusThis session at a glance (versions, weather, finished focus rounds, the last error), every tool, and every setting as a switch or a pick list
ToolsThe layout (side, order, shown or hidden), your buttons and button sets, and the icons
GuideA step-by-step tour of all of the above, in your language

In the terminal, press ctrl+x, let go, then tab: the toolbelt is selected. Tab moves between buttons, Enter presses one, Esc goes back to the prompt box. While it is selected, one letter presses a tool (a Attach, v Voice, t Light / Dark, h Hide, e Export, n Notes, b Edit, m more); your own buttons can have letters too. In the desktop app, click the buttons.

CommandWhat it does
/toolbelt (or /tb, when no other mod has it)Open the panel
/toolbelt tools · toc · notes · clips · status · guideOpen the panel at that tab
/toolbelt toc next · prev · first · last · <n> · find <words>Jump to a prompt
/toolbelt pomodoro start · pause · stop · resetStart or go on, pause, stop (the count of finished rounds stays) or reset (the count goes back to 0)
`/toolbelt export [md\html\txt\pdf]`Export the conversation
/toolbelt curtain on · offHide or show the conversation
/toolbelt weather refreshFetch the weather now
/toolbelt hide · showTake the toolbelt away entirely, or bring it back

Every setting is a switch or a pick list in the panel's Status tab; all but the export format are in /config too.

SettingDefaultChoices
languageautoauto (your system's), en, ko, ja
clockFormat · clockSecondsauto · offauto, 12, 24; seconds on or off
dateFormatautooff, auto, year first, day first, month first
timeZonesystemYour computer's zone, UTC, or one of 53 cities
iconStyleicon+labelicon+label, icon, label (for terminals that draw emoji badly)
weatherPlaceoffoff or one of 63 cities
temperatureUnit · weatherRefreshMinutesauto · 30auto (Fahrenheit for US cities), Celsius or Fahrenheit; 10 to 180 minutes
pomodoroFocusMinutes · pomodoroBreakMinutes25 · 55 to 120; 1 to 60
pomodoroAutoBreak · pomodoroNotifyoff · onStart the break when a focus round ends (without it no break is timed); notify when a round ends
pomodoroSound · pomodoroSoundNameoff · bellA sound when a round ends (macOS): bell, chime, ding, digital, marimba, soft
exportDirectory · pdfBrowserdownloads · offWhere exports go (downloads, desktop, documents, home, or session: the project folder, where a file could end up in git); the browser that prints PDFs (auto, chrome, edge, brave, chromium)
Export format (Status tab only)mdmd, html, txt, and pdf in the terminal once pdfBrowser is set
attachEnabled · voiceModeon · tapThe Attach button; how Voice records (tap or hold)
curtainRestoreOnSubmitoffShow the conversation again when you send a prompt
hotkeysEnabledonLetter keys for the toolbelt's buttons
tocTab · clipboardTabon · offThe Prompts tab; the Clipboard tab
paneOpenOnStartoffOpen the panel when a session starts (wide windows)

Privacy: Oh My Toolbelt runs on your computer and sends nothing about you to its authors; its only network request is the weather, if you turn it on.

  • Network: only the weather, and only after you pick a city. api.open-meteo.com receives that city's coordinates and the temperature unit; like any web request, it also sees your IP address.
  • Your conversation: while the Prompts tab is on (the default), it reads the prompts you send, what each turn did (tool names, edited files, time) and this session's transcript file, to list your prompts; Export writes the conversation to a file you asked for, keeping the commands you typed but not their output or the hidden text Claude Code puts ahead of your prompts. Neither leaves your computer.
  • Stored on this computer: your buttons, layout, icons, notes, the pomodoro, the weather cache, a few preferences and the transcript file paths of your 20 most recent sessions, recorded while the Prompts tab is on (on by default), as plain text in Claude Code's plugin store. The clipboard history stays in memory only. While Claude Code runs, other mods you installed can read your notes, buttons and clipboard history.
  • Programs it starts: your file picker when you press Attach and a browser when you export a PDF; by itself, while the Prompts tab is on, tail (PowerShell on Windows) to read this session's transcript when it is over 4 MiB. Each with an argument list and no shell.
  • The model sees only what you choose to send: a prompt button you press, or a note, clip, earlier prompt or attached file you put in the prompt box. The one-line reply of a /toolbelt command stays in the conversation, where the model can read it.

The full account is in PRIVACY.md.

  • Desktop app: no Attach or Voice button (its prompt box has its own), no PDF export, and a pomodoro alert is a message in the app. Clicking a prompt opens it in place instead of scrolling, as Claude Code lets no mod move the conversation there. Settings changed in its Status tab apply at once and are kept by the toolbelt; they are copied to /config the next time you use it in the terminal.
  • Clipboard tab: collects in the desktop app and where Claude Code draws the whole terminal screen ("tui": "fullscreen"); in the inline layout your terminal app does the selecting, so nothing reaches the toolbelt.
  • Hide covers the conversation but not the lines Claude Code draws itself, such as the logo and notices.
  • Hide and your buttons: with curtainRestoreOnSubmit off, a prompt sent by one of your send buttons stays in view (its answer is covered), as Claude Code does not pass that prompt's row to the mod that sent it.
  • Export: a prompt that starts like one of Claude Code's own tags (such as <command-name> or <bash-stdout>) can be left out of an export, as it cannot be told apart from Claude Code's output.
  • Prompts tab: the turn details shown for a prompt count only the turn that prompt started. The answer to a ! shell command (bash mode), a follow-up turn Claude Code starts by itself after something you do, and a turn started by another mod, by one of your buttons that runs a command or skill, or by a task notification count toward no prompt. In an older conversation whose transcript file records no turn lengths, such a turn may still count toward the prompt before it.
  • Tested on macOS with Claude Code 2.1.309 and 2.1.310, in the terminal and the desktop app. The automatic tests run on Linux and Windows too; the file pickers there have not been tried by hand.
  • No toolbelt? Run /plugin and check that oh-my-toolbelt is listed as active. In a narrow window the toolbelt shrinks, and below about 14 columns it steps aside.
  • Something failed? The panel's Status tab shows the last error in plain words. For the full record, start Claude Code with claude --debug.
  • Emoji look broken? Set iconStyle to label.
  • The toolbelt is gone after [-]? That is Claude Code's own fold (terminal): click the line it leaves, or press ctrl+x then ctrl+a.
  • ctrl+x then Tab does nothing? Your terminal app may use those keys. Bind others in ~/.claude/keybindings.json: part 5 of the Guide tab has the lines to paste.

For developers

Oh My Toolbelt is a Claude Code mod: a hooks module written in TypeScript (JSX for the drawings) that the engine loads as it is, with no build step.

git clone https://github.com/calmtiger86/oh-my-toolbelt
cd oh-my-toolbelt
claude --plugin-dir .          # run it; edits reload when a turn ends
claude plugin validate .       # the engine's own checks
claude plugin test .           # the test suite (over 500 tests, terminal and desktop surfaces)
npx -p typescript@5 tsc -p .   # type-check, once Claude Code has loaded the mod and written its types
  • How it fits together: docs/ARCHITECTURE.md is a one-page map of the modules, the state, the store and the rules the engine holds a mod to.
  • Add a language: copy hooks/locales/en.ts to hooks/locales/<code>.ts and translate it; register it in hooks/lib/i18n.ts and tests/i18n.test.ts; add the code to the language options in .claude-plugin/plugin.json. The tests check that every key and placeholder is there. The Japanese text has not been reviewed by a native speaker yet; corrections are welcome.
  • Add a tool: tools are plain objects in hooks/tools/registry.ts (a label, a priority, what a press does as a list of effects); hooks/register.tsx is the one place effects become calls on the engine.
  • Contributing: issues and pull requests are welcome. Keep code, comments and commit messages in English, and run the three checks above before you open a pull request. CI runs validate and the tests on macOS, Linux and Windows.

License

Apache License 2.0. Copyright 2026 calmtiger86 and contributors. If you share this code or a work built on it, keep the NOTICE file with it and mark the files you changed.

The Prompts tab adapts code from oikon48/prompt-rail (MIT); its notice is in THIRD_PARTY_NOTICES.md. The chip style of the clock, date, weather and pomodoro follows augiefra/claude-mods.

Oh My Toolbelt is an independent mod made by an individual developer, not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, PBC.

Source 52 files
hooks/register.tsx 892 lines
1// Toolbelt's hooks module: it registers every hook, declares every `$.state` atom and writes every call on the engine,
2// because the loader and the plugin directory read those only in this file. The other files are handed `engineOf($)`.
3// Parts ported from oikon48/prompt-rail (MIT): see THIRD_PARTY_NOTICES.md.
4import { atom, read, update } from 'claude-code'
5import type { CommandInfo, CommandRunInput, CommandRunResult, ConfigSetResult, ConfigValue, EngineInterface, HttpResponse, Register, RenderSurface } from 'claude-code'
6import type { ButtonConfig, ClipBoard, Layout, PaneTab } from '../types/index'
7import { FOLD_ID, UNFOLD_ID, bandModel, drawBand, drawFolded, hidesButtonNames } from './band'
8import { draftIssues, drawPane } from './pane'
9import type { PaneAction } from './pane'
10import type { Effect } from './lib/effects'
11import { formatClock, isValidTimeZone } from './lib/format'
12import { DEFAULT_ICON_GROUP, asIconGroup, asIcons, iconStyleOf, withToolIcon } from './lib/icons'
13import { resolveLanguage, systemLocale, translator } from './lib/i18n'
14import type { ResolvedLanguage, TFn } from './lib/i18n'
15import { cellsOf } from './lib/width'
16import { cleanButtonConfig, defaultButtons, defaultLayout, planLoad } from './lib/schema'
17import type { StoreRead } from './lib/schema'
18import { clockFormatOptions, clockLabel, configuredTimeZone } from './tools/clock'
19import { ARGUMENT_HINT, isHumanOrigin, parseCommand, replyFor } from './tools/command'
20import type { PomodoroVerb } from './tools/command'
21import { curtainEffects } from './tools/curtain'
22import { asPomodoro, idlePomodoro, mergePomodoro, pause, pomodoroConfig, pomodoroEffects, reset, start, stop, tick as advancePomodoro } from './tools/pomodoro'
23import { BUTTON_PREFIX, applyLayoutOp, emptySnapshot, isItemShown, normalizeLayout, resetLayout, toolForItem } from './tools/registry'
24import type { Ctx } from './tools/registry'
25import { isTocTabOn } from './tools/toc'
26import { addButton, addSetChange, allButtons, buildButton, defaultButtonConfig, deleteSetChange, draftFor, nextButtonId, pickSetChange, removeButton, setButtonIcon, setDraftField, updateButton } from './tools/user'
27import type { SetChange } from './tools/user'
28import { asWeatherCache, failedWeather, forecastUrl, isFresh, loadingWeather, newerCache, offWeather, parseForecast, sameTarget, shownWeather, weatherCity, weatherRefreshMs, weatherTarget } from './tools/weather'
29import type { WeatherCache, WeatherLook, WeatherTarget } from './tools/weather'
30import { EXPORT_TOAST_MS, MOVE_SETTINGS_MS, PANE_ID, PANE_ROWS, SYNC_MS, TICK_MS, VOICE_SYNC_MS, errorText, initialRuntime, makeCtx, withBandColumns } from './runtime'
31import type { Engine, StoreIo } from './runtime'
32import { moveToc, openedAnswer, noteTocView, resetToc, scrollToPrompt, tocBandCards, tocView, viewTocRow, watchToc } from './toc-live'
33import { fillTranscript, refillTranscript } from './toc-history'
34import { appendRow, completeTurn, logHookFailure, readTranscript, receivePrompt, startTurn } from './toc-hooks'
35import { changeCurtain, drawPromptRow, drawRow, isCurtainOn, submitPrompt } from './row-hooks'
36import { FOLDED_KEY, isBandFolded, loadFold, setBandFolded } from './fold'
37import { clipEffects, deleteClip, isClipsTabOn, noteSelection, readClipBoard, restoreClips } from './clips'
38import { exportOutcome, loadExportFormat, readExportFormat, rememberFormat, toastAndAnswer } from './export-run'
39import type { ExportEnv } from './export-run'
40import { addQuickNote, deleteQuickNote, noteFillEffects, readNotes, syncNotes } from './notes-run'
41import { runAttach } from './attach-run'
42import { changeSetting, dropSoundPick, listSettings, playPickedSound, previewSound, readSettings, readSettingsSource, readThemeRow, setTheme } from './settings'
43import type { SettingChange } from './settings'
44import { loadOverrides, moveOverrides, readVersions, saveOverride } from './own-settings'
45import { voiceModeOf, voiceModeToSync } from './tools/voice'
46
47const clockLabelState = atom({ plugin: 'oh-my-toolbelt', key: 'clockLabel' } as const, '')
48const pomodoroState = atom({ plugin: 'oh-my-toolbelt', key: 'pomodoro' } as const, idlePomodoro(0))
49const weatherState = atom({ plugin: 'oh-my-toolbelt', key: 'weather' } as const, offWeather())
50const bandHiddenState = atom({ plugin: 'oh-my-toolbelt', key: 'bandHidden' } as const, false)
51const paneTabState = atom({ plugin: 'oh-my-toolbelt', key: 'paneTab' } as const, 'tools')
52const openPickerState = atom({ plugin: 'oh-my-toolbelt', key: 'openPicker' } as const, null)
53const voiceState = atom({ plugin: 'oh-my-toolbelt', key: 'voice' } as const, { isAvailable: false, requested: null })
54const paneStartedState = atom({ plugin: 'oh-my-toolbelt', key: 'paneStarted' } as const, false)
55const guideSectionState = atom({ plugin: 'oh-my-toolbelt', key: 'guideSection' } as const, 'start')
56const buttonsState = atom({ plugin: 'oh-my-toolbelt', key: 'buttons' } as const, defaultButtons())
57const layoutState = atom({ plugin: 'oh-my-toolbelt', key: 'layout' } as const, defaultLayout())
58const iconsState = atom({ plugin: 'oh-my-toolbelt', key: 'icons' } as const, {})
59const iconGroupState = atom({ plugin: 'oh-my-toolbelt', key: 'iconGroup' } as const, DEFAULT_ICON_GROUP)
60const editDraftState = atom({ plugin: 'oh-my-toolbelt', key: 'editDraft' } as const, null)
61const lastErrorState = atom({ plugin: 'oh-my-toolbelt', key: 'lastError' } as const, null)
62const settingsState = atom({ plugin: 'oh-my-toolbelt', key: 'settings' } as const, [])
63// Whose rows the status tab draws: the engine's, or the mod's own from plugin.json.
64const settingsSourceState = atom({ plugin: 'oh-my-toolbelt', key: 'settingsSource' } as const, 'engine')
65// Claude Code's own theme row, for the theme button; read with the mod's rows.
66const themeRowState = atom({ plugin: 'oh-my-toolbelt', key: 'themeRow' } as const, null)
67const soundPickState = atom({ plugin: 'oh-my-toolbelt', key: 'soundPick' } as const, null)
68const bandFoldedState = atom({ plugin: 'oh-my-toolbelt', key: 'bandFolded' } as const, false)
69const EMPTY_CLIP_BOARD: ClipBoard = { clips: [], seen: null, changing: null }
70const clipBoardState = atom({ plugin: 'oh-my-toolbelt', key: 'clipBoard' } as const, EMPTY_CLIP_BOARD)
71// The status tab's choice; the store's `exportLast` is the one the band's button uses next, in every session.
72const exportFormatState = atom({ plugin: 'oh-my-toolbelt', key: 'exportFormat' } as const, 'md')
73const notesState = atom({ plugin: 'oh-my-toolbelt', key: 'notes' } as const, [])
74const curtainState = atom({ plugin: 'oh-my-toolbelt', key: 'curtain' } as const, false)
75// A count only the prompts tab reads, so bumping it draws the panel again and nothing else: `$.ui.invalidate` would
76// draw every transcript row too, and rows drawn again just after a jump move the viewport.
77const tocMovedState = atom({ plugin: 'oh-my-toolbelt', key: 'tocMoved' } as const, 0)
78
79// What this module load knows besides `$.state` (hooks/runtime.ts).
80let runtime = initialRuntime()
81const tr = (): TFn => translator(runtime.lang.lang)
82
83// ---- Failures and the store ----
84// The debug log in English, and the status tab's last error in the person's language (a key of the
85// language files). A failure to record one has nowhere left to go.
86async function noteError($: EngineInterface, key: string, args?: Record<string, unknown>): Promise<void> {
87  $.ui.log(`oh-my-toolbelt: ${translator('en')(key, args)}`, { to: 'debug' })
88  await update($, lastErrorState, () => tr()(key, args)).catch(() => undefined)
89}
90
91// Runs one call on `$`; a failure goes to the debug log and lastError, and `fallback` stands in.
92// `call` runs once; a promise already started is waited for the same way. That promise must come from an async
93// function, so that a throw arrives here as a rejection instead of escaping before `attempt` runs. `engineOf` does not
94// throw, so building `engineOf($)` for such a call cannot throw before `attempt` runs either.
95async function attempt<T>($: EngineInterface, what: string, call: Promise<T> | (() => Promise<T>), fallback: T): Promise<T> {
96  try {
97    return await (typeof call === 'function' ? call() : call)
98  } catch (err) {
99    await noteError($, 'error.call', { call: what, reason: errorText(err) })
100    return fallback
101  }
102}
103
104// The environment variables the mod reads, each name written out: the engine's loader reads `$.env.get` only with a literal
105// name, so a module's variables can be listed.
106async function envGet($: EngineInterface, name: string): Promise<string | undefined> {
107  switch (name) {
108    case 'LC_ALL': return $.env.get('LC_ALL')
109    case 'LC_MESSAGES': return $.env.get('LC_MESSAGES')
110    case 'LANG': return $.env.get('LANG')
111    case 'DISPLAY': return $.env.get('DISPLAY')
112    case 'WAYLAND_DISPLAY': return $.env.get('WAYLAND_DISPLAY')
113    case 'OS': return $.env.get('OS')
114    case 'SystemRoot': return $.env.get('SystemRoot')
115    case 'HOME': return $.env.get('HOME')
116    case 'USERPROFILE': return $.env.get('USERPROFILE')
117    case 'ProgramFiles': return $.env.get('ProgramFiles')
118    case 'ProgramFiles(x86)': return $.env.get('ProgramFiles(x86)')
119    case 'LOCALAPPDATA': return $.env.get('LOCALAPPDATA')
120    default: throw new Error(`not a variable this mod reads: ${name}`)
121  }
122}
123
124// The engine calls the feature modules make, each written here as a plain call (runtime.ts `Engine`).
125export function engineOf($: EngineInterface): Engine {
126  return {
127    cells: {
128      settings: { read: () => read($, settingsState), update: change => update($, settingsState, change) },
129      settingsSource: { read: () => read($, settingsSourceState), update: change => update($, settingsSourceState, change) },
130      themeRow: { read: () => read($, themeRowState), update: change => update($, themeRowState, change) },
131      soundPick: { read: () => read($, soundPickState), update: change => update($, soundPickState, change) },
132      bandFolded: { read: () => read($, bandFoldedState), update: change => update($, bandFoldedState, change) },
133      clipBoard: { read: () => read($, clipBoardState), update: change => update($, clipBoardState, change) },
134      exportFormat: { read: () => read($, exportFormatState), update: change => update($, exportFormatState, change) },
135      notes: { read: () => read($, notesState), update: change => update($, notesState, change) },
136      curtain: { read: () => read($, curtainState), update: change => update($, curtainState, change) },
137      tocMoved: { read: () => read($, tocMovedState), update: change => update($, tocMovedState, change) },
138    },
139    store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
140    env: { get: name => envGet($, name) },
141    ui: {
142      toast: (text, options) => $.ui.toast(text, options),
143      log: (text, options) => $.ui.log(text, options),
144      selection: () => $.ui.selection(),
145      invalidate: event => $.ui.invalidate(event),
146      scroll: args => $.ui.scroll(args),
147      resolve: e => $.ui.resolve(e),
148    },
149    clock: { now: () => $.clock.now(), after: (ms, fn) => $.clock.after(ms, fn) },
150    session: { surfaces: () => $.session.surfaces(), messages: () => $.session.messages(), id: () => $.session.id(), version: () => $.session.version(), cwd: () => $.session.cwd() },
151    prompt: { fill: input => $.prompt.fill(input) },
152    // Runs and writes only what PRIVACY.md lists under "What it runs" and "What it keeps on your computer": the file
153    // picker, the PDF browser, `tail` (PowerShell on Windows) for a long transcript, and the exports.
154    process: { run: (argv, init) => $.process.run(argv, init), spawn: request => $.process.spawn(request) },
155    fs: { exists: path => $.fs.exists(path), write: (path, text) => $.fs.write(path, text), stat: (path, options) => $.fs.stat(path, options), read: path => $.fs.read(path) },
156    command: { list: () => $.command.list() },
157    audio: { play: (clip, options) => $.audio.play(clip, options) },
158    config: { list: () => $.config.list(), write: (key, value) => writeConfig($, key, value) },
159    plugin: { name: $.plugin.name, root: $.plugin.root },
160  }
161}
162
163// The only place the mod writes /config: one plain call per row it may change, the key written out, so a reader (the
164// plugin directory's check among them) sees that it changes its own rows and the theme and nothing else.
165export async function writeConfig($: EngineInterface, key: string, value: ConfigValue): Promise<ConfigSetResult> {
166  switch (key) {
167    case 'oh-my-toolbelt.language': return $.config.set({ key: 'oh-my-toolbelt.language', value: value })
168    case 'oh-my-toolbelt.clockFormat': return $.config.set({ key: 'oh-my-toolbelt.clockFormat', value: value })
169    case 'oh-my-toolbelt.clockSeconds': return $.config.set({ key: 'oh-my-toolbelt.clockSeconds', value: value })
170    case 'oh-my-toolbelt.dateFormat': return $.config.set({ key: 'oh-my-toolbelt.dateFormat', value: value })
171    case 'oh-my-toolbelt.timeZone': return $.config.set({ key: 'oh-my-toolbelt.timeZone', value: value })
172    case 'oh-my-toolbelt.iconStyle': return $.config.set({ key: 'oh-my-toolbelt.iconStyle', value: value })
173    case 'oh-my-toolbelt.weatherPlace': return $.config.set({ key: 'oh-my-toolbelt.weatherPlace', value: value })
174    case 'oh-my-toolbelt.temperatureUnit': return $.config.set({ key: 'oh-my-toolbelt.temperatureUnit', value: value })
175    case 'oh-my-toolbelt.weatherRefreshMinutes': return $.config.set({ key: 'oh-my-toolbelt.weatherRefreshMinutes', value: value })
176    case 'oh-my-toolbelt.pomodoroFocusMinutes': return $.config.set({ key: 'oh-my-toolbelt.pomodoroFocusMinutes', value: value })
177    case 'oh-my-toolbelt.pomodoroBreakMinutes': return $.config.set({ key: 'oh-my-toolbelt.pomodoroBreakMinutes', value: value })
178    case 'oh-my-toolbelt.pomodoroAutoBreak': return $.config.set({ key: 'oh-my-toolbelt.pomodoroAutoBreak', value: value })
179    case 'oh-my-toolbelt.pomodoroNotify': return $.config.set({ key: 'oh-my-toolbelt.pomodoroNotify', value: value })
180    case 'oh-my-toolbelt.pomodoroSound': return $.config.set({ key: 'oh-my-toolbelt.pomodoroSound', value: value })
181    case 'oh-my-toolbelt.pomodoroSoundName': return $.config.set({ key: 'oh-my-toolbelt.pomodoroSoundName', value: value })
182    case 'oh-my-toolbelt.exportDirectory': return $.config.set({ key: 'oh-my-toolbelt.exportDirectory', value: value })
183    case 'oh-my-toolbelt.attachEnabled': return $.config.set({ key: 'oh-my-toolbelt.attachEnabled', value: value })
184    case 'oh-my-toolbelt.voiceMode': return $.config.set({ key: 'oh-my-toolbelt.voiceMode', value: value })
185    case 'oh-my-toolbelt.pdfBrowser': return $.config.set({ key: 'oh-my-toolbelt.pdfBrowser', value: value })
186    case 'oh-my-toolbelt.curtainRestoreOnSubmit': return $.config.set({ key: 'oh-my-toolbelt.curtainRestoreOnSubmit', value: value })
187    case 'oh-my-toolbelt.hotkeysEnabled': return $.config.set({ key: 'oh-my-toolbelt.hotkeysEnabled', value: value })
188    case 'oh-my-toolbelt.tocTab': return $.config.set({ key: 'oh-my-toolbelt.tocTab', value: value })
189    case 'oh-my-toolbelt.clipboardTab': return $.config.set({ key: 'oh-my-toolbelt.clipboardTab', value: value })
190    case 'oh-my-toolbelt.paneOpenOnStart': return $.config.set({ key: 'oh-my-toolbelt.paneOpenOnStart', value: value })
191    case 'theme': return $.config.set({ key: 'theme', value: value })
192    default: throw new Error(`not a setting this mod writes: ${key}`)
193  }
194}
195
196// A failed read comes back `isRead: false`, never as "unset".
197function storeGet($: EngineInterface, key: string): Promise<StoreRead> {
198  return attempt($, `store.get ${key}`, async (): Promise<StoreRead> => ({ isRead: true, value: await $.store.get(key) }), { isRead: false, value: undefined })
199}
200
201// True once written. A read-only store writes nothing; a refused write says so.
202async function storeSet($: EngineInterface, key: string, value: unknown): Promise<boolean> {
203  if (runtime.isStoreReadOnly) return false
204  const isSaved = await attempt($, `store.set ${key}`, () => $.store.set(key, value).then(() => true), false)
205  if (!isSaved) $.ui.toast(tr()('toast.saveFailed'))
206  return isSaved
207}
208
209// The panel saves to the store first and draws after, so a refused save changes nothing on screen.
210async function panelSave($: EngineInterface, key: string, value: unknown): Promise<boolean> {
211  if (runtime.isStoreReadOnly) $.ui.toast(tr()('status.storeNewer'))
212  return storeSet($, key, value)
213}
214
215// Re-read before a write, as every session shares the store; a failed re-read stops the save.
216async function freshRead($: EngineInterface, key: string): Promise<StoreRead> {
217  const stored = runtime.isStoreReadOnly ? { isRead: true, value: undefined } : await storeGet($, key)
218  if (!stored.isRead) $.ui.toast(tr()('toast.saveFailed'))
219  return stored
220}
221
222// The calls above as the feature modules make them (hooks/notes-run.ts, export-run.ts).
223function storeIo($: EngineInterface): StoreIo {
224  const [get, fresh] = [(key: string) => storeGet($, key), (key: string) => freshRead($, key)]
225  return { get, fresh, set: (key, value) => storeSet($, key, value), save: (key, value) => panelSave($, key, value), attempt: (what, call, fallback) => attempt($, what, call, fallback) }
226}
227
228// The buttons to change: the store's, else this session's; null when the re-read failed.
229async function freshButtons($: EngineInterface): Promise<ButtonConfig | null> {
230  const stored = await freshRead($, 'buttons')
231  return stored.isRead ? (cleanButtonConfig(stored.value) ?? read($, buttonsState)) : null
232}
233
234// Copies the store into `$.state`: at start, and after /clear, /resume and /branch reset the state.
235async function loadStore($: EngineInterface): Promise<void> {
236  // First, so a band drawn after /clear meanwhile is not drawn unfolded.
237  await attempt($, 'store.get bandFolded', loadFold(engineOf($)), undefined)
238  await attempt($, 'clipboard restore', restoreClips(engineOf($)), undefined)
239  const [version, buttons, layout] = [await storeGet($, 'schemaVersion'), await storeGet($, 'buttons'), await storeGet($, 'layout')]
240  const plan = planLoad(version, buttons, layout, defaultButtonConfig(tr()), runtime.isStoreReadOnly)
241  const snapshot = plan.snapshot
242  runtime = { ...runtime, isStoreReadOnly: plan.isReadOnly }
243  if (plan.isReadOnly) await noteError($, 'status.storeNewer')
244  // The version goes last, so a write that fails halfway is tried again at the next start.
245  if (plan.shouldWrite) for (const key of ['buttons', 'layout', 'schemaVersion'] as const) await storeSet($, key, snapshot[key])
246  const normalized = normalizeLayout(snapshot.layout, { state: { buttons: snapshot.buttons } })
247  const pomodoro = asPomodoro((await storeGet($, 'pomodoro')).value)
248  const isHidden = (await storeGet($, 'bandHidden')).value === true
249  const icons = await storeGet($, 'icons')
250  await update($, buttonsState, () => snapshot.buttons)
251  await update($, layoutState, () => normalized)
252  await update($, pomodoroState, current => mergePomodoro(current, pomodoro))
253  await update($, bandHiddenState, () => isHidden)
254  await loadExportFormat(engineOf($), storeIo($))
255  if (icons.isRead) await update($, iconsState, () => asIcons(icons.value))
256}
257
258// ---- Clock and pomodoro: state is written only when the drawn text changes ----
259async function refreshClock($: EngineInterface, now: number): Promise<void> {
260  const { text, degraded } = clockLabel(now, clockFormatOptions(runtime.options, runtime.lang.locale), formatClock)
261  if (text === (await read($, clockLabelState))) return
262  await update($, clockLabelState, () => text)
263  // Intl failed and the local time stands in: the status tab says so, once a load, and a later error stays shown.
264  if (!degraded || runtime.hasClockNote) return
265  runtime = { ...runtime, hasClockNote: true }
266  await noteError($, 'clock.noTimeZone')
267}
268
269async function refreshPomodoro($: EngineInterface, now: number): Promise<void> {
270  const current = await read($, pomodoroState)
271  const result = advancePomodoro(current, now, { config: pomodoroConfig(runtime.options), t: tr() })
272  if (result.state === current) return
273  if (result.ended === null) return void (await update($, pomodoroState, latest => (latest.updatedAt === current.updatedAt ? result.state : latest)))
274  // Each session sees the end; one finding a newer copy saved takes it and only toasts (notify and sound once).
275  const stored = asPomodoro((await storeGet($, 'pomodoro')).value)
276  if (stored === null || stored.updatedAt <= current.updatedAt) return runEffects($, [...pomodoroEffects(result.state), ...result.effects])
277  await update($, pomodoroState, () => stored)
278  await runEffects($, result.effects.filter(effect => effect.kind === 'ui.toast'))
279}
280
281async function tick($: EngineInterface): Promise<void> {
282  const now = await attempt($, 'clock.now', () => $.clock.now(), null)
283  if (now === null) return
284  await attempt($, 'clock tick', () => refreshClock($, now), undefined)
285  await attempt($, 'pomodoro tick', () => refreshPomodoro($, now), undefined)
286  if (runtime.isActive && isClipsTabOn(runtime.options)) await attempt($, 'clipboard tick', noteSelection(engineOf($), now), undefined)
287}
288
289async function syncPomodoro($: EngineInterface): Promise<void> {
290  const current = await read($, pomodoroState)
291  const merged = mergePomodoro(current, asPomodoro((await storeGet($, 'pomodoro')).value))
292  if (merged !== current) await update($, pomodoroState, latest => mergePomodoro(latest, merged))
293}
294
295// The timer as a press or a command sees it: this session's copy or a newer saved one.
296async function currentPomodoro($: EngineInterface) {
297  return mergePomodoro(await read($, pomodoroState), asPomodoro((await storeGet($, 'pomodoro')).value))
298}
299
300async function changePomodoro($: EngineInterface, verb: PomodoroVerb): Promise<void> {
301  const now = await $.clock.now()
302  const current = await currentPomodoro($)
303  const config = pomodoroConfig(runtime.options)
304  const next = verb === 'start' ? start(current, now, config) : verb === 'pause' ? pause(current, now) : verb === 'reset' ? reset(now) : stop(current, now)
305  await runEffects($, pomodoroEffects(next))
306}
307
308// A reload drops the old module's timers; one left from an earlier start in this module is cancelled.
309function startTimers($: EngineInterface): void {
310  for (const timer of runtime.timers) timer.cancel()
311  const timers = [
312    $.clock.every(TICK_MS, () => void tick($)),
313    $.clock.every(SYNC_MS, () => void attempt($, 'pomodoro sync', () => syncPomodoro($), undefined)),
314    ...(weatherCity(runtime.options) === null ? [] : [$.clock.every(weatherRefreshMs(runtime.options), () => askWeatherInBackground($))]),
315  ]
316  runtime = { ...runtime, timers }
317}
318
319// ---- Weather: one city's forecast, saved in the store and drawn from `$.state` ----
320const weatherLook = (): WeatherLook => ({ t: tr(), locale: runtime.lang.locale, style: iconStyleOf(runtime.options.iconStyle) === 'label' ? 'text' : 'emoji' })
321
322// The one request this mod makes: the coordinates and the unit, nothing the person wrote.
323function fetchWeather($: EngineInterface, target: WeatherTarget): Promise<HttpResponse | null> {
324  return attempt($, 'http.fetch', (): Promise<HttpResponse | null> => $.http.fetch(forecastUrl(target), { headers: { Accept: 'application/json' } }), null)
325}
326
327// `☁ --` for a failed ask, and a toast once per load; the next period asks again.
328async function weatherFailed($: EngineInterface, why: string): Promise<void> {
329  await noteError($, 'error.call', { call: 'weather forecast', reason: why })
330  await update($, weatherState, () => failedWeather(weatherLook()))
331  if (runtime.hasWeatherToast) return
332  runtime = { ...runtime, hasWeatherToast: true }
333  $.ui.toast(tr()('toast.weatherFailed'))
334}
335
336async function askWeather($: EngineInterface, target: WeatherTarget): Promise<void> {
337  await update($, weatherState, loadingWeather)
338  const reply = await fetchWeather($, target)
339  // A city or unit changed while the ask was out: its answer is no one's, neither saved over the new city's copy nor
340  // drawn nor told as a failure; refreshWeather asks for the new one next.
341  if (!sameTarget(target, weatherTarget(runtime.options))) return
342  const current = reply?.ok === true ? parseForecast(reply.text) : null
343  if (current === null) return weatherFailed($, reply === null ? 'request failed' : reply.ok ? 'answer unreadable' : `answered ${reply.status}`)
344  const mine: WeatherCache = { ...target, fetchedAt: await $.clock.now(), current }
345  // Every session shares the saved copy: read it again just before the write and keep a newer one for this city.
346  const kept = newerCache(mine, asWeatherCache((await storeGet($, 'weatherCache')).value))
347  if (kept === mine && !runtime.isStoreReadOnly) await attempt($, 'store.set weatherCache', () => $.store.set('weatherCache', mine), undefined)
348  await update($, weatherState, () => shownWeather(kept, weatherLook()))
349}
350
351// One ask at a time: a second one wanted while another is out (a start with a /clear, a command) is dropped.
352async function refreshWeather($: EngineInterface): Promise<void> {
353  const target = weatherTarget(runtime.options)
354  if (target === null || runtime.isFetchingWeather) return
355  runtime = { ...runtime, isFetchingWeather: true }
356  try {
357    await askWeather($, target)
358  } finally {
359    runtime = { ...runtime, isFetchingWeather: false }
360  }
361  if (!sameTarget(target, weatherTarget(runtime.options))) await refreshWeather($)
362}
363
364// A slow network must not hold a hook, a timer or a command, so nothing waits for the ask.
365function askWeatherInBackground($: EngineInterface): void {
366  void attempt($, 'weather refresh', () => refreshWeather($), undefined)
367}
368
369// At start and after /clear: a saved forecast for this city and unit that is young enough is drawn as it is, else a
370// new one is asked for. ponytail: the timer's first ask is a whole period after the start, so a forecast taken from
371// the store can grow to twice the period; schedule that ask at the saved copy's expiry if that matters.
372async function loadWeather($: EngineInterface): Promise<void> {
373  const target = weatherTarget(runtime.options)
374  if (target === null) return void (await update($, weatherState, () => offWeather()))
375  const saved = asWeatherCache((await storeGet($, 'weatherCache')).value)
376  if (isFresh(saved, target, await $.clock.now(), weatherRefreshMs(runtime.options))) {
377    return void (await update($, weatherState, () => shownWeather(saved, weatherLook())))
378  }
379  // A city changed in /config must not keep the old city's label while the new one is on its way.
380  await update($, weatherState, () => loadingWeather(offWeather()))
381  askWeatherInBackground($)
382}
383
384// ---- Session start, language and commands ----
385async function resolveLang($: EngineInterface): Promise<ResolvedLanguage> {
386  const setting = typeof runtime.options.language === 'string' ? runtime.options.language : 'auto'
387  const env = await attempt($, 'env.get', async () => [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')], [])
388  return resolveLanguage(setting, env, systemLocale())
389}
390
391async function listCommands($: EngineInterface): Promise<CommandInfo[] | null> {
392  const list = await attempt($, 'command.list', (): Promise<CommandInfo[] | null> => $.command.list(), null)
393  if (list !== null) runtime = { ...runtime, knownCommands: list.map(command => command.name) }
394  if (list !== null) await update($, voiceState, voice => ({ ...voice, isAvailable: list.some(command => command.name === 'voice') }))
395  return list
396}
397
398// The status tab's settings (hooks/settings.ts), a failed read keeping the last; kept store changes go to /config.
399// A change /config refused or a locked row holds is named, and the mod runs with /config's value again.
400async function refreshSettings($: EngineInterface): Promise<void> {
401  await attempt($, 'config.list', listSettings(engineOf($), runtime.options), undefined)
402  const rows = await readSettings(engineOf($))
403  if (runtime.isStoreReadOnly || rows.length === 0 || (await readSettingsSource(engineOf($))) !== 'engine') return
404  const result = await attempt($, 'settings move', moveOverrides(engineOf($), runtime.baseOptions, rows), { refused: [], moved: 0, failed: [] })
405  // One that failed stays in the store for the next try, and the status tab says why.
406  if (result.failed.length > 0) await noteError($, 'error.call', { call: 'settings move', reason: result.failed.join('; ') })
407  if (result.refused.length === 0) return
408  $.ui.toast(tr()('toast.settingsNotMoved', { names: result.refused.join(', ') }))
409  // A change written to /config reloads the module, which applies the settings again; with none written, it is done here.
410  if (result.moved === 0) await applySettings($)
411}
412
413async function pickSetting($: EngineInterface, key: string, value: string): Promise<void> {
414  await previewSound(engineOf($), key, value)
415  let change: SettingChange
416  try {
417    change = await changeSetting(engineOf($), key, value, runtime.options)
418  } catch (err) {
419    await dropSoundPick(engineOf($))
420    throw err
421  }
422  if (change.kind === 'own') return saveOwnSetting($, change.field, change.value)
423  if (change.kind !== 'denied') return
424  await dropSoundPick(engineOf($))
425  return $.ui.toast(tr()('toast.settingRefused', { reason: change.reason }))
426}
427
428// A change to the mod's own rows (hooks/own-settings.ts): kept first, then applied as a load applies one, as no reload
429// follows. A prompts tab turned on reads the conversation again: nothing noted it while the tab was off.
430async function saveOwnSetting($: EngineInterface, field: string, value: ConfigValue): Promise<void> {
431  const saving = runtime.isStoreReadOnly ? null : saveOverride(engineOf($), runtime.baseOptions, field, value)
432  const isSaved = saving !== null && (await attempt($, 'settings save', saving.then(() => true), false))
433  if (!isSaved) return void (await dropSoundPick(engineOf($)), $.ui.toast(tr()(runtime.isStoreReadOnly ? 'status.storeNewer' : 'toast.saveFailed')))
434  const wasToc = isTocLive()
435  await applySettings($)
436  if (!wasToc && isTocLive()) await refillTranscript(engineOf($), !runtime.isStoreReadOnly)
437  // As a start says it after a /config change.
438  if (field === 'timeZone') warnBadTimeZone($)
439}
440
441// A zone this computer's clock data lacks draws the clock in the computer's own zone.
442function warnBadTimeZone($: EngineInterface): void {
443  const zone = configuredTimeZone(runtime.options)
444  if (zone !== '' && !isValidTimeZone(zone)) $.ui.toast(tr()('clock.badTimeZone', { zone }))
445}
446
447// /toolbelt always; /tb only while no one else owns that name. Registering a name again replaces it.
448async function registerCommands($: EngineInterface): Promise<void> {
449  const t = tr()
450  const list = await listCommands($)
451  await refreshSettings($)
452  // immediate: typed while a turn runs, /toolbelt runs at once (it used to reach the model instead).
453  // Its hook keeps to the mod's own state, not the turn's, as the flag asks.
454  const main = () => $.command.register({ name: 'toolbelt', description: t('command.description'), argumentHint: ARGUMENT_HINT, immediate: true })
455  await attempt($, 'command.register toolbelt', main, undefined)
456  const owner = list?.find(command => command.name === 'tb')
457  if (owner !== undefined && owner.plugin !== 'oh-my-toolbelt' && !runtime.ownsAlias) return
458  const alias = () => $.command.register({ name: 'tb', description: t('command.aliasDescription'), argumentHint: ARGUMENT_HINT, immediate: true })
459  // Awaited before `runtime` is spread, so a start and a /clear running together keep each other's fields.
460  const ownsAlias = await attempt($, 'command.register tb', () => alias().then(() => true), false)
461  runtime = { ...runtime, ownsAlias }
462}
463
464// Timers first, so a failure below cannot leave the clock and the pomodoro stopped. After /clear this
465// also revives a timer whose period the host refused; the old handles are cancelled first.
466async function reloadSession($: EngineInterface): Promise<void> {
467  startTimers($)
468  await loadStore($)
469  await syncNotes(engineOf($), storeIo($))
470  await refreshClock($, await $.clock.now())
471  await registerCommands($)
472  await attempt($, 'weather load', () => loadWeather($), undefined)
473}
474
475// A voice mode changed in the status tab or /config takes over at once while the voice this mod turned on is on: the
476// change reloads the module, which stops what the old load had begun (the sound), so the new load runs /voice <mode>,
477// from a timer as the band's buttons and timers run commands. Only when the mode differs.
478async function syncVoiceMode($: EngineInterface): Promise<void> {
479  const mode = voiceModeToSync(await read($, voiceState), voiceModeOf(runtime.options.voiceMode))
480  if (mode !== null) $.clock.after(VOICE_SYNC_MS, () => attempt($, 'voice sync', () => runEffect($, { kind: 'voice', next: mode }), undefined))
481}
482
483// A load's work with the settings, done again by a change to the mod's own rows; returns how many store changes stand.
484async function applySettings($: EngineInterface): Promise<number> {
485  const kept = await attempt($, 'settings load', loadOverrides(engineOf($), runtime.baseOptions), { options: runtime.baseOptions, count: 0 })
486  runtime = { ...runtime, options: kept.options }
487  const lang = await resolveLang($)
488  runtime = { ...runtime, lang }
489  await reloadSession($)
490  // The band and the panel draw from the options, which no state they read follows (a changed icon style did not show).
491  $.ui.invalidate('ui.render')
492  // Neither may keep the session from starting.
493  await attempt($, 'sound pick', playPickedSound(engineOf($)), undefined)
494  await attempt($, 'voice sync', () => syncVoiceMode($), undefined)
495  return kept.count
496}
497
498// A reload in the desktop app (a settings file change) starts again with isInteractive false and no surface, the desktop
499// still attached (in the real engine the toolbelt vanished), so the attached surfaces count too.
500async function startIfActive($: EngineInterface, e: { isInteractive: boolean; surface: RenderSurface | null }): Promise<void> {
501  const isActive = e.isInteractive || e.surface !== null || (await $.session.surfaces()).length > 0
502  runtime = { ...runtime, isActive }
503  if (isActive) await startSession($)
504}
505
506async function startSession($: EngineInterface): Promise<void> {
507  // ponytail: kept store changes go to /config one look later (rows list ~4 s on); a slower load leaves them to the panel
508  if ((await applySettings($)) > 0 && (await $.session.surfaces()).includes('terminal')) $.clock.after(MOVE_SETTINGS_MS, () => void attempt($, 'settings refresh', () => refreshSettings($), undefined))
509  warnBadTimeZone($)
510  // Not a failure the status tab names: the versions line is left out.
511  const versions = await readVersions(engineOf($)).catch((err: unknown) => ($.ui.log(`oh-my-toolbelt: versions unread: ${errorText(err)}`, { to: 'debug' }), null))
512  runtime = { ...runtime, versions }
513  if (runtime.options.paneOpenOnStart === true) await openPaneAtStart($)
514  // The prompts sent before this load, from the transcript file a SessionStart or the store names.
515  if (isTocTabOn(runtime.options)) await fillTranscript(engineOf($), !runtime.isStoreReadOnly)
516}
517
518// ---- Effects: the one place a tool's wish becomes a `$` call ----
519// True when the command ran; a refusal says why in a toast.
520async function runCommand($: EngineInterface, command: string, args: string | undefined): Promise<boolean> {
521  const ran = await attempt($, `command.run ${command}`, () => $.command.run({ command, ...(args === undefined ? {} : { args }) }), null)
522  if (ran !== null) return true
523  const known = runtime.knownCommands
524  $.ui.toast(tr()(known !== null && !known.includes(command) ? 'toast.commandMissing' : 'toast.commandRejected', { command }))
525  return false
526}
527
528// A second press of the button that opened a tab closes the panel; from another tab it moves to this one.
529async function togglePane($: EngineInterface, tab: PaneTab): Promise<void> {
530  const panes = await attempt($, 'ui.panes', () => $.ui.panes(), [])
531  if (!panes.some(pane => pane.id === PANE_ID && pane.isShown)) return openPane($, tab)
532  if ((await read($, paneTabState)) === tab) return $.ui.close({ id: PANE_ID })
533  // Opened again, not only switched, so it asks for the keys as a first open does (notes pressed over another tab used
534  // to leave the cursor in the prompt box).
535  return openPane($, tab)
536}
537
538// The notes tab shows what the store holds now: another session may have saved a note since this one read them.
539async function showTab($: EngineInterface, tab: PaneTab): Promise<void> {
540  if (tab === 'notes') await syncNotes(engineOf($), storeIo($))
541  await update($, openPickerState, () => null)
542  await update($, paneTabState, () => tab)
543}
544
545async function openPane($: EngineInterface, tab: PaneTab): Promise<void> {
546  await showTab($, tab)
547  await listCommands($)
548  await refreshSettings($)
549  const placed = await $.ui.open({ id: PANE_ID, title: tr()('pane.title'), focus: true, closeOnEscape: true, rows: PANE_ROWS })
550  if (!placed.isPlaced) await noteError($, 'error.paneNotPlaced', { reason: placed.reason })
551  // The desktop app gives the keys to the panel but not to its autoFocus field, so notes move them.
552  else if (tab === 'notes') await attempt($, 'ui.focus', () => $.ui.focus({ requestId: PANE_ID, key: 'note-input' }), undefined)
553}
554
555// Asks for no keyboard focus, and shows the contents tab when there is one. An unrequested pane waits for a wide
556// screen, so `isPlaced: false` is not worth a word. A reload starts the session again but keeps `$.state`, so the panel
557// opens once a run: a tab moved to, or a panel closed, stays as the person left it.
558async function openPaneAtStart($: EngineInterface): Promise<void> {
559  if (await read($, paneStartedState)) return
560  await update($, paneStartedState, () => true)
561  await update($, paneTabState, () => (isTocTabOn(runtime.options) ? 'toc' : 'tools'))
562  await attempt($, 'ui.open', () => $.ui.open({ id: PANE_ID, title: tr()('pane.title'), rows: PANE_ROWS }), null)
563}
564
565async function runEffect($: EngineInterface, effect: Effect): Promise<void> {
566  const t = tr()
567  switch (effect.kind) {
568    case 'prompt.fill':
569      return (await $.prompt.fill({ text: effect.text, mode: effect.mode })).isFilled ? undefined : $.ui.toast(t('toast.fillRejected'))
570    case 'prompt.submit': {
571      const sent = await $.prompt.submit({ text: effect.text, ...(effect.asUser ? { asUser: true as const } : {}) })
572      if (sent.drop !== undefined) return $.ui.toast(t('toast.promptRejected'))
573      // The send button's prompt is the person's words (`asUser`), so curtainRestoreOnSubmit lowers the curtain for it
574      // as for one typed. 2.1.310 runs none of this mod's prompt.submit or ui.render hooks on its own submit (only
575      // session.append, where the prompts tab lists it), so it is judged here.
576      if (effect.asUser && runtime.options.curtainRestoreOnSubmit === true) await changeCurtain(engineOf($), () => false)
577      return
578    }
579    case 'command.run': return void (await runCommand($, effect.command, effect.args))
580    // The button follows a /voice that ran; one refused goes for this session.
581    case 'voice': {
582      const ran = await runCommand($, 'voice', effect.next)
583      // /voice only turns voice on; Space records, so the toast says how, for the mode.
584      if (ran && effect.next !== 'off') $.ui.toast(t(`toast.voiceOn.${effect.next}`), { timeoutMs: EXPORT_TOAST_MS })
585      return void (await update($, voiceState, voice => (ran ? { ...voice, requested: effect.next } : { ...voice, isAvailable: false })))
586    }
587    // Only the theme button sets a /config row through an effect.
588    case 'config.set': {
589      if (effect.key !== 'theme') return noteError($, 'error.effectUnavailable', { effect: `config.set ${effect.key}` })
590      const deny = await setTheme(engineOf($), String(effect.value))
591      return deny === undefined ? undefined : $.ui.toast(t('toast.themeRefused', { reason: deny }))
592    }
593    case 'ui.open': return openPane($, effect.tab)
594    case 'ui.toggle': return togglePane($, effect.tab)
595    case 'ui.close': return $.ui.close({ id: PANE_ID })
596    case 'ui.toast': return $.ui.toast(effect.text, effect.timeoutMs === undefined ? {} : { timeoutMs: effect.timeoutMs })
597    // Raised alone: the tool adds a toast for a bell without text or a desktop without a channel.
598    case 'notify':
599      return void (await $.ui.notify(effect.text, effect.title === undefined ? {} : { title: effect.title }))
600    // A sound that cannot play (not macOS, no file) stays quiet.
601    case 'audio.play':
602      return $.audio.play({ asset: effect.asset }).catch(() => undefined)
603    case 'state.update':
604      if (effect.key === 'curtain') return void (await changeCurtain(engineOf($), value => effect.fn(value) === true))
605      if (effect.key !== 'pomodoro') return noteError($, 'error.effectUnavailable', { effect: `state.update ${effect.key}` })
606      return void (await update($, pomodoroState, value => asPomodoro(effect.fn(value)) ?? value))
607    case 'store.set': return void (await storeSet($, effect.key, effect.value))
608    case 'export': return $.ui.toast(await exportOutcome(engineOf($), storeIo($), exportEnv(), effect.format), { timeoutMs: EXPORT_TOAST_MS })
609    case 'attach': return runAttach(engineOf($), t)
610    case 'log': return $.ui.log(effect.text, { to: 'debug' })
611    // A kind added to Effect without a case fails to compile here, and one that reaches it anyway leaves a line why.
612    default: {
613      const unhandled: never = effect
614      return noteError($, 'error.effectUnavailable', { effect: String((unhandled as { kind?: unknown }).kind) })
615    }
616  }
617}
618
619async function runEffects($: EngineInterface, effects: readonly Effect[]): Promise<void> {
620  for (const effect of effects) await attempt($, effect.kind, () => runEffect($, effect), undefined)
621}
622
623// ---- Presses and the panel: a press reads state now, never what a drawing captured ----
624async function pressItem($: EngineInterface, id: string, surface: RenderSurface): Promise<void> {
625  if (id === FOLD_ID || id === UNFOLD_ID) return void (await setBandFolded(engineOf($), id === FOLD_ID), await storeSet($, FOLDED_KEY, id === FOLD_ID))
626  const state = { ...emptySnapshot(), buttons: await read($, buttonsState), layout: await read($, layoutState), pomodoro: await currentPomodoro($), curtain: await isCurtainOn(engineOf($)), themeRow: await readThemeRow(engineOf($)), voice: await read($, voiceState) }
627  const ctx = makeCtx(runtime, surface, state, await $.clock.now())
628  await runEffects($, toolForItem(id, ctx)?.onPress?.(ctx, { surface }) ?? [])
629}
630
631// True once the changed layout is saved; until then nothing changes.
632async function changeLayout($: EngineInterface, surface: RenderSurface, change: (layout: Layout, ctx: Ctx) => Layout, buttons?: ButtonConfig): Promise<boolean> {
633  const current = buttons ?? (await freshButtons($))
634  const stored = current === null ? null : await freshRead($, 'layout')
635  if (current === null || stored === null || !stored.isRead) return false
636  const base = normalizeLayout(stored.value === undefined ? await read($, layoutState) : stored.value, { state: { buttons: current } })
637  const shownBy = { themeRow: await readThemeRow(engineOf($)), voice: await read($, voiceState) }
638  const next = change(base, makeCtx(runtime, surface, { ...emptySnapshot(), buttons: current, layout: base, ...shownBy }, await $.clock.now()))
639  if (!(await panelSave($, 'layout', next))) return false
640  await update($, layoutState, () => next)
641  return true
642}
643
644// A pick from a layout row's icon list: a fixed tool's goes to the shared `icons`, a button's into its own `icon`.
645async function changeIcon($: EngineInterface, surface: RenderSurface, id: string, value: string): Promise<void> {
646  if (id.startsWith(BUTTON_PREFIX)) {
647    const buttons = await freshButtons($)
648    const next = buttons === null ? null : setButtonIcon(buttons, id.slice(BUTTON_PREFIX.length), value)
649    if (next !== null) await saveButtons($, surface, next)
650    return
651  }
652  const stored = await freshRead($, 'icons')
653  const next = stored.isRead ? withToolIcon(asIcons(stored.value), id, value) : null
654  if (next !== null && (await panelSave($, 'icons', next))) await update($, iconsState, () => next)
655}
656
657// Saving buttons also tidies the layout: a new button joins the right end, a deleted one leaves.
658// True once the buttons are saved; a tidy that fails is done again by the next load.
659async function saveButtons($: EngineInterface, surface: RenderSurface, next: ButtonConfig): Promise<boolean> {
660  if (!(await panelSave($, 'buttons', next))) return false
661  await update($, buttonsState, () => next)
662  await changeLayout($, surface, layout => layout, next)
663  return true
664}
665
666async function saveDraft($: EngineInterface, surface: RenderSurface): Promise<void> {
667  const draft = await read($, editDraftState)
668  const buttons = draft === null ? null : await freshButtons($)
669  if (draft === null || buttons === null) return
670  const first = draftIssues(draft, buttons, runtime.knownCommands).errors[0]
671  if (first !== undefined) return $.ui.toast(tr()(first.key, first.args))
672  const id = draft.id
673  const isEdit = id !== undefined && allButtons(buttons).some(b => b.id === id)
674  const button = buildButton(draft, isEdit ? id : nextButtonId(buttons, await $.clock.now()))
675  if (!(await saveButtons($, surface, isEdit ? updateButton(buttons, button) : addButton(buttons, button)))) return
676  await update($, editDraftState, () => null)
677  $.ui.toast(tr()('toast.buttonSaved'))
678}
679
680async function deleteButton($: EngineInterface, surface: RenderSurface, id: string): Promise<void> {
681  const buttons = await freshButtons($)
682  if (buttons === null || !(await saveButtons($, surface, removeButton(buttons, id)))) return
683  await update($, editDraftState, draft => (draft?.id === id ? null : draft))
684  $.ui.toast(tr()('toast.buttonDeleted'))
685}
686
687// Sets: tools/user.ts decides what a press changes and says; the save closes an open form.
688// True when the sets changed and were saved.
689async function changeSets($: EngineInterface, surface: RenderSurface, plan: (buttons: ButtonConfig) => SetChange): Promise<boolean> {
690  const buttons = await freshButtons($)
691  if (buttons === null) return false
692  const { next, say } = plan(buttons)
693  if (next !== undefined && !(await saveButtons($, surface, next))) return false
694  if (next !== undefined) await update($, editDraftState, () => null)
695  if (say !== undefined) $.ui.toast(tr()(say.key, say.args))
696  return next !== undefined
697}
698
699async function runPaneAction($: EngineInterface, action: PaneAction, surface: RenderSurface): Promise<void> {
700  switch (action.kind) {
701    case 'tab': return showTab($, action.tab)
702    case 'press': return pressItem($, action.id, surface)
703    case 'toc.go': return scrollToPrompt(engineOf($), action.requestId, tr())
704    case 'toc.view': return action.op === 'export' ? exportAnswer($) : viewTocRow(engineOf($), action.op, action.key, tr())
705    case 'note.add': return addQuickNote(engineOf($), storeIo($), action.text, tr())
706    case 'note.fill': return runEffects($, await noteFillEffects(engineOf($), action.id))
707    case 'note.delete': return deleteQuickNote(engineOf($), storeIo($), action.id)
708    case 'clip.fill': return runEffects($, await clipEffects(engineOf($), action.id, tr()('toast.clipGone')))
709    case 'clip.delete': return deleteClip(engineOf($), action.id)
710    case 'export.format': return rememberFormat(engineOf($), storeIo($), action.format)
711    case 'layout':
712      return void (await changeLayout($, surface, (layout, ctx) => applyLayoutOp(layout, action.op, action.id, id => isItemShown(id, ctx))))
713    case 'layout.reset':
714      if (await changeLayout($, surface, (_, ctx) => resetLayout(ctx.state.buttons))) $.ui.toast(tr()('toast.layoutReset'))
715      return
716    case 'icon.set': return changeIcon($, surface, action.id, action.value)
717    case 'icon.group': return void (await update($, iconGroupState, () => asIconGroup(action.group)))
718    case 'button.delete': return deleteButton($, surface, action.id)
719    case 'draft.open': {
720      const draft = draftFor(await read($, buttonsState), action.id)
721      await update($, openPickerState, () => null)
722      return void (await update($, editDraftState, () => draft))
723    }
724    case 'draft.field':
725      return void (await update($, editDraftState, draft => (draft === null ? null : setDraftField(draft, action.field, action.value))))
726    case 'draft.save': return saveDraft($, surface)
727    case 'draft.cancel': return void (await Promise.all([update($, editDraftState, () => null), update($, openPickerState, () => null)]))
728    case 'set.pick': return void (await changeSets($, surface, buttons => pickSetChange(buttons, action.name)))
729    // The name field closes once the set is made; a refused name keeps it open, the toast saying why.
730    case 'set.add': return void ((await changeSets($, surface, buttons => addSetChange(buttons, action.name, cellsOf))) && (await update($, openPickerState, () => null)))
731    case 'set.delete': return void (await changeSets($, surface, deleteSetChange))
732    case 'setting.set': return pickSetting($, action.key, action.value)
733    case 'guide.section': return void (await update($, guideSectionState, () => action.section))
734    case 'picker.open': return void (await update($, openPickerState, () => action.key))
735    case 'pomodoro': return changePomodoro($, action.verb).then(() => $.ui.toast(tr()(`command.pomodoro.${action.verb}`)))
736  }
737}
738
739// ---- The prompts tab: hooks/toc-live.ts keeps the table, the turns and where the person reads ----
740const isTocLive = (): boolean => runtime.isActive && isTocTabOn(runtime.options)
741
742// ---- Export (hooks/export-run.ts): the settings it writes with, and the desktop app's opened prompt row's answer ----
743const exportEnv = (): ExportEnv => ({ t: tr(), lang: runtime.lang.lang, clock: clockFormatOptions(runtime.options, runtime.lang.locale), exportDirectory: runtime.options.exportDirectory, pdfBrowser: runtime.options.pdfBrowser })
744
745async function exportAnswer($: EngineInterface): Promise<void> {
746  const only = await openedAnswer(engineOf($))
747  if (only !== null) $.ui.toast(await exportOutcome(engineOf($), storeIo($), exportEnv(), undefined, only), { timeoutMs: EXPORT_TOAST_MS })
748}
749
750// ---- /toolbelt and /tb ----
751async function runToolbelt($: EngineInterface, e: CommandRunInput): Promise<CommandRunResult> {
752  const t = tr()
753  const command = parseCommand(e.args)
754  if (command.op === 'toc') return moveToc(engineOf($), command.move, tr(), isTocLive())
755  if (command.op === 'open') await openPane($, command.tab)
756  const isPersonOnly = command.op === 'band' || command.op === 'curtain' || command.op === 'export'
757  if (isPersonOnly && !isHumanOrigin(e.origin)) return { text: `${t('command.error.humanOnly')} ${t('command.usage')}` }
758  if (command.op === 'export') return toastAndAnswer(engineOf($), await exportOutcome(engineOf($), storeIo($), exportEnv(), command.format))
759  if (command.op === 'band') {
760    await update($, bandHiddenState, () => command.isHidden)
761    await storeSet($, 'bandHidden', command.isHidden)
762  }
763  // The curtain flips through the band button's effects; asked for the state it is in, it changes and toasts nothing.
764  if (command.op === 'curtain' && command.isOn !== (await isCurtainOn(engineOf($)))) await runEffects($, curtainEffects(command.isOn, t))
765  if (command.op === 'pomodoro') await changePomodoro($, command.verb)
766  if (command.op === 'weather') {
767    if (weatherCity(runtime.options) === null) return { text: t('command.weather.off') }
768    askWeatherInBackground($)
769  }
770  const text = replyFor(command, t)
771  return text === undefined ? {} : { text }
772}
773
774// ---- Drawing reads: each site reads only what it draws, so it redraws only for that ----
775async function bandCtx($: EngineInterface, surface: RenderSurface): Promise<Ctx> {
776  const [clockLabel, pomodoro] = [await read($, clockLabelState), await read($, pomodoroState)]
777  const [weather, buttons, layout] = [await read($, weatherState), await read($, buttonsState), await read($, layoutState)]
778  const [curtain, icons, themeRow, voice] = [await isCurtainOn(engineOf($)), await read($, iconsState), await readThemeRow(engineOf($)), await read($, voiceState)]
779  // The band carries the prompts' cards, so it draws again as the prompts tab does.
780  if (isTocTabOn(runtime.options)) await watchToc(engineOf($))
781  return makeCtx(runtime, surface, { ...emptySnapshot(), clockLabel, pomodoro, weather, buttons, layout, curtain, icons, themeRow, voice }, await $.clock.now())
782}
783
784async function paneCtx($: EngineInterface, surface: RenderSurface): Promise<Ctx> {
785  const paneTab = await read($, paneTabState)
786  // The prompts tab draws again as where the person reads or a turn changes (toc-live's count).
787  if (paneTab === 'toc') await watchToc(engineOf($))
788  // Only the status tab draws the clock, the weather, the timer, the curtain button and the export format, and only
789  // the notes tab the notes, so each redraws only as its own values change.
790  const live = paneTab === 'status'
791    ? { clockLabel: await read($, clockLabelState), weather: await read($, weatherState), pomodoro: await read($, pomodoroState), curtain: await isCurtainOn(engineOf($)), exportFormat: await readExportFormat(engineOf($)) }
792    : paneTab === 'notes' ? { notes: await readNotes(engineOf($)) }
793    : paneTab === 'clips' ? { clipBoard: await readClipBoard(engineOf($)) }
794    : paneTab === 'guide' ? { guideSection: await read($, guideSectionState) } : {}
795  const [buttons, layout] = [await read($, buttonsState), await read($, layoutState)]
796  const [editDraft, lastError] = [await read($, editDraftState), await read($, lastErrorState)]
797  // Whether the theme and voice buttons exist: the status tab lists them and the tools tab lays them out.
798  const [themeRow, voice] = [await readThemeRow(engineOf($)), await read($, voiceState)]
799  const [icons, iconGroup, openPicker] = [await read($, iconsState), await read($, iconGroupState), await read($, openPickerState)]
800  return makeCtx(runtime, surface, { ...emptySnapshot(), ...live, paneTab, buttons, layout, icons, iconGroup, openPicker, editDraft, lastError, themeRow, voice }, await $.clock.now())
801}
802
803export const register: Register = (on, options) => {
804  runtime = { ...runtime, options, baseOptions: options }
805
806  // `claude -p` and SDK hosts get nothing: no command, no timer, no drawing.
807  on('session.start', async ($, e, next) => {
808    const starting = startIfActive($, e)
809    runtime = { ...runtime, starting }
810    await starting
811    return next(e)
812  }).catch(($, e, next) => (logHookFailure(engineOf($), 'session.start', next.error), next(e)))
813
814  // The desktop app hosts its Code tab's sessions through the SDK, so they start with isInteractive false and no
815  // surface (d.ts SessionStartInput); a person is there once a surface attaches (without this, /toolbelt was missing in
816  // the desktop app). The mod starts then, once; a -p run, which attaches nothing, stays off. A start still asking for
817  // the surfaces decides first, so the two cannot both start it, nor that start turn it off; it logs its own failure.
818  on('session.attach', async ($, e, next) => {
819    await runtime.starting.catch(() => undefined)
820    if (!runtime.isActive) {
821      runtime = { ...runtime, isActive: true }
822      await startSession($)
823    }
824    return next(e)
825  }).catch(($, e, next) => (logHookFailure(engineOf($), 'session.attach', next.error), next(e)))
826
827  // /clear, /resume and /branch reset `$.state` without a new session.start.
828  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
829    // Nothing the table holds is in the new, empty conversation. The panel is drawn again after the usual wait,
830    // when the old rows are gone and cannot be noted again.
831    if (e.source === 'clear') resetToc(engineOf($))
832    // These empty `$.state`, and the panel is not opened by itself after them, nor after a reload that follows.
833    await update($, paneStartedState, () => true)
834    if (runtime.isActive) await reloadSession($)
835    return next(e)
836  }).catch(($, e, next) => (logHookFailure(engineOf($), 'classic.SessionStart', next.error), next(e)))
837
838  // This `.catch` must not read `next`, or validate no longer sees a hook that answers its own command.
839  on('command.run', { command: ['toolbelt', 'tb'] }, ($, e) => runToolbelt($, e)).catch($ => (logHookFailure(engineOf($), 'command.run'), { text: tr()('command.failed') }))
840
841  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
842    const props = e.props
843    noteTocView(props.view.agentId)
844    // Read before the guards: a band drawn before session.start draws again when loadStore writes it.
845    if ((await read($, bandHiddenState)) || !runtime.isActive || props.hasSurvey || props.view.agentId !== undefined || props.maxRows < 1) return next(e)
846    runtime = { ...runtime, bandColumns: withBandColumns(engineOf($), runtime.bandColumns, e.surface, props.bodyColumns) }
847    // Folded, it reads nothing it does not draw, so the clock's tick leaves it be.
848    const isFolded = await isBandFolded(engineOf($))
849    const model = isFolded ? null : bandModel(await bandCtx($, e.surface), props.bodyColumns, runtime.knownCommands)
850    if (model === null && !isFolded) return next(e)
851    // Another mod's band goes below ours, one row shorter; if it fails, ours draws alone.
852    const other = await next({ ...e, props: { ...props, maxRows: Math.max(0, props.maxRows - 1) } }).catch(() => undefined)
853    const press = (id: string, surface: RenderSurface) => attempt($, `press ${id}`, () => pressItem($, id, surface), undefined)
854    if (model === null) return drawFolded($.ui.resolve(e), press, other)
855    return drawBand($.ui.resolve(e), model, press, other, tocBandCards(props.bodyColumns, tr()), props.bodyColumns)
856  }).catch(($, e, next) => (logHookFailure(engineOf($), 'ui.render AbovePrompt', next.error), next(e)))
857
858  on('ui.render', { component: 'Pane', requestId: 'oh-my-toolbelt' }, async ($, e, next) => {
859    // Read before the guard, as the band does: a panel kept open over a reload draws again.
860    const ctx = await paneCtx($, e.surface)
861    if (!runtime.isActive) return next(e)
862    noteTocView(e.props.view.agentId)
863    // Only the prompts tab draws the list, so only it works out where the person reads.
864    const toc = ctx.state.paneTab === 'toc' ? tocView() : { rows: [], hidden: 0, isElsewhere: false }
865    const isStatus = ctx.state.paneTab === 'status'
866    const model = {
867      ctx, language: `${runtime.lang.lang} (${runtime.lang.locale})`, versions: runtime.versions, knownCommands: runtime.knownCommands,
868      settings: isStatus ? await readSettings(engineOf($)) : [], settingsSource: await readSettingsSource(engineOf($)),
869      canSelect: e.surface === 'desktop' || (e.surface === 'terminal' && e.viewport?.isFullscreen === true),
870      isBandShort: isStatus && !(await read($, bandHiddenState)) && !(await isBandFolded(engineOf($))) && hidesButtonNames(ctx, runtime.bandColumns[e.surface], runtime.knownCommands),
871      toc, columns: e.props.bodyColumns, clock: clockFormatOptions(runtime.options, runtime.lang.locale),
872    }
873    const act = (action: PaneAction, surface: RenderSurface) => attempt($, `panel ${action.kind}`, () => runPaneAction($, action, surface), undefined)
874    return drawPane($.ui.resolve(e), model, act)
875  }).catch(($, e, next) => (logHookFailure(engineOf($), 'ui.render Pane', next.error), next(e)))
876
877  // The rows', the turns' and the transcript's hooks (hooks/row-hooks.ts, hooks/toc-hooks.ts): registered whatever the
878  // settings say, and asked at each event, so a change to the mod's own rows comes without a reload.
879  const rowsLive = () => ({ isTocLive: isTocLive(), restoresCurtain: runtime.options.curtainRestoreOnSubmit === true })
880  const turnsLive = () => ({ isActive: isTocLive(), isStoreReadOnly: runtime.isStoreReadOnly })
881  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => drawPromptRow(engineOf($), e, next, rowsLive)).catch(($, e, next) => (logHookFailure(engineOf($), 'ui.render UserMessage', next.error), next(e)))
882  on('ui.render', { component: ['AssistantMessage', 'ToolUse', 'ToolResult', 'ToolGroup', 'CommandOutput', 'TurnDuration', 'InfoNotice', 'ToolProgress'] }, async ($, e, next) => drawRow(engineOf($), e, next, rowsLive)).catch(($, e, next) => (logHookFailure(engineOf($), 'ui.render curtain', next.error), next(e)))
883  on('prompt.submit', async ($, e, next) => submitPrompt(engineOf($), e, next, rowsLive)).catch(($, e, next) => (logHookFailure(engineOf($), 'prompt.submit', next.error), next(e)))
884  // This SessionStart runs after the clear/resume/fork one above, so the session is loaded again (or /clear has emptied the table) first.
885  on('classic.SessionStart', async ($, e, next) => readTranscript(engineOf($), e, next, turnsLive)).catch(($, e, next) => (logHookFailure(engineOf($), 'classic.SessionStart transcript', next.error), next(e)))
886  on('session.receive', async ($, e, next) => receivePrompt(engineOf($), e, next, turnsLive)).catch(($, e, next) => (logHookFailure(engineOf($), 'session.receive', next.error), next(e)))
887  on('session.append', { door: ['prompt', 'response'] }, async ($, e, next) => appendRow(engineOf($), e, next, turnsLive)).catch(($, e, next) => (logHookFailure(engineOf($), 'session.append', next.error), next(e)))
888  on('turn.start', async ($, e, next) => startTurn(engineOf($), e, next, turnsLive)).catch(($, e, next) => (logHookFailure(engineOf($), 'turn.start', next.error), next(e)))
889  on('turn.complete', async ($, e, next) => completeTurn(engineOf($), e, next, turnsLive)).catch(($, e, next) => (logHookFailure(engineOf($), 'turn.complete', next.error), next(e)))
890}
891
892
hooks/band.tsx 196 lines
1// The band above the prompt: which items fit, and the tree that draws them.
2// Pure: register.tsx hands in the element table from `$.ui.resolve(e)` and the press callback.
3import type { Elements, RenderElement, RenderSurface } from 'claude-code'
4import { ACCENT } from './card'
5import { PILL_PADDING, chipCells, drawChip } from './chip'
6import type { Chip } from './chip'
7import { DEFAULT_GAPS, fitBand } from './lib/fit'
8import type { FitItem, FitSize } from './lib/fit'
9import { cellsOf, padCells } from './lib/width'
10import { isVoiceOn } from './tools/voice'
11import { BUTTON_PREFIX, TOOLS, isItemShown, priorityOf, toolForItem } from './tools/registry'
12import type { Ctx, Tool } from './tools/registry'
13import type { BandCard } from './tools/toc'
14import { activeButtons } from './tools/user'
15
16export type BandKit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
17export type PressItem = (id: string, surface: RenderSurface) => unknown
18
19export type BandItem = {
20  // The layout id: a tool id, or `btn:<id>` for a user button.
21  id: string
22  kind: 'text' | 'button'
23  label: string
24  hotkey?: string
25  // A command or skill this session does not have: drawn dim.
26  isDim: boolean
27  // The curtain while it is on, or voice: drawn in the accent colour. A running pomodoro shows in its chip's meter.
28  isActive: boolean
29  group: 'user' | 'tool'
30  // Information drawn as a chip (hooks/chip.tsx) in place of the label.
31  chip?: Chip
32}
33
34// The surface and Claude Code's theme pick the chips' look and tints; `size` is the room fitBand gave the items, and
35// `hidesNames` whether a button drawn lost its name to it.
36export type BandModel = { left: BandItem[]; right: BandItem[]; more: BandItem | null; size: FitSize; hidesNames: boolean; surface: RenderSurface; theme: string | undefined }
37
38// The ◂ mark at the band's left end folds it to one ▸ mark that unfolds it: each a bracketless button of one cell, the
39// fold mark and the gap after it away from the engine's [-] at the right end. A mark is the button's label, dim until
40// pointed at, as a desktop draws the label of a native button (d.ts Button).
41export const FOLD_ID = 'fold'
42export const UNFOLD_ID = 'unfold'
43const FOLD_MARK = '◂'
44const UNFOLD_MARK = '▸'
45
46type Entry = { item: (size: FitSize) => BandItem; fit: FitItem }
47
48// Every band button is drawn `[ label ]` (4 cells of chrome): a plain Button would print its hotkey as `o: label`, and
49// hotkeys are shown in the panel only. Information (every `text` tool) is a chip, which counts its own cells.
50const BUTTON_CHROME = 4
51const cellsOfItem = (item: BandItem, surface: RenderSurface): number =>
52  item.chip === undefined ? cellsOf(item.label) + BUTTON_CHROME : chipCells(item.chip) + (surface === 'terminal' ? 0 : PILL_PADDING)
53const GROUP_MARK = '❘'
54// The terminal parts the information with a dim rule and a space either side; a desktop sets its pills a cell apart.
55const INFO_RULE = '│'
56const LEFT_COLUMN_GAP = 1
57const gapsOf = (surface: RenderSurface) => ({ ...DEFAULT_GAPS, left: surface === 'terminal' ? cellsOf(INFO_RULE) + 2 * LEFT_COLUMN_GAP : LEFT_COLUMN_GAP })
58// A desktop draws even a plain button in a native frame, counted as the bracketed buttons are.
59const foldCells = (surface: RenderSurface) => cellsOf(FOLD_MARK) + (surface === 'terminal' ? 0 : BUTTON_CHROME) + LEFT_COLUMN_GAP
60
61function isMissingCommand(id: string, ctx: Ctx, knownCommands: readonly string[] | null): boolean {
62  if (knownCommands === null || !id.startsWith(BUTTON_PREFIX)) return false
63  const button = activeButtons(ctx.state.buttons).find(b => BUTTON_PREFIX + b.id === id)
64  return button !== undefined && button.kind !== 'prompt' && !knownCommands.includes(button.command ?? '')
65}
66
67function itemOf(id: string, tool: Tool, ctx: Ctx, size: FitSize, isDim: boolean): BandItem {
68  const hotkey = tool.hotkey?.(ctx)
69  const chip = tool.chip?.(ctx, size)
70  return {
71    id,
72    kind: tool.kind,
73    label: tool.label(ctx, size),
74    ...(hotkey === undefined ? {} : { hotkey }),
75    isDim,
76    isActive: (tool.id === 'curtain' && ctx.state.curtain) || (tool.id === 'voice' && isVoiceOn(ctx.state.voice)),
77    group: id.startsWith(BUTTON_PREFIX) ? 'user' : 'tool',
78    ...(chip === undefined ? {} : { chip }),
79  }
80}
81
82function entryOf(id: string, side: 'left' | 'right', order: number, ctx: Ctx, known: readonly string[] | null): Entry | null {
83  const tool = toolForItem(id, ctx)
84  if (tool === undefined || !isItemShown(id, ctx)) return null
85  const isDim = isMissingCommand(id, ctx, known)
86  const item = (size: FitSize) => itemOf(id, tool, ctx, size, isDim)
87  const [long, short] = [item('long'), item('short')]
88  // An empty label (the clock before its first tick) takes no room and draws nothing.
89  if (long.label === '') return null
90  return { item, fit: { id, side, order, priority: priorityOf(tool, ctx), longCells: cellsOfItem(long, ctx.surface), shortCells: cellsOfItem(short, ctx.surface), group: long.group } }
91}
92
93// What the band draws at this width, or null when it should yield to the rest of the chain:
94// too narrow or nothing to show.
95export function bandModel(ctx: Ctx, bodyColumns: number, knownCommands: readonly string[] | null): BandModel | null {
96  const { left, right } = ctx.state.layout
97  const entries = [
98    ...left.map((id, order) => entryOf(id, 'left', order, ctx, knownCommands)),
99    ...right.map((id, order) => entryOf(id, 'right', order, ctx, knownCommands)),
100  ].filter((entry): entry is Entry => entry !== null)
101  const more = itemOf('more', TOOLS.more, ctx, 'short', false)
102  const moreCells = cellsOfItem(more, ctx.surface)
103  const fitted = fitBand({ bodyColumns: bodyColumns - foldCells(ctx.surface), items: entries.map(entry => entry.fit), moreCells, gaps: gapsOf(ctx.surface) })
104  if (fitted === null || fitted.left.length + fitted.right.length + (fitted.hasMore ? 1 : 0) === 0) return null
105  const byId = new Map(entries.map(entry => [entry.fit.id, entry]))
106  const pick = (ids: readonly string[]): BandItem[] =>
107    ids.flatMap(id => {
108      const entry = byId.get(id)
109      return entry === undefined ? [] : [entry.item(fitted.size)]
110    })
111  // `icon name` drawn as `icon`, an icon holding no letter or digit: a button with no icon keeps a shortened word.
112  const hidesNames = fitted.size === 'short' && [...fitted.left, ...fitted.right].some(id => {
113    const [long, short] = [byId.get(id)?.item('long'), byId.get(id)?.item('short')]
114    return long?.kind === 'button' && short !== undefined && short.label !== '' && !/[\p{L}\p{N}]/u.test(short.label) && long.label.startsWith(`${short.label} `)
115  })
116  return { left: pick(fitted.left), right: pick(fitted.right), more: fitted.hasMore ? more : null, size: fitted.size, hidesNames, surface: ctx.surface, theme: ctx.state.themeRow?.value }
117}
118
119// Whether the band, drawn `columns` wide, is too narrow for a button's name (a band of icon-less buttons was once said
120// to show icons only).
121export const hidesButtonNames = (ctx: Ctx, columns: number | undefined, knownCommands: readonly string[] | null): boolean =>
122  bandModel(ctx, columns ?? 0, knownCommands)?.hidesNames === true
123
124function drawItem(kit: BandKit, model: BandModel, item: BandItem, onPress: PressItem): RenderElement {
125  const { Button, Text } = kit
126  if (item.chip !== undefined) {
127    const press = item.kind === 'button' ? { onPress: () => void onPress(item.id, model.surface) } : {}
128    return drawChip(kit, item.chip, { key: item.id, label: item.label, surface: model.surface, theme: model.theme, ...press })
129  }
130  const props = {
131    key: item.id,
132    label: item.label,
133    onPress: (press: { surface: RenderSurface }) => void onPress(item.id, press.surface),
134    // The desktop app's own Tab never reaches the band, so a letter there could not be pressed.
135    ...(item.hotkey === undefined || model.surface === 'desktop' ? {} : { hotkey: item.hotkey }),
136    ...(item.isDim ? { dimColor: true } : {}),
137    ...(item.isActive && model.surface !== 'terminal' ? { variant: 'primary' as const } : {}),
138  }
139  // On, on the terminal: the label in the toolbelt's accent (card.tsx), whatever the theme's accent is.
140  return item.isActive && model.surface === 'terminal' ? <Button {...props}><Text color={ACCENT} weight="semibold">{item.label}</Text></Button> : <Button {...props} />
141}
142
143// One row: information on the left, buttons on the right, `…` at the right end when anything was hidden. Another mod's
144// band, when given, goes below it. The prompts tab's cards lie hidden over the row, each padded to the band's width so
145// it covers the row while a pointer on its panel row reveals it. A desktop lets the row show through a card, so there a
146// card is a row of its own above it, a row apart.
147export function drawBand(kit: BandKit, model: BandModel, onPress: PressItem, other?: RenderElement, cards: readonly BandCard[] = [], width = 0): RenderElement {
148  const { Box, Button, Text } = kit
149  const isOverlay = model.surface === 'terminal'
150  const cardBoxes = cards.map(card => (
151    <Box key={`band-card-${card.scope}`} {...(isOverlay ? { position: 'absolute' as const, top: 0, left: 0, width } : { marginBottom: 1 })} display="none" hover={{ scope: card.scope, display: 'flex' }}>
152      <Text wrap="truncate-end">{isOverlay ? padCells(card.text, width) : card.text}</Text>
153    </Box>
154  ))
155  const row = (
156    <Box key="band-row" flexDirection="row" justifyContent="space-between">
157      <Box key="band-left" flexDirection="row" flexShrink={0} columnGap={LEFT_COLUMN_GAP}>
158        <Button key={FOLD_ID} label={FOLD_MARK} plain dimColor onPress={press => void onPress(FOLD_ID, press.surface)} />
159        {model.left.flatMap((item, i) => [
160          ...(i > 0 && model.surface === 'terminal' ? [<Text dimColor>{INFO_RULE}</Text>] : []),
161          drawItem(kit, model, item, onPress),
162        ])}
163      </Box>
164      <Box key="band-right" flexDirection="row" flexShrink={0} columnGap={1}>
165        {model.right.flatMap((item, i) => {
166          const before = model.right[i - 1]
167          const mark = before !== undefined && before.group !== item.group ? [<Text dimColor>{GROUP_MARK}</Text>] : []
168          return [...mark, drawItem(kit, model, item, onPress)]
169        })}
170        {model.more === null ? null : drawItem(kit, model, model.more, onPress)}
171      </Box>
172      {isOverlay ? cardBoxes : null}
173    </Box>
174  )
175  if (other === undefined && isOverlay) return row
176  return (
177    <Box key="band" flexDirection="column">
178      {isOverlay ? null : cardBoxes}
179      {row}
180      {other ?? null}
181    </Box>
182  )
183}
184
185// The folded band: the ▸ mark that unfolds it, and another mod's band below it as drawBand puts it. The prompts tab's
186// cards wait for the band to unfold.
187export function drawFolded(kit: BandKit, onPress: PressItem, other?: RenderElement): RenderElement {
188  const { Box, Button } = kit
189  const row = (
190    <Box key="band-row" flexDirection="row">
191      <Button key={UNFOLD_ID} label={UNFOLD_MARK} plain dimColor onPress={press => void onPress(UNFOLD_ID, press.surface)} />
192    </Box>
193  )
194  return other === undefined ? row : <Box key="band" flexDirection="column">{row}{other}</Box>
195}
196
hooks/pane.tsx 640 lines
1// The Toolbelt panel: tabs, the layout rows, the button list and the edit form, the status tab with every tool, and the
2// guide. Pure: register.tsx hands in the element table from `$.ui.resolve(e)` and `act`, which turns a PaneAction into
3// `$` calls.
4import type { ConfigRow, Elements, RenderElement, RenderSurface } from 'claude-code'
5import type { ButtonConfig, ExportFormat, GuideSection, PaneTab, ToolButton } from '../types/index'
6import { drawGuide } from './guide'
7import { EXPORT_FORMATS, asExportFormat } from './lib/export'
8import { formatStamp } from './lib/format'
9import type { ClockOptions } from './lib/format'
10import { ICON_DEFAULT, ICON_NONE, ICON_TOOLS, OWN_ICONS, groupChoices, iconChoices, iconStyleOf } from './lib/icons'
11import { cellsOf, truncateCells } from './lib/width'
12import { NOTE_CELLS_MAX } from './tools/notes'
13import { isClipsTabOn } from './clips'
14import { CONFIG_PREFIX } from './runtime'
15import type { Versions } from './runtime'
16import { weatherRefreshMs } from './tools/weather'
17import { drawLabeledButton, drawPicker, pickerButton, pickerList } from './picker'
18import { ACCENT, ACCENT_TEXT, cardFrame, drawCard } from './card'
19import { pomodoroConfig, pomodoroProgress } from './tools/pomodoro'
20import type { Picker } from './picker'
21import { BUTTON_PREFIX, isItemShown, reservedHotkeys, toolForItem } from './tools/registry'
22import type { Ctx, LayoutOp } from './tools/registry'
23import { firstLine, isTocTabOn } from './tools/toc'
24import type { TocView, TocViewOp } from './tools/toc'
25import { TOC_CELLS_MIN, TOC_MARGIN, drawTocTab } from './toc-tab'
26import { activeButtons, validateButton } from './tools/user'
27import type { ButtonDraft, DraftField, Validation } from './tools/user'
28
29type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
30// Mobile draws no Input: there the form is left out. Lists are Buttons (hooks/picker.tsx), drawn everywhere.
31export type PaneKit = Kit & Partial<Pick<Elements['terminal'], 'Input'>>
32type FormKit = Kit & Pick<Elements['terminal'], 'Input'>
33
34export type PaneAction =
35  | { kind: 'tab'; tab: PaneTab }
36  | { kind: 'layout'; op: LayoutOp; id: string }
37  | { kind: 'layout.reset' }
38  // An icon picked for a layout row: `id` is the row's item id (a tool, or `btn:<id>`); the value an icon, "none" or "default".
39  | { kind: 'icon.set'; id: string; value: string }
40  // The group of icons the lists show (this session only).
41  | { kind: 'icon.group'; group: string }
42  | { kind: 'button.delete'; id: string }
43  // null opens an empty form for a new button.
44  | { kind: 'draft.open'; id: string | null }
45  | { kind: 'draft.field'; field: DraftField; value: string }
46  | { kind: 'draft.save' }
47  | { kind: 'draft.cancel' }
48  // A tool pressed from the status tab, as its band button does.
49  | { kind: 'press'; id: string }
50  // A prompt of the contents tab: bring its transcript row into view.
51  | { kind: 'toc.go'; requestId: string }
52  // The desktop app's opened prompt row (hooks/toc-live.ts viewTocRow), and its answer saved alone.
53  | { kind: 'toc.view'; op: TocViewOp | 'export'; key: string }
54  // The notes tab: save what was typed, put a note in the prompt box, delete a note.
55  | { kind: 'note.add'; text: string }
56  | { kind: 'note.fill'; id: string }
57  | { kind: 'note.delete'; id: string }
58  // The clipboard tab (hooks/clips.ts): put a clip in the prompt box, delete it.
59  | { kind: 'clip.fill'; id: string }
60  | { kind: 'clip.delete'; id: string }
61  // Button sets: show another set, make one (it starts empty and is shown), delete the one shown.
62  | { kind: 'set.pick'; name: string }
63  | { kind: 'set.add'; name: string }
64  | { kind: 'set.delete' }
65  // A /config row of this mod changed from the status tab; the value is a choice, or `true`/`false` for a toggle.
66  | { kind: 'setting.set'; key: string; value: string }
67  // The part of the guide to show.
68  | { kind: 'guide.section'; section: GuideSection }
69  // The status tab's export format: the band's export button saves in it from now on.
70  | { kind: 'export.format'; format: ExportFormat }
71  // The pick list to open (hooks/picker.tsx), or null to close the open one.
72  | { kind: 'picker.open'; key: string | null }
73  // The status tab's pomodoro reset, as /toolbelt pomodoro reset.
74  | { kind: 'pomodoro'; verb: 'reset' }
75
76export type Act = (action: PaneAction, surface: RenderSurface) => unknown
77
78export type PaneModel = {
79  ctx: Ctx
80  // How the status tab names the language in use, e.g. "ko (ko-KR)", and the versions running.
81  language: string
82  versions: Versions | null
83  knownCommands: readonly string[] | null
84  // The mod's own /config rows, which the status tab shows and changes, and whose they are: the engine's, or the mod's
85  // own from plugin.json where the engine lists none (the desktop app, hooks/own-settings.ts).
86  settings: readonly ConfigRow[]
87  settingsSource: 'engine' | 'own'
88  // The band is drawn at its short size and a button there shows its icon without its name.
89  isBandShort: boolean
90  // This surface tells the mod what the person selects: a terminal Claude Code draws in full, or the desktop app
91  // (hooks/clips.ts).
92  canSelect: boolean
93  // The prompts tab as hooks/toc-live.ts sees it, and the panel's width in cells.
94  toc: TocView
95  columns: number
96  // The clock's locale, zone and cycle, which the notes' dates follow.
97  clock: ClockOptions
98}
99
100const tabsOf = (ctx: Ctx): readonly PaneTab[] => [
101  ...(isTocTabOn(ctx.options) ? ['toc' as const] : []), 'notes', ...(isClipsTabOn(ctx.options) ? ['clips' as const] : []), 'status', 'tools', 'guide',
102]
103const NAME_CELLS_MAX = 24
104const ARROW = { up: '↑', down: '↓', toRight: '→', toLeft: '←' } as const
105
106const pressOf = (act: Act, action: PaneAction) => (press: { surface: RenderSurface }) => void act(action, press.surface)
107
108function itemName(id: string, ctx: Ctx): string {
109  if (!id.startsWith(BUTTON_PREFIX)) return ctx.t(`tool.${id}`)
110  return activeButtons(ctx.state.buttons).find(b => BUTTON_PREFIX + b.id === id)?.label ?? id
111}
112
113// A fixed-width name cell so the row buttons line up.
114function nameCell(kit: Kit, key: string, name: string, width: number): RenderElement {
115  const { Box, Text } = kit
116  return (
117    <Box key={key} width={width} flexShrink={0}>
118      <Text wrap="truncate-end">{name}</Text>
119    </Box>
120  )
121}
122
123// Row controls are plain and dim: bracketed Buttons are drawn bold, which some terminals color loudly.
124function control(kit: Kit, key: string, label: string, onPress: (press: { surface: RenderSurface }) => void): RenderElement {
125  const { Button } = kit
126  return <Button key={key} label={label} plain dimColor onPress={onPress} />
127}
128
129// The band shows no hotkeys; the panel names them, `h:` before the tool. Not in the desktop app, where no letter
130// reaches the band; the cell keeps the names in line.
131function hotkeyCell(kit: Kit, id: string, ctx: Ctx): RenderElement {
132  const { Box, Text } = kit
133  const hotkey = ctx.surface === 'desktop' ? undefined : toolForItem(id, ctx)?.hotkey?.(ctx)
134  return (
135    <Box key={`hotkey-${id}`} width={2} flexShrink={0}>
136      <Text dimColor>{hotkey === undefined ? '' : `${hotkey}:`}</Text>
137    </Box>
138  )
139}
140
141// A layout row's icon button, `icon ▾`: the longest text it shows (an icon, or "None") and a space and the arrow.
142const ICON_PICK_FRAME = 2
143const pickCells = (ctx: Ctx): number => ICON_PICK_FRAME + Math.max(2, cellsOf(ctx.t('icon.none')))
144
145// What a row leaves for the name: the panel's width less the margin, the hotkey, the icon list, the controls and the
146// one-cell gaps between them. A narrow panel cuts the name instead of wrapping the row.
147function nameRoom(ctx: Ctx, columns: number, pick: number): number {
148  const controls = 3 + Math.max(cellsOf(ctx.t('layout.hide')), cellsOf(ctx.t('layout.show')))
149  return columns - TOC_MARGIN - 2 - pick - controls - (pick === 0 ? 5 : 6)
150}
151
152type RowWidths = { name: number; pick: number }
153
154// The icon list of a row: a fixed tool that takes an icon (with "default") or a button of the set; none for the clock,
155// the date and the weather, whose cell is left blank so the names line up.
156function iconPicker(id: string, ctx: Ctx, act: Act): Picker | null {
157  const button = id.startsWith(BUTTON_PREFIX) ? activeButtons(ctx.state.buttons).find(b => BUTTON_PREFIX + b.id === id) : undefined
158  const isTool = ICON_TOOLS.includes(id)
159  if (!isTool && button === undefined) return null
160  const current = button === undefined ? (ctx.state.icons[id] ?? ICON_DEFAULT) : (button.icon ?? ICON_NONE)
161  const options = iconChoices(ctx.t, ctx.state.iconGroup, current, isTool ? (OWN_ICONS[id] ?? null) : null)
162  return { key: `icon-${id}`, options, value: current, onPick: (value, surface) => void act({ kind: 'icon.set', id, value }, surface) }
163}
164
165function layoutRow(kit: PaneKit, id: string, side: 'left' | 'right' | 'hidden', widths: RowWidths, ctx: Ctx, act: Act): RenderElement {
166  const { Box } = kit
167  const op = (o: LayoutOp) => pressOf(act, { kind: 'layout', op: o, id })
168  const controls =
169    side === 'hidden'
170      ? [control(kit, `show-${id}`, ctx.t('layout.show'), op('show'))]
171      : [
172          control(kit, `up-${id}`, ARROW.up, op('up')),
173          control(kit, `down-${id}`, ARROW.down, op('down')),
174          control(kit, `side-${id}`, side === 'left' ? ARROW.toRight : ARROW.toLeft, op('side')),
175          control(kit, `hide-${id}`, ctx.t('layout.hide'), op('hide')),
176        ]
177  const picker = iconPicker(id, ctx, act)
178  const row = (
179    <Box key={`row-${id}`} flexDirection="row" columnGap={1}>
180      {hotkeyCell(kit, id, ctx)}
181      <Box key={`icon-box-${id}`} width={widths.pick} flexShrink={0}>
182        {picker === null ? null : pickerButton(kit, picker, ctx, act)}
183      </Box>
184      {nameCell(kit, `name-${id}`, itemName(id, ctx), widths.name)}
185      {controls}
186    </Box>
187  )
188  // The open icon list goes under the row, as wide as the panel.
189  const list = picker === null ? null : pickerList(kit, picker, ctx, act)
190  return list === null ? row : <Box key={`item-${id}`} flexDirection="column">{row}{list}</Box>
191}
192
193function layoutGroup(kit: PaneKit, side: 'left' | 'right' | 'hidden', ids: readonly string[], widths: RowWidths, ctx: Ctx, act: Act): RenderElement {
194  const { Box, Text } = kit
195  const title = { left: 'layout.left', right: 'layout.right', hidden: 'layout.hiddenTools' }[side]
196  return (
197    <Box key={`group-${side}`} flexDirection="column">
198      <Text dimColor>{ctx.t(title)}</Text>
199      {ids.length === 0 ? <Text dimColor>{ctx.t('layout.empty')}</Text> : ids.map(id => layoutRow(kit, id, side, widths, ctx, act))}
200    </Box>
201  )
202}
203
204// The icon group the icon lists show (this session only).
205const groupPicker = (key: string, ctx: Ctx, act: Act): Picker => ({
206  key, label: ctx.t('icon.group'), options: groupChoices(ctx.t), value: ctx.state.iconGroup,
207  onPick: (group, surface) => void act({ kind: 'icon.group', group }, surface),
208})
209
210// The layout: the items shown now (fixed tools and the active set's buttons), per side.
211function layoutSection(kit: PaneKit, ctx: Ctx, act: Act, columns: number): RenderElement {
212  const { Box, Button, Text } = kit
213  const inner = columns - cardFrame(columns)
214  const shown = (ids: readonly string[]) => ids.filter(id => isItemShown(id, ctx))
215  const { left, right, hidden } = ctx.state.layout
216  const sides = { left: shown(left), right: shown(right), hidden: shown(hidden) }
217  const names = [...sides.left, ...sides.right, ...sides.hidden].map(id => cellsOf(itemName(id, ctx)))
218  const pick = pickCells(ctx)
219  const longest = Math.max(0, ...names)
220  const widths = { name: Math.max(4, Math.min(NAME_CELLS_MAX, nameRoom(ctx, inner, pick), longest)), pick }
221  return drawCard(kit, { key: 'layout', title: ctx.t('layout.title'), columns }, [
222    <Box key="layout-body" flexDirection="column" rowGap={1}>
223      <Box key="icon-group-help" flexDirection="column">
224        {drawPicker(kit, groupPicker('icon-group', ctx, act), ctx, act)}
225        <Text dimColor>{ctx.t('layout.iconHelp')}</Text>
226      </Box>
227      {layoutGroup(kit, 'left', sides.left, widths, ctx, act)}
228      {layoutGroup(kit, 'right', sides.right, widths, ctx, act)}
229      <Text dimColor>{ctx.t('layout.moreNote')}</Text>
230      {layoutGroup(kit, 'hidden', sides.hidden, widths, ctx, act)}
231      <Button key="layout-reset" label={ctx.t('layout.reset')} onPress={pressOf(act, { kind: 'layout.reset' })} />
232    </Box>,
233  ])
234}
235
236function buttonRow(kit: Kit, button: ToolButton, width: number, canEdit: boolean, ctx: Ctx, act: Act): RenderElement {
237  const { Box, Text } = kit
238  const hotkey = button.hotkey ? ctx.t('pane.hotkey', { key: button.hotkey }) : ctx.t('pane.hotkeyNone')
239  const facts = [ctx.t(`pane.kind.${button.kind}`), ctx.t(`pane.mode.${button.mode}`), hotkey].join(' · ')
240  return (
241    <Box key={`button-${button.id}`} flexDirection="row" columnGap={2}>
242      {nameCell(kit, `label-${button.id}`, button.label, width)}
243      <Text dimColor wrap="truncate-end">{facts}</Text>
244      {canEdit ? control(kit, `edit-${button.id}`, ctx.t('pane.edit'), pressOf(act, { kind: 'draft.open', id: button.id })) : null}
245      {control(kit, `delete-${button.id}`, ctx.t('pane.delete'), pressOf(act, { kind: 'button.delete', id: button.id }))}
246    </Box>
247  )
248}
249
250// The check of the form, drawn live under it and run again on Save: errors block saving, warnings do not.
251export function draftIssues(draft: ButtonDraft, buttons: ButtonConfig, knownCommands: readonly string[] | null): Validation {
252  return validateButton(draft, {
253    existing: activeButtons(buttons),
254    reserved: reservedHotkeys(),
255    cells: cellsOf,
256    ...(knownCommands === null ? {} : { knownCommands }),
257  })
258}
259
260function issueLines(kit: Kit, issues: Validation, ctx: Ctx): RenderElement[] {
261  const { Text } = kit
262  return [
263    ...issues.errors.map(i => <Text color="error">{ctx.t(i.key, i.args)}</Text>),
264    ...issues.warnings.map(i => <Text color="warning">{ctx.t(i.key, i.args)}</Text>),
265  ]
266}
267
268// A field's one-line description, under it.
269const help = (kit: Kit, ctx: Ctx, key: string): RenderElement => <kit.Text dimColor>{ctx.t(key)}</kit.Text>
270
271function kindFields(kit: FormKit, draft: ButtonDraft, ctx: Ctx, act: Act, field: (f: DraftField) => FieldHandlers): RenderElement[] {
272  const { Input } = kit
273  if (draft.kind === 'prompt' || draft.kind === undefined) {
274    const modes = (['fill', 'send'] as const).map(m => ({ value: m, label: ctx.t(`pane.mode.${m}`) }))
275    return [
276      <Input key="field-text" label={ctx.t('pane.field.text')} rows={3} value={draft.text ?? ''} {...field('text')} />,
277      help(kit, ctx, 'pane.help.prompt'),
278      drawPicker(kit, { key: 'field-mode', label: ctx.t('pane.field.mode'), options: modes, value: draft.mode === 'send' ? 'send' : 'fill', onPick: field('mode').onSubmit }, ctx, act),
279    ]
280  }
281  return [
282    <Input key="field-command" label={ctx.t('pane.field.command')} value={draft.command ?? ''} {...field('command')} />,
283    help(kit, ctx, 'pane.help.command'),
284    // One "prompt" field for every kind: here it is what follows the command (stored as `args`).
285    <Input key="field-args" label={ctx.t('pane.field.text')} value={draft.args ?? ''} {...field('args')} />,
286    help(kit, ctx, 'pane.help.commandPrompt'),
287  ]
288}
289
290type FieldHandlers = { onInput: (value: string) => void; onSubmit: (value: string) => void }
291
292// The edit form. Every edit and Enter writes the draft, so Save reads what is on screen.
293function draftForm(kit: FormKit, draft: ButtonDraft, model: PaneModel, act: Act, surface: RenderSurface): RenderElement {
294  const { Box, Button, Input, Text } = kit
295  const { ctx } = model
296  const set = (f: DraftField) => (value: string) => void act({ kind: 'draft.field', field: f, value }, surface)
297  const field = (f: DraftField): FieldHandlers => ({ onInput: set(f), onSubmit: set(f) })
298  const kinds = (['prompt', 'command', 'skill'] as const).map(k => ({ value: k, label: ctx.t(`pane.kind.${k}`) }))
299  return drawCard(kit, { key: 'form', title: ctx.t(draft.id === undefined ? 'pane.formNew' : 'pane.formEdit'), columns: model.columns }, [
300    <Box key="form-body" flexDirection="column">
301      <Input key="field-label" label={ctx.t('pane.field.label')} value={draft.label ?? ''} {...field('label')} />
302      {help(kit, ctx, 'pane.help.label')}
303      {drawPicker(kit, groupPicker('field-icon-group', ctx, act), ctx, act)}
304      {drawPicker(kit, { key: 'field-icon', label: ctx.t('pane.field.icon'), options: iconChoices(ctx.t, ctx.state.iconGroup, draft.icon || ICON_NONE, null), value: draft.icon || ICON_NONE, onPick: set('icon') }, ctx, act)}
305      {help(kit, ctx, 'pane.help.icon')}
306      {drawPicker(kit, { key: 'field-kind', label: ctx.t('pane.field.kind'), options: kinds, value: draft.kind ?? 'prompt', onPick: set('kind') }, ctx, act)}
307      {kindFields(kit, draft, ctx, act, field)}
308      <Input key="field-hotkey" label={ctx.t('pane.field.hotkey')} value={draft.hotkey ?? ''} {...field('hotkey')} />
309      {issueLines(kit, draftIssues(draft, ctx.state.buttons, model.knownCommands), ctx)}
310      <Box key="form-actions" flexDirection="row" columnGap={1}>
311        {ctx.surface === 'terminal'
312          ? <Button key="save" label={ctx.t('pane.save')} onPress={pressOf(act, { kind: 'draft.save' })}><Text color={ACCENT} weight="semibold">{ctx.t('pane.save')}</Text></Button>
313          : <Button key="save" variant="primary" label={ctx.t('pane.save')} onPress={pressOf(act, { kind: 'draft.save' })} />}
314        <Button key="cancel" label={ctx.t('pane.cancel')} onPress={pressOf(act, { kind: 'draft.cancel' })} />
315      </Box>
316    </Box>,
317  ])
318}
319
320// The sets: which set the band shows, Delete for that set, and + New set, which opens a field for the new one's name (a
321// bare field was easy to miss). The field is open while `openPicker` names it.
322export const SET_NEW_FIELD = 'set-new'
323
324function setControls(kit: FormKit, ctx: Ctx, act: Act): RenderElement {
325  const { Box, Button, Input, Text } = kit
326  const isAdding = ctx.state.openPicker === SET_NEW_FIELD
327  const toggleNew = pressOf(act, { kind: 'picker.open', key: isAdding ? null : SET_NEW_FIELD })
328  const { sets, activeSet } = ctx.state.buttons
329  const picker: Picker = {
330    key: 'set', label: ctx.t('pane.set'), options: Object.keys(sets).map(name => ({ value: name, label: name })), value: activeSet,
331    onPick: (name, surface) => void act({ kind: 'set.pick', name }, surface),
332  }
333  return (
334    <Box key="sets" flexDirection="column" rowGap={1}>
335      <Box key="set-pick" flexDirection="column">
336        <Box key="set-row" flexDirection="row" columnGap={2}>
337          {pickerButton(kit, picker, ctx, act)}
338          {control(kit, 'set-delete', ctx.t('pane.setDelete'), pressOf(act, { kind: 'set.delete' }))}
339        </Box>
340        {pickerList(kit, picker, ctx, act)}
341        {/* On its own line under the set row; the name field opens in its place. */}
342        {isAdding ? (
343          <Box key="set-new-field" flexDirection="column">
344            <Input key={SET_NEW_FIELD} autoFocus placeholder={ctx.t('pane.setNew')} submitLabel={ctx.t('pane.setAdd')} value="" onSubmit={name => void act({ kind: 'set.add', name }, ctx.surface)} />
345            <Button key="set-new-cancel" label={ctx.t('pane.cancel')} onPress={toggleNew} />
346          </Box>
347        ) : <Button key="set-new-open" label={ctx.t('pane.newSet')} onPress={toggleNew} />}
348        <Text dimColor>{ctx.t('pane.setHelp')}</Text>
349      </Box>
350    </Box>
351  )
352}
353
354function toolsTab(kit: PaneKit, model: PaneModel, act: Act): RenderElement {
355  const { Box, Button, Input } = kit
356  const { ctx } = model
357  const form = Input === undefined ? null : { ...kit, Input }
358  const draft = ctx.state.editDraft
359  const buttons = activeButtons(ctx.state.buttons)
360  const width = Math.min(NAME_CELLS_MAX, Math.max(4, ...buttons.map(b => cellsOf(b.label))))
361  return (
362    <Box key="tools" flexDirection="column" rowGap={1}>
363      {layoutSection(kit, ctx, act, model.columns)}
364      {drawCard(kit, { key: 'buttons', title: ctx.t('pane.setName', { name: ctx.state.buttons.activeSet }), columns: model.columns }, [
365        <Box key="buttons-body" flexDirection="column" rowGap={1}>
366          {form === null ? null : setControls(form, ctx, act)}
367          <Box key="button-rows" flexDirection="column">
368            {buttons.map(b => buttonRow(kit, b, width, form !== null, ctx, act))}
369          </Box>
370          {form === null ? null : <Button key="button-new" label={ctx.t('pane.newButton')} onPress={pressOf(act, { kind: 'draft.open', id: null })} />}
371        </Box>,
372      ])}
373      {form === null || draft === null ? null : draftForm(form, draft, model, act, ctx.surface)}
374    </Box>
375  )
376}
377
378// The pomodoro's bar under its button while a round runs or waits: spaces on a colour, focus in the accent colour and a
379// break in the success colour, the rest in the subtle one, then the percent.
380const METER_CELLS_MAX = 20
381
382function pomodoroMeter(kit: Kit, ctx: Ctx, columns: number): RenderElement | null {
383  const { Box, Text } = kit
384  const progress = pomodoroProgress(ctx.state.pomodoro, ctx.now, pomodoroConfig(ctx.options))
385  const width = Math.min(METER_CELLS_MAX, columns - 6)
386  if (progress === null || width < 4) return null
387  const filled = Math.round(progress.ratio * width)
388  return (
389    <Box key="pomodoro-meter" flexDirection="row" columnGap={1}>
390      <Box key="pomodoro-meter-bar" flexDirection="row">
391        {filled > 0 ? <Text backgroundColor={progress.phase === 'break' ? 'success' : ACCENT}>{' '.repeat(filled)}</Text> : null}
392        {width - filled > 0 ? <Text backgroundColor="subtle">{' '.repeat(width - filled)}</Text> : null}
393      </Box>
394      <Text dimColor>{`${Math.round(progress.ratio * 100)}%`}</Text>
395    </Box>
396  )
397}
398
399// Every tool the person has not hidden, pressable here whatever fits in the band (the band's … opens it).
400function allTools(kit: Kit, ctx: Ctx, act: Act, columns: number): RenderElement {
401  const { Button, Text } = kit
402  const ids = [...ctx.state.layout.left, ...ctx.state.layout.right].filter(id => isItemShown(id, ctx))
403  // This list is where the names are: the icon-only style is drawn with its words here.
404  const named = ctx.options.iconStyle === 'icon' ? { ...ctx, options: { ...ctx.options, iconStyle: 'icon+label' } } : ctx
405  const rows = ids.flatMap(id => {
406    const tool = toolForItem(id, ctx)
407    if (tool === undefined) return []
408    const label = tool.label(named, 'long')
409    if (label === '') return []
410    if (tool.kind === 'text') return [<Text>{label}</Text>]
411    const button = <Button key={`more-${id}`} label={label} onPress={pressOf(act, { kind: 'press', id })} />
412    return id === 'pomodoro' ? [button, pomodoroMeter(kit, ctx, columns - cardFrame(columns))] : [button]
413  })
414  return drawCard(kit, { key: 'more', title: ctx.t('more.title'), columns }, [<Text dimColor>{ctx.t('more.description')}</Text>, ...rows])
415}
416
417// The clipboard tab: the selections copied by dragging, newest first, as the notes are drawn but with no time, so
418// Insert and Delete stay in a narrow panel: the first line, Insert and Delete. A surface where no selection reaches the
419// mod (the main screen, a surface not known to answer) says so in place of the empty list's line.
420function clipsTab(kit: PaneKit, model: PaneModel, act: Act): RenderElement {
421  const { Box, Text } = kit
422  const { ctx } = model
423  const clips = ctx.state.clipBoard.clips
424  const buttons = cellsOf(ctx.t('notes.insert')) + cellsOf(ctx.t('pane.delete'))
425  // The row's gaps are two one-cell columns; the panel may draw a focus mark in the margin.
426  const room = model.columns - TOC_MARGIN - buttons - 2
427  // A clip of several lines says how many after its first, as Insert puts them all.
428  const lines = clips.map(clip => {
429    const count = clip.text.split('\n').length
430    const more = count > 1 ? ` ${ctx.t('clips.lines', { count })}` : ''
431    return `${truncateCells(firstLine(clip.text), Math.max(1, Math.min(NOTE_CELLS_MAX, Math.max(TOC_CELLS_MIN, room)) - cellsOf(more)))}${more}`
432  })
433  const lineCells = Math.max(0, ...lines.map(cellsOf))
434  return (
435    <Box key="clips" flexDirection="column" rowGap={1}>
436      <Text dimColor>{ctx.t('clips.intro')}</Text>
437      {!model.canSelect ? <Text dimColor>{ctx.t('clips.notHere')}</Text> : clips.length === 0 ? <Text dimColor>{ctx.t('clips.empty')}</Text> : null}
438      <Box key="clip-list" flexDirection="column">
439        {clips.map((clip, i) => (
440          <Box key={`clip-${clip.id}`} flexDirection="row" columnGap={1}>
441            {nameCell(kit, `clip-text-${clip.id}`, lines[i] ?? '', lineCells)}
442            {control(kit, `clip-fill-${clip.id}`, ctx.t('notes.insert'), pressOf(act, { kind: 'clip.fill', id: clip.id }))}
443            {control(kit, `clip-delete-${clip.id}`, ctx.t('pane.delete'), pressOf(act, { kind: 'clip.delete', id: clip.id }))}
444          </Box>
445        ))}
446      </Box>
447    </Box>
448  )
449}
450
451// The notes tab: the field to type in, then the notes newest first: date and time, the first line, Insert and Delete.
452// Mobile draws no Input, so there only the list shows.
453function notesTab(kit: PaneKit, model: PaneModel, act: Act): RenderElement {
454  const { Box, Text, Input } = kit
455  const { ctx } = model
456  const notes = [...ctx.state.notes].reverse()
457  const stamps = notes.map(note => formatStamp(note.createdAt, model.clock))
458  const stampCells = Math.max(0, ...stamps.map(cellsOf))
459  const buttons = cellsOf(ctx.t('notes.insert')) + cellsOf(ctx.t('pane.delete'))
460  // The row's gaps are three one-cell columns; the panel may draw a focus mark in the margin.
461  const room = model.columns - TOC_MARGIN - stampCells - buttons - 3
462  const lines = notes.map(note => truncateCells(firstLine(note.text), Math.min(NOTE_CELLS_MAX, Math.max(TOC_CELLS_MIN, room))))
463  const lineCells = Math.max(0, ...lines.map(cellsOf))
464  return (
465    <Box key="notes" flexDirection="column" rowGap={1}>
466      {Input === undefined ? null : (
467        <Input
468          key="note-input"
469          rows={3}
470          placeholder={ctx.t('notes.placeholder')}
471          submitLabel={ctx.t('notes.add')}
472          autoFocus
473          value=""
474          onSubmit={text => void act({ kind: 'note.add', text }, ctx.surface)}
475        />
476      )}
477      {notes.length === 0 ? <Text dimColor>{ctx.t('notes.empty')}</Text> : null}
478      <Box key="note-list" flexDirection="column">
479        {notes.map((note, i) => (
480          <Box key={`note-${note.id}`} flexDirection="row" columnGap={1}>
481            <Box key={`note-stamp-${note.id}`} width={stampCells} flexShrink={0}>
482              <Text dimColor>{stamps[i]}</Text>
483            </Box>
484            {nameCell(kit, `note-text-${note.id}`, lines[i] ?? '', lineCells)}
485            {control(kit, `note-fill-${note.id}`, ctx.t('notes.insert'), pressOf(act, { kind: 'note.fill', id: note.id }))}
486            {control(kit, `note-delete-${note.id}`, ctx.t('pane.delete'), pressOf(act, { kind: 'note.delete', id: note.id }))}
487          </Box>
488        ))}
489      </Box>
490    </Box>
491  )
492}
493
494// The export format (kept in the store, not /config): a settings row after the export folder. pdf needs a browser to
495// print with (pdfBrowser) and a terminal to run it in.
496function exportFormatRow(kit: PaneKit, ctx: Ctx, act: Act): RenderElement {
497  const choose = (value: string, surface: RenderSurface) => {
498    const format = asExportFormat(value)
499    if (format !== null) void act({ kind: 'export.format', format }, surface)
500  }
501  const canPdf = ctx.surface === 'terminal' && ctx.options.pdfBrowser !== undefined && ctx.options.pdfBrowser !== 'off'
502  const formats = EXPORT_FORMATS.filter(format => format !== 'pdf' || canPdf).map(format => ({ value: format, label: format }))
503  const value = formats.some(f => f.value === ctx.state.exportFormat) ? ctx.state.exportFormat : 'html'
504  return drawPicker(kit, { key: 'export-format', label: ctx.t('status.exportFormat'), options: formats, value, onPick: choose }, ctx, act)
505}
506
507// The weather as the status tab tells it: when it came, how often it comes, or why it did not; nothing while it is off.
508function weatherFact(ctx: Ctx, clock: ClockOptions): string | null {
509  const place = ctx.options.weatherPlace
510  if (typeof place !== 'string' || place === 'off') return null
511  const minutes = weatherRefreshMs(ctx.options) / 60_000
512  const weather = ctx.state.weather
513  if (weather.status === 'ok' && weather.fetchedAt !== null) return ctx.t('status.weatherOk', { place, time: formatStamp(weather.fetchedAt, clock), minutes })
514  return weather.status === 'error' ? ctx.t('status.weatherError', { place, minutes }) : ctx.t('status.weatherLoading', { place })
515}
516
517// This session first: the versions, the language, the weather, the finished focus rounds and whether anything failed,
518// with where to read more when it did; then every tool, then the settings.
519function statusTab(kit: PaneKit, model: PaneModel, act: Act): RenderElement {
520  const { Box, Text } = kit
521  const { ctx } = model
522  const error = ctx.state.lastError
523  const weather = weatherFact(ctx, model.clock)
524  return (
525    <Box key="status" flexDirection="column" rowGap={1}>
526      {drawCard(kit, { key: 'status-facts', title: ctx.t('status.title'), columns: model.columns }, [
527        model.versions === null ? null : <Text>{ctx.t('status.versions', model.versions)}</Text>,
528        <Text>{ctx.t('status.language', { lang: model.language })}</Text>,
529        weather === null ? null : <Text>{weather}</Text>,
530        <Text>{ctx.t('status.pomodoroRounds', { count: ctx.state.pomodoro.round })}</Text>,
531        // A mark beside the words, so the colour is not the only sign.
532        <Box key="status-error" flexDirection="row" columnGap={1}>
533          <Text color={error === null ? 'success' : 'error'}>{error === null ? '✓' : '✗'}</Text>
534          <Text>{error === null ? ctx.t('status.noError') : ctx.t('status.lastError', { error })}</Text>
535        </Box>,
536        error === null ? null : <Text dimColor>{ctx.t('status.debugHint')}</Text>,
537      ])}
538      {allTools(kit, ctx, act, model.columns)}
539      {settingsSection(kit, model, act)}
540    </Box>
541  )
542}
543
544// The text a language file has for `key`, or undefined: t answers a key it does not know with the key itself.
545const known = (ctx: Ctx, key: string): string | undefined => {
546  const text = ctx.t(key)
547  return text === key ? undefined : text
548}
549
550// The mod's /config rows: a toggle flips with one press, a choice opens its list. A locked row, or one with nothing to
551// pick, shows the value as text. Names and worded values are the person's language (`settings.field.<field>`,
552// `settings.value.<field>.<value>` or `settings.value.<value>`); /config shows plugin.json's English, and a field or
553// value the files lack keeps the engine's text.
554function settingsSection(kit: PaneKit, model: PaneModel, act: Act): RenderElement {
555  const { Box, Text } = kit
556  const { ctx } = model
557  const rows = model.settings.map(row => {
558    const field = row.key.slice(CONFIG_PREFIX.length)
559    const name = known(ctx, `settings.field.${field}`) ?? row.label
560    const word = (value: string) => known(ctx, `settings.value.${field}.${value}`) ?? known(ctx, `settings.value.${value}`) ?? value
561    const shown = typeof row.value === 'boolean' ? ctx.t(row.value ? 'settings.on' : 'settings.off') : word(String(row.value))
562    const key = `setting-${row.key}`
563    const pick = (value: string, surface: RenderSurface) => void act({ kind: 'setting.set', key: row.key, value }, surface)
564    if (!row.isLocked && row.kind === 'boolean') return drawLabeledButton(kit, key, name, shown, press => pick(String(row.value !== true), press.surface))
565    const options = (row.options ?? []).map(option => ({ value: option, label: word(option) }))
566    // A Text takes no key: a line of text goes in a keyed Box.
567    if (row.isLocked || row.kind !== 'choice' || options.length === 0) return <Box key={key}><Text>{`${name}: ${shown}`}</Text></Box>
568    return drawPicker(kit, { key, label: name, options, value: String(row.value), onPick: pick }, ctx, act)
569  })
570  // Rows the panel adds among them, each after the last row it follows or last: the pomodoro's reset after its
571  // settings, the export format after the export folder, and under the icon style, while the band is too narrow for
572  // names, a note that it shows icons only.
573  const isIconsOnly = model.isBandShort && iconStyleOf(ctx.options.iconStyle) === 'icon+label'
574  const extras = [
575    { follows: (key: string) => key.startsWith(`${CONFIG_PREFIX}pomodoro`), row: drawLabeledButton(kit, 'pomodoro-reset', ctx.t('settings.pomodoro'), ctx.t('settings.pomodoroReset'), pressOf(act, { kind: 'pomodoro', verb: 'reset' })) },
576    { follows: (key: string) => key === `${CONFIG_PREFIX}exportDirectory`, row: exportFormatRow(kit, ctx, act) },
577    ...(isIconsOnly ? [{ follows: (key: string) => key === `${CONFIG_PREFIX}iconStyle`, row: <Box key="icon-style-short"><Text dimColor>{ctx.t('settings.iconsOnly')}</Text></Box> }] : []),
578  ].map(extra => ({ ...extra, at: model.settings.findLastIndex(row => extra.follows(row.key)) }))
579  const listed = [...rows.flatMap((row, i) => [row, ...extras.filter(x => x.at === i).map(x => x.row)]), ...extras.filter(x => x.at === -1).map(x => x.row)]
580  const isOwn = model.settingsSource === 'own'
581  return drawCard(kit, { key: 'settings', title: ctx.t(isOwn ? 'settings.titleOwn' : 'settings.title'), columns: model.columns }, [
582    rows.length === 0 ? <Text dimColor>{ctx.t(isOwn ? 'settings.noneOwn' : 'settings.none')}</Text> : null,
583    ...listed,
584  ])
585}
586
587// The tabs that fit on one line (wrapped tabs took the top of a narrow panel): all of them, or the one shown and those
588// after it in order that fit beside a … button, which lists the rest.
589// A tab is `[ name ]` (4 cells around the name), or ` name ` as a badge in the terminal (2 cells).
590const TAB_FRAME = 4
591const BADGE_FRAME = 2
592const MORE_TABS = '…'
593
594export function fitTabs(names: readonly PaneTab[], shown: PaneTab, cells: (tab: PaneTab) => number, room: number, frame = TAB_FRAME): { fitted: PaneTab[]; rest: PaneTab[] } {
595  const width = (tab: PaneTab) => cells(tab) + frame + 1
596  if (names.reduce((sum, tab) => sum + width(tab), 0) - 1 <= room) return { fitted: [...names], rest: [] }
597  const budget = room - (cellsOf(MORE_TABS) + frame)
598  const chosen = names.reduce((kept, tab) => (tab !== shown && kept.used + width(tab) <= budget ? { used: kept.used + width(tab), tabs: [...kept.tabs, tab] } : kept), { used: width(shown), tabs: [shown] })
599  return { fitted: names.filter(tab => chosen.tabs.includes(tab)), rest: names.filter(tab => !chosen.tabs.includes(tab)) }
600}
601
602export function drawPane(kit: PaneKit, model: PaneModel, act: Act): RenderElement {
603  const { Box, Button, Text } = kit
604  const { ctx } = model
605  const names = tabsOf(ctx)
606  const tab = names.includes(ctx.state.paneTab) ? ctx.state.paneTab : 'tools'
607  const label = (name: PaneTab) => ctx.t(`pane.tab.${name}`)
608  // The terminal shows the tab in use as a badge, the accent colour behind its name, and the others dim; a desktop
609  // keeps its own buttons.
610  const isBadge = ctx.surface === 'terminal'
611  const { fitted, rest } = fitTabs(names, tab, name => cellsOf(label(name)), model.columns - TOC_MARGIN, isBadge ? BADGE_FRAME : TAB_FRAME)
612  const go = (name: PaneTab) => pressOf(act, { kind: 'tab', tab: name })
613  const tabs = fitted.map(name =>
614    !isBadge ? <Button key={`tab-${name}`} label={label(name)} {...(name === tab ? { variant: 'primary' as const } : {})} onPress={go(name)} />
615    : name === tab ? <Button key={`tab-${name}`} plain onPress={go(name)}><Text backgroundColor={ACCENT} color={ACCENT_TEXT} weight="semibold">{` ${label(name)} `}</Text></Button>
616    : <Button key={`tab-${name}`} label={` ${label(name)} `} plain dimColor onPress={go(name)} />,
617  )
618  const more: Picker = { key: 'tab-more', options: rest.map(name => ({ value: name, label: label(name) })), value: '', onPick: (name, surface) => void act({ kind: 'tab', tab: name as PaneTab }, surface) }
619  const isMoreOpen = ctx.state.openPicker === more.key
620  const body =
621    tab === 'toc' ? drawTocTab(kit, { view: model.toc, columns: model.columns }, ctx, act)
622    : tab === 'notes' ? notesTab(kit, model, act)
623    : tab === 'clips' ? clipsTab(kit, model, act)
624    : tab === 'status' ? statusTab(kit, model, act)
625    : tab === 'guide' ? drawGuide(kit, ctx, act, model.columns)
626    : toolsTab(kit, model, act)
627  return (
628    <Box key="pane" flexDirection="column" rowGap={1}>
629      <Box key="tab-bar" flexDirection="column">
630        <Box key="tabs" flexDirection="row" columnGap={1}>
631          {tabs}
632          {rest.length === 0 ? null : <Button key="tab-more" label={isBadge ? ` ${MORE_TABS} ` : MORE_TABS} {...(isBadge ? { plain: true as const, dimColor: true } : {})} onPress={pressOf(act, { kind: 'picker.open', key: isMoreOpen ? null : more.key })} />}
633        </Box>
634        {pickerList(kit, more, ctx, act)}
635      </Box>
636      {body}
637    </Box>
638  )
639}
640
hooks/lib/effects.ts 32 lines
1// Effects: what a pure tool function asks the host to do. Only register.tsx's runEffect turns them into `$` calls, so
2// tool logic stays testable without `$`.
3import type { PluginState } from 'claude-code'
4import type { PaneTab } from '../../types/index'
5import type { ExportFormat } from './export'
6
7export type StateKey = keyof PluginState['oh-my-toolbelt']
8
9export type Effect =
10  | { kind: 'prompt.fill'; text: string; mode: 'replace' | 'append' | 'insert' }
11  | { kind: 'prompt.submit'; text: string; asUser?: true }
12  | { kind: 'command.run'; command: string; args?: string }
13  | { kind: 'config.set'; key: string; value: string | boolean | number }
14  | { kind: 'ui.open'; tab: PaneTab }
15  // Open the panel, or close it when it is already showing (the band's edit button).
16  | { kind: 'ui.toggle'; tab: PaneTab }
17  | { kind: 'ui.close' }
18  | { kind: 'ui.toast'; text: string; timeoutMs?: number }
19  // Goes through the do-not-disturb and surface guards.
20  | { kind: 'notify'; text: string; title?: string }
21  // Goes through the do-not-disturb and settings guards.
22  | { kind: 'audio.play'; asset: string }
23  | { kind: 'state.update'; key: StateKey; fn: (value: unknown) => unknown }
24  | { kind: 'store.set'; key: string; value: unknown }
25  // Save the conversation to files: in this format, or the last one chosen.
26  | { kind: 'export'; format?: ExportFormat }
27  // Open the system's file picker and put the picked paths in the prompt box.
28  | { kind: 'attach' }
29  // Run /voice with this argument; the voice state follows only a run that went through.
30  | { kind: 'voice'; next: 'tap' | 'hold' | 'off' }
31  | { kind: 'log'; text: string }
32
hooks/lib/format.ts 110 lines
1// Time and duration formatting. Thin wrappers over Intl.
2
3export type ClockFormat = 'auto' | '12' | '24'
4
5export type ClockOptions = {
6  locale: string
7  // Empty or missing means the system time zone.
8  timeZone?: string
9  // Missing means the locale default.
10  hour12?: boolean
11  seconds: boolean
12}
13
14const pad2 = (n: number): string => String(n).padStart(2, '0')
15
16// Throws a RangeError where Intl rejects the locale or time zone: tools/clock.ts clockLabel then draws the local time
17// and says so in the status tab.
18export function formatClock(nowMs: number, options: ClockOptions): string {
19  // hourCycle states the cycle outright (h12 or h23).
20  const hourCycle = options.hour12 === undefined ? undefined : options.hour12 ? 'h12' : 'h23'
21  return new Intl.DateTimeFormat(options.locale, {
22    hour: 'numeric',
23    minute: '2-digit',
24    second: options.seconds ? '2-digit' : undefined,
25    hourCycle,
26    timeZone: options.timeZone || undefined,
27  }).format(nowMs)
28}
29
30// A note's date and time: month and day, then the clock, in the clock's locale, zone and cycle.
31export function formatStamp(nowMs: number, options: ClockOptions): string {
32  const hourCycle = options.hour12 === undefined ? undefined : options.hour12 ? 'h12' : 'h23'
33  try {
34    return new Intl.DateTimeFormat(options.locale, {
35      month: 'numeric',
36      day: 'numeric',
37      hour: 'numeric',
38      minute: '2-digit',
39      hourCycle,
40      timeZone: options.timeZone || undefined,
41    }).format(nowMs)
42  } catch (error) {
43    if (!(error instanceof RangeError)) throw error
44    const date = new Date(nowMs)
45    return `${date.getMonth() + 1}/${date.getDate()} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
46  }
47}
48
49// The export's header date: medium date and short time, in the clock's locale, zone and cycle.
50export function formatDateTime(nowMs: number, options: ClockOptions): string {
51  const hourCycle = options.hour12 === undefined ? undefined : options.hour12 ? 'h12' : 'h23'
52  try {
53    return new Intl.DateTimeFormat(options.locale, { dateStyle: 'medium', timeStyle: 'short', hourCycle, timeZone: options.timeZone || undefined }).format(nowMs)
54  } catch (error) {
55    if (!(error instanceof RangeError)) throw error
56    const date = new Date(nowMs)
57    return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
58  }
59}
60
61// `YYYYMMDD-HHmm` for an export's file name, in the clock's zone; the local zone when Intl refuses it.
62export function fileStamp(nowMs: number, timeZone?: string): string {
63  try {
64    const parts = new Intl.DateTimeFormat('en-US', {
65      year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: timeZone || undefined,
66    }).formatToParts(nowMs)
67    const part = (type: Intl.DateTimeFormatPartTypes): string => parts.find(p => p.type === type)?.value ?? '00'
68    return `${part('year')}${part('month')}${part('day')}-${part('hour')}${part('minute')}`
69  } catch (error) {
70    if (!(error instanceof RangeError)) throw error
71    const date = new Date(nowMs)
72    return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2(date.getMinutes())}`
73  }
74}
75
76// Countdown text: mm:ss, or h:mm:ss from one hour on. Partial seconds round up, as timers do.
77export function formatDuration(ms: number): string {
78  const total = Number.isFinite(ms) ? Math.max(0, Math.ceil(ms / 1000)) : 0
79  const hours = Math.floor(total / 3600)
80  const minutes = Math.floor((total % 3600) / 60)
81  const seconds = total % 60
82  return hours > 0
83    ? `${hours}:${pad2(minutes)}:${pad2(seconds)}`
84    : `${pad2(minutes)}:${pad2(seconds)}`
85}
86
87// Intl throws RangeError for a time zone it does not know (confirmed on 2.1.308).
88export function isValidTimeZone(timeZone: string): boolean {
89  try {
90    new Intl.DateTimeFormat('en-US', { timeZone })
91    return true
92  } catch (error) {
93    if (error instanceof RangeError) return false
94    throw error
95  }
96}
97
98// true when the clock shows 12 hours: fixed by the setting, or by the locale when it is auto.
99export function resolveHour12(clockFormat: ClockFormat, locale: string): boolean {
100  if (clockFormat === '12') return true
101  if (clockFormat === '24') return false
102  try {
103    const cycle = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hourCycle
104    return cycle === 'h12' || cycle === 'h11'
105  } catch (error) {
106    if (error instanceof RangeError) return false
107    throw error
108  }
109}
110
hooks/lib/icons.ts 117 lines
1// Icons for the toolbelt buttons: the groups the pickers list, the three icon styles, and the small pure helpers that
2// turn a pick into a label or a list of choices. Pure: no `$`.
3import type { ToolIcons } from '../../types/index'
4import type { TFn } from './i18n'
5
6// Stored and picked as these words: "none" is kept, "default" is the key being absent.
7export const ICON_NONE = 'none'
8export const ICON_DEFAULT = 'default'
9// A Select takes at most 64 choices (2.1.308): a group, plus "default", "none" and a pick from another group, stays under.
10export const ICON_GROUP_MAX = 60
11
12export type IconGroup = { id: string; icons: readonly string[] }
13
14// Emoji groups hold characters with Emoji_Presentation=Yes written as one code point, which a terminal draws two cells
15// wide. The symbols group holds text-drawn characters one cell wide whose East Asian Width is Neutral or Narrow: an
16// Ambiguous one (a star, a filled circle) is two cells wide in Korean and Japanese terminals and would break every width.
17// Spelled as strings, split into code points below.
18const EMOJI_WORK = '💼📁📂📄📋📌📎📝💻🔧🔨🔍🔎🔗📊📈📉📅📆📧📨📤📥📦🧪🧰🧮🤖🧠💡🎯🚀✅❌🔒🔓🔑🐛🧹🧾📐📏🔬🔭📡💾💿📀'
19const EMOJI_TIME = '⏰⏳⌛⌚🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛🔔🔕❗❓⚡🔥⭐🌟✨💯🔴🔵⚫⚪➕➖⏩⏪⏫⏬🚦🚩🏁🎉🎊💤🔄🔁🔃'
20const EMOJI_OBJECTS = '🏠🏢🏫🏥🚗🚌🚲🚂📱📞📷📹🎥📺📻🎧🎤🎵🎶🎮🎲🧩🎨📚📖📕📗📘📙📓📒🎓🏆🏅🎁🛒💰💳💎⚽⚾🏀☕🍎🍕🍔🍜🍺🔋💊🎸🎹🔦🔮'
21const EMOJI_NATURE = '🌱🌲🌳🌴🌵🌷🌸🌹🌻🍀🍁🍂🍃🌍🌙🌞⛅☔🌈🌊🐶🐱🐭🐰🦊🐻🐼🐨🐯🦁🐮🐷🐸🐵🐔🐧🐦🐢🐍🐙🐟🐬🐳🦋🐝🦄🐞🌼🍄🌰'
22const EMOJI_FACES = '😀😃😄😁😆😅😂🙂😉😊😇😍😘😋😎🤓🤔😐😴😮😢😭😡😱🥳🥺🤯🤗🙄😬👍👎👌👏🙏💪👋✋👀💙💚💛💜🧡🖤💔💕🤝👊🙌'
23const SYMBOLS = '✓✗✘✎✐✑⌘⌥⌫⌦⏎⌃➜➤➔➙➝⇨⇦⇩↻↺⟳⟲⧗⏻⏼⏽⌂⌕⌖⎈⚑⚐☐☒✦✧✱✲✪✿❀❖✚✠❁❅❆❯❮☾☽♫▸◦◌▢'
24
25const codePoints = (text: string): readonly string[] => Array.from(text)
26
27// The group ids are the keys `icon.group.<id>` of the language files.
28export const ICON_GROUPS: readonly IconGroup[] = [
29  { id: 'work', icons: codePoints(EMOJI_WORK) },
30  { id: 'time', icons: codePoints(EMOJI_TIME) },
31  { id: 'objects', icons: codePoints(EMOJI_OBJECTS) },
32  { id: 'nature', icons: codePoints(EMOJI_NATURE) },
33  { id: 'faces', icons: codePoints(EMOJI_FACES) },
34  { id: 'symbols', icons: codePoints(SYMBOLS) },
35]
36export const DEFAULT_ICON_GROUP = 'work'
37
38const LISTED: ReadonlySet<string> = new Set(ICON_GROUPS.flatMap(group => group.icons))
39
40// Whether a value is an icon of some group. A stored or typed value is checked with this before it is drawn.
41export const isListedIcon = (value: unknown): value is string => typeof value === 'string' && LISTED.has(value)
42
43// A group id from outside (a pick, the session state); anything else is the first group.
44export const asIconGroup = (value: unknown): string => ICON_GROUPS.find(group => group.id === value)?.id ?? DEFAULT_ICON_GROUP
45
46// ---- Icon style (the `iconStyle` setting) ----
47
48export type IconStyle = 'icon+label' | 'icon' | 'label'
49
50// The older values `emoji` and `text` are not in the list; the engine reads them as the default, and so does this.
51export const iconStyleOf = (value: unknown): IconStyle => (value === 'icon' || value === 'label' ? value : 'icon+label')
52
53// What a button draws: its icon and its word, by the style and the room the band gives it. A button with no icon
54// ('') shows its word whatever the style says. `shortWord` is the word when the band is tight.
55export function faceOf(style: IconStyle, size: 'long' | 'short', icon: string, word: string, shortWord: string = word): string {
56  if (style === 'label' || icon === '') return size === 'long' ? word : shortWord
57  if (style === 'icon') return icon
58  return size === 'long' ? `${icon} ${word}` : icon
59}
60
61// ---- The fixed tools' icons (store key `icons`) ----
62
63// The buttons of the toolbelt that take a picked icon: not `…`, and not the clock, date and weather cells.
64export const ICON_TOOLS: readonly string[] = ['pomodoro', 'curtain', 'export', 'notes', 'edit', 'theme', 'voice', 'attach']
65
66// The stored icons, cleaned: only the tools above, each with "none" or an icon of a group.
67export function asIcons(value: unknown): ToolIcons {
68  if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
69  const raw = value as Record<string, unknown>
70  const picks = ICON_TOOLS.flatMap(id => {
71    const pick = Object.hasOwn(raw, id) ? raw[id] : undefined
72    return pick === ICON_NONE || isListedIcon(pick) ? [[id, pick] as const] : []
73  })
74  return Object.fromEntries(picks)
75}
76
77// The pick for a tool: undefined for its own icon, '' for none, else the icon.
78export function pickedIcon(icons: ToolIcons, id: string): string | undefined {
79  const pick = Object.hasOwn(icons, id) ? icons[id] : undefined
80  return pick === ICON_NONE ? '' : pick
81}
82
83// The icons after a pick, or null when the tool takes no icon or the value is not a choice.
84export function withToolIcon(icons: ToolIcons, id: string, value: string): ToolIcons | null {
85  if (!ICON_TOOLS.includes(id)) return null
86  const rest = Object.fromEntries(Object.entries(icons).filter(([key]) => key !== id))
87  if (value === ICON_DEFAULT) return rest
88  return value === ICON_NONE || isListedIcon(value) ? { ...rest, [id]: value } : null
89}
90
91// ---- The pickers ----
92
93export type Choice = { value: string; label: string }
94
95export const groupChoices = (t: TFn): Choice[] => ICON_GROUPS.map(group => ({ value: group.id, label: t(`icon.group.${group.id}`) }))
96
97// Each fixed tool's own icon (the curtain's as it shows while off). A tool's "default" choice is drawn as this icon, so
98// a closed list shows the icon the band shows rather than a word. Kept beside the tools' own constants in hooks/tools;
99// tests/icons.test.ts checks every tool that takes an icon has one.
100export const OWN_ICONS: Readonly<Record<string, string>> = { pomodoro: '🍅', curtain: '▢', export: '⇩', notes: '📝', edit: '⚙', theme: '☾', voice: '🎤', attach: '📎' }
101
102// The choices of one icon Select: "default" (a fixed tool only, drawn as `own`, its own icon), "none", the current pick
103// when it belongs to another group, then the group's icons. `current` is a value of the Select: an icon, ICON_NONE or
104// ICON_DEFAULT.
105export function iconChoices(t: TFn, group: string, current: string, own: string | null): Choice[] {
106  // The tool's own icon is the Default row: listed again it would be a second, identical row that stores the icon
107  // itself (the curtain's ▢ would then no longer change to ▣ when on).
108  const listed = (ICON_GROUPS.find(g => g.id === group)?.icons ?? []).filter(icon => icon !== own)
109  const elsewhere = isListedIcon(current) && !listed.includes(current) ? [{ value: current, label: current }] : []
110  return [
111    ...(own === null ? [] : [{ value: ICON_DEFAULT, label: own }]),
112    { value: ICON_NONE, label: t('icon.none') },
113    ...elsewhere,
114    ...listed.map(icon => ({ value: icon, label: icon })),
115  ]
116}
117
hooks/lib/i18n.ts 78 lines
1// Language tables, `t` and the language decision order. Pure: environment variables and the Intl locale are read by the
2// caller and passed in.
3import { en } from '../locales/en'
4import { ja } from '../locales/ja'
5import { ko } from '../locales/ko'
6import type { Messages } from '../locales/en'
7
8export const LANGS = ['en', 'ko', 'ja'] as const
9export type Lang = (typeof LANGS)[number]
10export type TFn = (key: string, args?: Record<string, unknown>) => string
11export type ResolvedLanguage = { lang: Lang; locale: string }
12
13const TABLE: Record<Lang, Messages> = { en, ko, ja }
14const isLang = (value: string): value is Lang => (LANGS as readonly string[]).includes(value)
15const lookup = (messages: Messages, key: string): string | undefined => {
16  const value = (messages as Record<string, unknown>)[key]
17  return typeof value === 'string' ? value : undefined
18}
19
20// Replaces {name} with args.name. A placeholder with no matching argument stays as written.
21function interpolate(text: string, args?: Record<string, unknown>): string {
22  if (!args) return text
23  return text.replace(/\{(\w+)\}/g, (placeholder, name: string) => (Object.hasOwn(args, name) ? String(args[name]) : placeholder))
24}
25
26// Looks the key up in `lang`, then in English. An unknown key comes back as itself, so a typo shows.
27export function t(lang: Lang, key: string, args?: Record<string, unknown>): string {
28  return interpolate(lookup(TABLE[lang], key) ?? lookup(en, key) ?? key, args)
29}
30
31// One function per language, so a cache can tell a language change by the function alone.
32const translators = new Map<Lang, TFn>()
33export function translator(lang: Lang): TFn {
34  const known = translators.get(lang)
35  if (known !== undefined) return known
36  const made: TFn = (key, args) => t(lang, key, args)
37  translators.set(lang, made)
38  return made
39}
40export const directionOf = (lang: Lang): 'ltr' | 'rtl' => TABLE[lang].dir
41
42// "ko_KR.UTF-8" becomes "ko-KR". "C", "C.UTF-8", "POSIX" and an empty value are not language tags
43// (the pattern wants a 2-3 letter language), so they are skipped.
44function toTag(raw: string | undefined): string | null {
45  if (!raw) return null
46  const tag = (raw.split(/[.@]/)[0] ?? '').replace(/_/g, '-')
47  return /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(tag) ? tag : null
48}
49
50// The language is the setting when it names a supported language; otherwise the first supported
51// language among the environment values (given in order: LC_ALL, LC_MESSAGES, LANG) and then the
52// Intl default locale; otherwise English. The locale for time formats is separate: the first valid
53// full tag from the same list (e.g. "de-DE" even though the interface falls back to English).
54export function resolveLanguage(
55  setting: string,
56  envValues: readonly (string | undefined)[],
57  intlLocale?: string,
58): ResolvedLanguage {
59  const tags = [...envValues, intlLocale].map(toTag).filter((tag): tag is string => tag !== null)
60  const fromTags = tags.map(tag => (tag.split('-')[0] ?? '').toLowerCase()).find(isLang)
61  return { lang: (isLang(setting) ? setting : undefined) ?? fromTags ?? 'en', locale: tags[0] ?? 'en' }
62}
63
64// The locale for words such as a weekday: the language picked in the setting when the time locale speaks another one,
65// so Korean on an en_US computer writes `금`; otherwise the time locale, so a German computer with no language picked
66// keeps `23.10.2026 Fr`.
67export const wordLocale = (resolved: ResolvedLanguage, setting: unknown): string =>
68  isLang(String(setting)) && (resolved.locale.split('-')[0] ?? '').toLowerCase() !== resolved.lang ? resolved.lang : resolved.locale
69
70// The runtime's default locale (step 3 of the language decision order), or undefined where Intl cannot say.
71export function systemLocale(): string | undefined {
72  try {
73    return new Intl.DateTimeFormat().resolvedOptions().locale
74  } catch {
75    return undefined
76  }
77}
78
hooks/lib/width.ts 118 lines
1// Display width in terminal cells. Never use String.length for layout.
2// ponytail: Intl.Segmenter is assumed present (verified in the Claude Code 2.1.308 mod runtime);
3// add a code-point fallback only if a runtime without it ever shows up.
4// Parts ported from oikon48/prompt-rail (MIT): see THIRD_PARTY_NOTICES.md.
5
6type Range = readonly [number, number]
7
8// Marks that take no room of their own.
9const ZERO_WIDTH: readonly Range[] = [
10  [0x0300, 0x036f], // combining diacritics
11  [0x1160, 0x11ff], // Hangul medial vowels and final consonants
12  [0x1ab0, 0x1aff], // combining diacritics extended
13  [0x200b, 0x200f], // zero-width space, joiners, direction marks
14  [0x20d0, 0x20ff], // combining marks for symbols
15  [0x2060, 0x2064], // word joiner and invisible operators
16  [0xfe00, 0xfe0f], // variation selectors
17  [0x1f3fb, 0x1f3ff], // skin tone modifiers
18]
19
20// East Asian Wide/Fullwidth plus characters that default to emoji presentation.
21const WIDE: readonly Range[] = [
22  [0x1100, 0x115f],
23  [0x2e80, 0x303e],
24  [0x3041, 0x33ff],
25  [0x3400, 0x4dbf],
26  [0x4e00, 0x9fff],
27  [0xa000, 0xa4cf],
28  [0xac00, 0xd7a3],
29  [0xf900, 0xfaff],
30  [0xfe30, 0xfe4f],
31  [0xff00, 0xff60],
32  [0xffe0, 0xffe6],
33  [0x1f1e6, 0x1f1ff], // regional indicators (flags)
34  [0x1f300, 0x1f64f],
35  [0x1f680, 0x1f6ff],
36  [0x1f900, 0x1f9ff],
37  [0x1fa70, 0x1faff],
38  [0x20000, 0x3fffd],
39  // Symbols that default to emoji presentation (Unicode Emoji_Presentation=Yes).
40  // Text-default symbols such as U+2600 (sun) stay 1 cell unless followed by U+FE0F.
41  [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
42  [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f],
43  [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be],
44  [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea],
45  [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd],
46  [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
47  [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],
48  [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50],
49  [0x2b55, 0x2b55],
50]
51
52const ZWJ = 0x200d
53const VS16 = 0xfe0f
54const ELLIPSIS = '…'
55
56const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
57
58function inRanges(codePoint: number, ranges: readonly Range[]): boolean {
59  return ranges.some(([low, high]) => codePoint >= low && codePoint <= high)
60}
61
62// Width of one grapheme cluster, decided by its first code point.
63function clusterCells(cluster: string): number {
64  const codePoints = Array.from(cluster, ch => ch.codePointAt(0) ?? 0)
65  const first = codePoints[0] ?? 0
66  if (inRanges(first, ZERO_WIDTH)) return 0
67  if (inRanges(first, WIDE)) return 2
68  if (codePoints.includes(VS16)) return 2
69  // Emoji ZWJ sequence that starts with a text-default symbol (e.g. a heart + ZWJ + fire).
70  if (codePoints.includes(ZWJ) && codePoints.some(cp => inRanges(cp, WIDE))) return 2
71  return 1
72}
73
74export function cellsOf(text: string): number {
75  let cells = 0
76  for (const { segment } of segmenter.segment(text)) cells += clusterCells(segment)
77  return cells
78}
79
80// Splits text into lines of at most `max` cells, never inside a grapheme. ponytail: breaks mid-word; break at
81// spaces if cards read badly.
82export function wrapCells(text: string, max: number): string[] {
83  if (max <= 0) return []
84  const lines: string[] = []
85  let line = ''
86  let used = 0
87  for (const { segment } of segmenter.segment(text)) {
88    const cells = clusterCells(segment)
89    if (used + cells > max && line !== '') {
90      lines.push(line)
91      line = ''
92      used = 0
93    }
94    line += segment
95    used += cells
96  }
97  return line === '' ? lines : [...lines, line]
98}
99
100// Text followed by spaces up to `width` cells, so a card painted over rows hides them whole.
101export const padCells = (text: string, width: number): string => text + ' '.repeat(Math.max(0, width - cellsOf(text)))
102
103// Cuts text to at most `max` cells; when something is cut the result ends with an ellipsis.
104export function truncateCells(text: string, max: number): string {
105  if (max <= 0) return ''
106  if (cellsOf(text) <= max) return text
107  const budget = max - cellsOf(ELLIPSIS)
108  let used = 0
109  let kept = ''
110  for (const { segment } of segmenter.segment(text)) {
111    const cells = clusterCells(segment)
112    if (used + cells > budget) break
113    kept += segment
114    used += cells
115  }
116  return kept + ELLIPSIS
117}
118
hooks/lib/schema.ts 150 lines
1// Store schema version, per-key defaults and migration. Pure: no `$`.
2import type { ButtonConfig, Layout, ToolButton } from '../../types'
3import { isListedIcon } from './icons'
4
5export const CURRENT_SCHEMA = 1
6export const DEFAULT_SET = 'default'
7// English fallback; the language files supply the localized body.
8export const DEFAULT_REVIEW_TEXT = 'Review the change you just made and list the risks.'
9
10// The keys a migration is responsible for. Other keys (notes, pomodoro, ...) have no stored shape to fix.
11export type StoreSnapshot = { schemaVersion: typeof CURRENT_SCHEMA; buttons: ButtonConfig; layout: Layout }
12
13export const LEFT_DEFAULT: readonly string[] = ['clock', 'date', 'weather', 'pomodoro']
14export const RIGHT_DEFAULT: readonly string[] = ['attach', 'voice', 'theme', 'curtain', 'export', 'notes', 'edit']
15const BUTTON_KINDS: readonly unknown[] = ['prompt', 'command', 'skill']
16const BUTTON_MODES: readonly unknown[] = ['fill', 'send', 'run']
17// A user button's hotkey: one lowercase letter (no digits).
18export const HOTKEY_PATTERN = /^[a-z]$/
19// Control characters and line or paragraph separators: in a label they would break the band's one row. And the
20// invisible ones that reorder or hide text: the bidi marks, embeddings, overrides and isolates, the BOM and the tag
21// characters. Not all of \p{Cf}: the zero-width joiner (U+200D) holds emoji like a family together.
22const CONTROLS = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu
23
24export const stripControls = (text: string): string => text.replace(CONTROLS, '')
25
26// A prompt's text keeps its line breaks and tabs, the only control characters a Text draws.
27const TEXT_CONTROLS = new RegExp(`(?![\\t\\n])${CONTROLS.source}`, 'gu')
28export const stripTextControls = (text: string): string => text.replace(TEXT_CONTROLS, '')
29
30export function defaultButtons(reviewText: string = DEFAULT_REVIEW_TEXT, reviewLabel = 'Review'): ButtonConfig {
31  return {
32    version: 1,
33    sets: {
34      [DEFAULT_SET]: [
35        { id: 'ex-review', kind: 'prompt', label: reviewLabel, text: reviewText, mode: 'fill' },
36        { id: 'ex-usage', kind: 'command', label: '/usage', command: 'usage', mode: 'run' },
37      ],
38    },
39    activeSet: DEFAULT_SET,
40  }
41}
42
43// `more` is never part of a layout: it is always drawn at the right end.
44export function defaultLayout(buttons: ButtonConfig = defaultButtons()): Layout {
45  const mine = (buttons.sets[buttons.activeSet] ?? []).map(button => `btn:${button.id}`)
46  return { left: [...LEFT_DEFAULT], right: [...mine, ...RIGHT_DEFAULT], hidden: [] }
47}
48
49const isRecord = (value: unknown): value is Record<string, unknown> =>
50  typeof value === 'object' && value !== null && !Array.isArray(value)
51
52const isStringArray = (value: unknown): value is string[] =>
53  Array.isArray(value) && value.every(item => typeof item === 'string')
54
55function isToolButton(value: unknown): value is ToolButton {
56  return (
57    isRecord(value) &&
58    typeof value.id === 'string' &&
59    typeof value.label === 'string' &&
60    BUTTON_KINDS.includes(value.kind) &&
61    BUTTON_MODES.includes(value.mode)
62  )
63}
64
65export function isButtonConfig(value: unknown): value is ButtonConfig {
66  return (
67    isRecord(value) &&
68    value.version === 1 &&
69    typeof value.activeSet === 'string' &&
70    isRecord(value.sets) &&
71    Object.hasOwn(value.sets, value.activeSet) &&
72    Object.values(value.sets).every(list => Array.isArray(list) && list.every(isToolButton))
73  )
74}
75
76// A stored button with its optional fields checked: a hotkey that is not one letter, or a text
77// field that is not a string, or an icon that is in no icon group, is dropped, and the label loses its control characters. Hand-edited
78// values and other versions' values never reach the band or a press unchecked.
79function cleanButton(button: ToolButton): ToolButton {
80  const { hotkey, text, command, args, icon } = button as Record<string, unknown>
81  return {
82    id: button.id,
83    kind: button.kind,
84    label: stripControls(button.label),
85    mode: button.mode,
86    ...(typeof hotkey === 'string' && HOTKEY_PATTERN.test(hotkey) ? { hotkey } : {}),
87    ...(typeof text === 'string' ? { text: stripTextControls(text) } : {}),
88    ...(typeof command === 'string' ? { command: stripControls(command) } : {}),
89    ...(typeof args === 'string' ? { args: stripControls(args) } : {}),
90    ...(isListedIcon(icon) ? { icon } : {}),
91  }
92}
93
94// The stored buttons, each cleaned, or null when the value does not have the ButtonConfig shape.
95// Set names lose their control characters as a new set's do; two that become one name keep both sets' buttons.
96export function cleanButtonConfig(value: unknown): ButtonConfig | null {
97  if (!isButtonConfig(value)) return null
98  const named = Object.entries(value.sets).map(([name, list]) => [stripControls(name).trim(), list.map(cleanButton)] as const)
99  const sets = named.reduce<Record<string, ToolButton[]>>((all, [name, list]) => (name === '' ? all : { ...all, [name]: [...(all[name] ?? []), ...list] }), {})
100  const active = stripControls(value.activeSet).trim()
101  const activeSet = Object.hasOwn(sets, active) ? active : Object.hasOwn(sets, DEFAULT_SET) ? DEFAULT_SET : (Object.keys(sets)[0] ?? DEFAULT_SET)
102  return { version: 1, sets: Object.keys(sets).length === 0 ? { [DEFAULT_SET]: [] } : sets, activeSet }
103}
104
105function isLayout(value: unknown): value is Layout {
106  return (
107    isRecord(value) &&
108    isStringArray(value.left) &&
109    isStringArray(value.right) &&
110    isStringArray(value.hidden)
111  )
112}
113
114// v0 is "no schemaVersion stored". Anything already stored that has the v1 shape is kept, so a lost
115// version key never wipes the user's buttons; the rest gets the defaults.
116function v0ToV1(raw: unknown): StoreSnapshot {
117  const stored = isRecord(raw) ? raw : {}
118  const buttons = cleanButtonConfig(stored.buttons) ?? defaultButtons()
119  const layout = isLayout(stored.layout) ? stored.layout : defaultLayout(buttons)
120  return { schemaVersion: CURRENT_SCHEMA, buttons, layout }
121}
122
123// Brings what was stored at `fromVersion` up to CURRENT_SCHEMA. Call it only for versions below
124// CURRENT_SCHEMA; a newer store is read-only (see isNewerSchema).
125export function migrate(raw: unknown, fromVersion: number): StoreSnapshot {
126  if (fromVersion === 0) return v0ToV1(raw)
127  throw new RangeError(`cannot migrate from schema version ${fromVersion}`)
128}
129
130// True when a newer version of the mod wrote the store: read it, never write it.
131export function isNewerSchema(version: unknown): boolean {
132  return typeof version === 'number' && version > CURRENT_SCHEMA
133}
134
135// One `$.store.get`. `isRead` is false when the read failed, which is not the same as unset.
136export type StoreRead = { isRead: boolean; value: unknown }
137export type LoadPlan = { snapshot: StoreSnapshot; isReadOnly: boolean; shouldWrite: boolean }
138
139// What a start makes of the stored schemaVersion, buttons and layout. `fresh` is the new-install buttons, in the
140// person's language, which also stand in for stored buttons too broken to read. The store is written only when all three
141// were read: after a failed read the session works from what it could read and writes
142// nothing, so a passing failure never puts defaults over the person's buttons. An unreadable version keeps the
143// read-only flag it had.
144export function planLoad(version: StoreRead, buttons: StoreRead, layout: StoreRead, fresh: ButtonConfig, wasReadOnly: boolean): LoadPlan {
145  const snapshot = migrate({ buttons: cleanButtonConfig(buttons.value) ?? fresh, layout: layout.value }, 0)
146  const isReadOnly = version.isRead ? isNewerSchema(version.value) : wasReadOnly
147  const isAllRead = version.isRead && buttons.isRead && layout.isRead
148  return { snapshot, isReadOnly, shouldWrite: isAllRead && !isReadOnly && version.value !== CURRENT_SCHEMA }
149}
150
hooks/tools/clock.ts 93 lines
1// Clock tool. Pure: the clock's formatter is passed in; the date beside it calls Intl itself and draws nothing where
2// Intl fails.
3
4export type ClockFormatOptions = { locale: string; timeZone?: string; hour12?: boolean; seconds: boolean }
5export type ClockFormatter = (nowMs: number, options: ClockFormatOptions) => string
6export type ClockLabel = { text: string; degraded: boolean }
7
8const MINUTE_MS = 60_000
9const SECOND_MS = 1_000
10
11// The timeZone setting picks from a list whose first entry, "system", means the computer's own zone;
12// an empty value (from before the list) means the same.
13export const configuredTimeZone = (options: Readonly<Record<string, unknown>>): string => {
14  const zone = typeof options.timeZone === 'string' ? options.timeZone.trim() : ''
15  return zone === 'system' ? '' : zone
16}
17
18// Maps the userConfig values (clockFormat, clockSeconds, timeZone) to formatter options.
19export function clockFormatOptions(options: Readonly<Record<string, unknown>>, locale: string): ClockFormatOptions {
20  const zone = configuredTimeZone(options)
21  return {
22    locale,
23    ...(zone === '' ? {} : { timeZone: zone }),
24    ...(options.clockFormat === '12' ? { hour12: true } : options.clockFormat === '24' ? { hour12: false } : {}),
25    seconds: options.clockSeconds === true,
26  }
27}
28
29const pad2 = (n: number): string => String(n).padStart(2, '0')
30
31// Date-getter fallback for a runtime where the formatter throws: 24-hour, local zone.
32function localClock(nowMs: number, seconds: boolean): string {
33  const date = new Date(nowMs)
34  const hm = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
35  return seconds ? `${hm}:${pad2(date.getSeconds())}` : hm
36}
37
38// The text to draw. Long and short forms are the same. `degraded` tells the caller to show
39// "time zone support unavailable" in the status tab.
40export function clockLabel(nowMs: number, options: ClockFormatOptions, format: ClockFormatter): ClockLabel {
41  try {
42    return { text: format(nowMs, options), degraded: false }
43  } catch {
44    return { text: localClock(nowMs, options.seconds), degraded: true }
45  }
46}
47
48// Milliseconds until the label can change: the next minute boundary, or 1 s with seconds shown.
49// A time exactly on the boundary waits a full period, so a timer never fires twice for one label.
50export function msUntilNextTick(nowMs: number, seconds: boolean): number {
51  const period = seconds ? SECOND_MS : MINUTE_MS
52  return period - (((nowMs % period) + period) % period)
53}
54
55export type DateFormat = 'off' | 'auto' | 'ymd' | 'dmy' | 'mdy'
56const DATE_FORMATS: readonly string[] = ['off', 'auto', 'ymd', 'dmy', 'mdy']
57// A value outside the list reads as the schema's default, as the engine reads it.
58export const dateFormatOf = (value: unknown): DateFormat => (typeof value === 'string' && DATE_FORMATS.includes(value) ? (value as DateFormat) : 'auto')
59
60// The date tool, a tool of its own beside the clock: year, month and day in the language's own order (auto) or the one
61// picked, and the weekday short (one letter in Korean and Japanese), in the clock's time zone. Null when off, or when
62// Intl does not know the zone (the clock says so already).
63// ponytail: one remembered answer per minute and setting, as a band draws the date several times a render; a map if two zones ever show
64let lastParts: { key: string; parts: { date: string; weekday: string } | null } | null = null
65
66export function dateParts(nowMs: number, format: DateFormat, locale: string, timeZone: string): { date: string; weekday: string } | null {
67  const key = `${Math.floor(nowMs / 60_000)}|${format}|${locale}|${timeZone}`
68  if (lastParts?.key !== key) lastParts = { key, parts: formatDate(nowMs, format, locale, timeZone) }
69  return lastParts.parts
70}
71
72function formatDate(nowMs: number, format: DateFormat, locale: string, timeZone: string): { date: string; weekday: string } | null {
73  if (format === 'off') return null
74  try {
75    const zone = timeZone === '' ? {} : { timeZone }
76    const weekday = new Intl.DateTimeFormat(locale, { weekday: 'short', ...zone }).format(nowMs)
77    // The Gregorian year in Western digits whatever the locale's calendar.
78    const digits = { year: 'numeric', month: '2-digit', day: '2-digit', calendar: 'gregory', numberingSystem: 'latn', ...zone } as const
79    if (format === 'auto') return { date: new Intl.DateTimeFormat(locale, digits).format(nowMs), weekday }
80    const parts = new Intl.DateTimeFormat('en-US', digits).formatToParts(nowMs)
81    const part = (type: Intl.DateTimeFormatPartTypes): string => parts.find(p => p.type === type)?.value ?? ''
82    const [y, m, d] = [part('year'), part('month'), part('day')]
83    return { date: format === 'ymd' ? `${y}/${m}/${d}` : format === 'dmy' ? `${d}/${m}/${y}` : `${m}/${d}/${y}`, weekday }
84  } catch {
85    return null
86  }
87}
88
89export const dateText = (nowMs: number, format: DateFormat, locale: string, timeZone: string): string => {
90  const parts = dateParts(nowMs, format, locale, timeZone)
91  return parts === null ? '' : `${parts.date} ${parts.weekday}`
92}
93
hooks/tools/command.ts 106 lines
1// The /toolbelt (and /tb) arguments. Pure: register.tsx runs what this returns.
2import type { PromptOrigin } from 'claude-code'
3import type { ExportFormat, PaneTab } from '../../types/index'
4import { asExportFormat } from '../lib/export'
5import { stripControls } from '../lib/schema'
6import type { TFn } from '../lib/i18n'
7import type { TocMove } from './toc'
8
9export type PomodoroVerb = 'start' | 'pause' | 'stop' | 'reset'
10
11export type ToolbeltCommand =
12  | { op: 'open'; tab: PaneTab }
13  | { op: 'band'; isHidden: boolean }
14  | { op: 'pomodoro'; verb: PomodoroVerb }
15  | { op: 'weather' }
16  | { op: 'curtain'; isOn: boolean }
17  // A move through the prompts; `/toolbelt toc` alone opens the tab.
18  | { op: 'toc'; move: TocMove }
19  // Without a format, the last one chosen.
20  | { op: 'export'; format?: ExportFormat }
21  // Words it does not know: answer the usage line, naming the first unknown word when there is one.
22  | { op: 'usage'; unknown?: string }
23
24// Shown after the command name in the typeahead: the sub-command words, which are not translated.
25export const ARGUMENT_HINT = '[tools|toc|notes|clips|status|guide|toc next|prev|first|last|<n>|find <words>|hide|show|pomodoro start|pause|stop|reset|weather refresh|curtain on|off|export txt|md|html|pdf]'
26
27// `more` was the tab the status tab took in; the word still opens it.
28const TABS: Readonly<Record<string, PaneTab>> = { '': 'tools', tools: 'tools', toc: 'toc', notes: 'notes', clips: 'clips', more: 'status', status: 'status', guide: 'guide' }
29const VERBS: readonly string[] = ['start', 'pause', 'stop', 'reset']
30const TOC_STEPS: readonly string[] = ['next', 'prev', 'first', 'last']
31
32export function parseCommand(args: string): ToolbeltCommand {
33  const [first = '', ...rest] = stripControls(args).trim().toLowerCase().split(/\s+/)
34  // Own keys only: `constructor` or `__proto__` is not a tab.
35  const tab = Object.hasOwn(TABS, first) ? TABS[first] : undefined
36  const isBand = first === 'hide' || first === 'show'
37  if (tab !== undefined && rest.length === 0) return { op: 'open', tab }
38  if (isBand && rest.length === 0) return { op: 'band', isHidden: first === 'hide' }
39  if (first === 'pomodoro') {
40    const verb = rest[0] ?? ''
41    return VERBS.includes(verb) && rest.length === 1 ? { op: 'pomodoro', verb: verb as PomodoroVerb } : unknownWord(rest[0] ?? '')
42  }
43  if (first === 'weather') {
44    const verb = rest[0] ?? ''
45    if (verb === 'refresh' && rest.length === 1) return { op: 'weather' }
46    return unknownWord(verb === 'refresh' ? (rest[1] ?? '') : verb)
47  }
48  if (first === 'toc') return tocCommand(rest)
49  if (first === 'export') {
50    const format = asExportFormat(rest[0])
51    if (rest.length === 0) return { op: 'export' }
52    return format !== null && rest.length === 1 ? { op: 'export', format } : unknownWord(format === null ? (rest[0] ?? '') : (rest[1] ?? ''))
53  }
54  if (first === 'curtain') {
55    const verb = rest[0] ?? ''
56    const isVerb = verb === 'on' || verb === 'off'
57    return isVerb && rest.length === 1 ? { op: first, isOn: verb === 'on' } : unknownWord(isVerb ? (rest[1] ?? '') : verb)
58  }
59  // After a word it knows, the first extra word is the unknown one.
60  return unknownWord(tab !== undefined || isBand ? (rest[0] ?? '') : first)
61}
62
63const unknownWord = (word: string): ToolbeltCommand => (word === '' ? { op: 'usage' } : { op: 'usage', unknown: word })
64
65// The words of a find go back to the person in a toast, so they are cut to this many characters.
66const FIND_CHARS_MAX = 80
67
68// `toc next|prev|first|last`, `toc 12` or `toc #12`, `toc find <words>`; the words of a find keep their order.
69function tocCommand(rest: readonly string[]): ToolbeltCommand {
70  const [verb = '', ...more] = rest
71  const number = /^#?(\d+)$/.exec(verb)?.[1]
72  if (verb === 'find') return more.length > 0 ? { op: 'toc', move: { kind: 'find', words: Array.from(more.join(' ')).slice(0, FIND_CHARS_MAX).join('') } } : unknownWord('')
73  if (more.length > 0) return unknownWord(more[0] ?? '')
74  if (number !== undefined) return { op: 'toc', move: { kind: 'number', n: Number(number) } }
75  return TOC_STEPS.includes(verb) ? { op: 'toc', move: { kind: verb as 'next' | 'prev' | 'first' | 'last' } } : unknownWord(verb)
76}
77
78// Hiding the band or the transcript and writing the conversation to a file are for the person alone: a typed command,
79// the remote bridge, or the desktop app, whose sessions run over the SDK so what a person types there is `sdk` (its
80// transcripts: promptSource "sdk"); never another plugin.
81// ponytail: any active session's sdk prompt counts, as only one with a surface runs the mod; an SDK script that a phone
82// attaches to would count too, so check `$.session.surfaces()` for the desktop if such hosts show up
83export const isHumanOrigin = (origin: PromptOrigin): boolean => origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk'
84
85const POMODORO_REPLY = { start: 'command.pomodoro.started', pause: 'command.pomodoro.paused', stop: 'command.pomodoro.stopped', reset: 'command.pomodoro.reset' } as const
86
87// The line /toolbelt answers once the command ran. Opening the panel answers with no text.
88export function replyFor(command: ToolbeltCommand, t: TFn): string | undefined {
89  switch (command.op) {
90    case 'open': return undefined
91    // A move answers with a toast, as the person reads far above where a reply row would land.
92    case 'toc': return undefined
93    case 'band': return t(command.isHidden ? 'command.hidden' : 'command.shown')
94    case 'pomodoro': return t(POMODORO_REPLY[command.verb])
95    case 'weather': return t('command.weather.refreshing')
96    // Raising the curtain hides this very reply, so the toast says it instead.
97    case 'curtain': return command.isOn ? undefined : t('command.curtain.off')
98    // register.tsx answers with where the files went.
99    case 'export': return undefined
100    case 'usage':
101      return command.unknown === undefined
102        ? t('command.usage')
103        : `${t('command.error.unknownArgument', { arg: command.unknown })} ${t('command.usage')}`
104  }
105}
106
hooks/tools/curtain.ts 23 lines
1// Curtain tool. Pure: register.tsx keeps the state and draws what this decides.
2import type { Effect } from '../lib/effects'
3import { faceOf } from '../lib/icons'
4import type { IconStyle } from '../lib/icons'
5import type { TFn } from '../lib/i18n'
6
7// Filled while the curtain is up, empty while it is down. The word beside it names what a press does.
8const ICON = { on: '▣', off: '▢' } as const
9// The toast says what the curtain leaves out, so it is longer than most and stays longer than the default 4 seconds.
10export const CURTAIN_TOAST_MS = 8000
11
12// `▣ Show` / `▢ Hide`, the icon alone when the band is tight, by the icon style. `picked` is the icon the person chose
13// for this button (`''` for none), which stands for both states.
14export function curtainLabel(isOn: boolean, size: 'long' | 'short', style: IconStyle, picked: string | undefined, t: TFn): string {
15  return faceOf(style, size, picked ?? (isOn ? ICON.on : ICON.off), t(isOn ? 'curtain.show' : 'curtain.hide'))
16}
17
18// Raising the curtain says once what it leaves out; lowering it needs no word.
19export const curtainEffects = (isOn: boolean, t: TFn): Effect[] => [
20  { kind: 'state.update', key: 'curtain', fn: () => isOn },
21  ...(isOn ? [{ kind: 'ui.toast' as const, text: t('toast.curtainOn'), timeoutMs: CURTAIN_TOAST_MS }] : []),
22]
23