SLOPSHOPPER

dash

The session's dashboard in one pane, opened by /dash: the model, the effort and presets that set both, ultracode and fast mode, each switched with one click…

newpaneguardcommandtoastprocess
v0.1.6MITupdated 2026-10-07devohmycode/ccmods/dash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dash
│ ┃ Dash ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Model claude-opus-5-5 │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ ○ Fable strongest │ ⎿ Read 6 lines │ ┃ │ ● Opus 5.5 deep │ ⏺ Update(src/auth.ts) │ ┃ │ ○ Sonnet balanced │ ⎿ Added 2 lines, removed 1 line │ ┃ │ ○ Haiku fast │ ⏺ Bash(bun test) │ ┃ │ Other: Opus 5.5 ▾ │ ⎿ 3 pass, 1 fail │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ⚡ Effort unknown │ ┃ ╭──────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ○ low ▰▱▱▱▱ │ │ ┃ │ ○ medium ▰▰▱▱▱ │ › /dash │ ┃ │ ○ high ▰▰▰▱▱ │ ⎿ dash: Dash pane opened. │ ┃ │ ○ xhigh ▰▰▰▰▱ │ │ ┃ │ ○ max ▰▰▰▰▰ │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ applied by the mod on each request (no │ ┃ /effort here) │ ┃ │ ┃ ◇ Presets │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ○ deep Opus · xhigh │ │ ┃ │ ○ daily Sonnet · medium │ │ ┃ │ ○ quick Haiku │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dash
◆ Model claude-opus-5-5 ╭──────────────────────────────────────────────────────────╮ │ ○ Fable strongest │ │ ● Opus 5.5 deep │ │ ○ Sonnet balanced │ │ ○ Haiku fast │ │ Other: Opus 5.5 ▾ │ ╰──────────────────────────────────────────────────────────╯ ⚡ Effort unknown ╭──────────────────────────────────────────────────────────╮ │ ○ low ▰▱▱▱▱ │ │ ○ medium ▰▰▱▱▱ │ │ ○ high ▰▰▰▱▱ │ │ ○ xhigh ▰▰▰▰▱ │ │ ○ max ▰▰▰▰▰ │ ╰──────────────────────────────────────────────────────────╯ applied by the mod on each request (no /effort here) ◇ Presets ╭──────────────────────────────────────────────────────────╮ │ ○ deep Opus · xhigh │ │ ○ daily Sonnet · medium │ │ ○ quick Haiku │ │ + New preset │ ╰──────────────────────────────────────────────────────────╯ ◈ Session 48,70 % ╭──────────────────────────────────────────────────────────╮ │ Context: ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ │ │ 5 h quota: ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31 % │ │ Cost: $0.42 · Duration: 30 min │ │ ⊟ compact │ │ Name: rename the session ⏎ rename │ ╰──────────────────────────────────────────────────────────╯ ➤ Message ↻ ╭──────────────────────────────────────────────────────────╮ │ Looking for sessions… │ ╰──────────────────────────────────────────────────────────╯ ▤ Library ╭──────────────────────────────────────────────────────────╮ │ ▸ Subagents 0 │ │ ▸ Commands 0 │ │ ▸ Skills 0 │ │ ▸ Plugins 0 │ │ ▸ MCP servers 0 │ │ ▸ Hooks 0 │ │ ▸ Settings 0 │ ╰──────────────────────────────────────────────────────────╯ ⌨ Keyboard shortcuts: hidden
README

dash — the session's dashboard

One pane, opened by /dash, that gathers what you would otherwise reach through a dozen slash commands: the model, the effort level and presets that set both, ultracode and fast mode, the context gauge, /compact, the session's name, messages to your other sessions, your agents, commands, skills, plugins, MCP servers and hooks, and the /config settings. A click changes any of them.

<table> <tr> <th>Terminal</th> <th>Claude desktop (Code tab)</th> </tr> <tr> <td valign="top"><img src="assets/dash-terminal.png" alt="dash in the terminal" width="300"></td> <td valign="top"><img src="assets/dash-desktop.png" alt="dash in the Claude desktop app" width="300"></td> </tr> </table>

Each model has its color: purple for Fable, orange for Opus, blue for Sonnet, green for Haiku. The Model frame takes the active model's color, and its row carries the version read from its id (Opus 5.5). Effort runs from green to red, and its frame takes the active level's color. The desktop app draws the effort gauges and the context bar as graphics, the terminal as text.

A switch that goes through is announced by a 5 s toast: ⇄ Model Opus → Sonnet, ⚡ Effort medium → max or ◇ Preset deep: Sonnet → Opus, medium → xhigh. When a switch fails, the toast says why.

Usage

CommandEffect
/dashopens the pane
/dash opus, /dash sonnet, /dash haiku, /dash fableswitches model without the pane
/dash low … /dash maxswitches effort without the pane
/dash deep, /dash quick, …applies a preset
/dash ultracode, /dash ultracode offturns ultracode on or off (/effort ultracode)
/dash set …lists or changes the mod's settings (see Settings)

Model, effort and presets

  • Model: the four current families, then Other, a drop-down of the versioned ids still served (Opus 4.8, Sonnet 4.6, Haiku 4.5…). No hook gives /model's list, so the mod keeps its own (MORE_MODELS).
  • Effort: low to max, each with its gauge. An effort just picked shows (asked) until a main request carries it.
  • Ultracode and Fast mode: one button each (see How it works).
  • Presets: deep (Opus · xhigh), daily (Sonnet · medium) and quick (Haiku) out of the box. + New preset opens a form — name, model, effort or the model's own — and Add saves it to the presets setting. A preset you added can be deleted from the pane; the shipped ones cannot.

Session

  • Context: how full the context window is, as the status line reads it, with its percentage next to the title.
  • 5 h quota and Weekly quota: how much of each rate-limit window is used, as a gauge and a percentage, and how long until it resets. A gateway's spend limit shows the same way. On an API key, with no subscription, the engine reports no window and these rows are left out.
  • Cost and Duration: what the session has cost so far, as /cost totals it, and how long it has run. The pane redraws once a minute so the durations stay current.
  • Colors: every gauge of the frame, the context's included, fills green under 50 %, yellow under 75 %, orange under 90 %, red after.
  • compact: asks first, with an optional field for instructions, then runs /compact <instructions>. A stray click summarizes nothing.
  • Name: renames the session through /rename. A /rename you type yourself shows here too.

Message

Sends a message to one of your other open sessions, listed as the ListAgents tool lists them (name and state). The list is read again when the To drop-down opens, or with ↻.

Into picks where the message lands:

  • pane: the message is signed and wrapped so that the other session's dash takes it before its Claude sees it. It shows there as a toast and under Received (the last 20 are kept), with ↩ reply (the sender becomes the recipient), ⇢ Claude (puts the message in that session's prompt box, for its user to send or change) and ✕.
  • Claude: the message is delivered as is, to the other session's Claude.

A message that cannot be delivered stays in the field, and the toast says why.

Library

Seven folded sections, each with its count. A click on a title unfolds it in a frame of its color.

SectionWhat it listsA click on an entry
Subagentsyour custom agents (the local /context breakdown)puts @agent-<name> at the head of the prompt: your next message goes to it
Commandsthe .md files under commands/ (a subfolder is a namespace: git/commit.md → /git:commit)puts /<name> at the head of the prompt
Skillsthe folders under skills/ that hold a SKILL.mdputs /<name> at the head of the prompt
Pluginsthe plugins enabledPlugins names in your settingsturns it on or off (`claude plugin enable\disable`); ↻ /reload-plugins then applies it
MCP serversthe connected, configured and disabled serversturns it on or off (`/mcp enable\disable`)
Hooksthe hooks in your settings, by event, with how many run on eachnothing: /hooks changes them
Settingsevery /config rowchanges it at once: a switch, a drop-down or a field

Subagents, commands and skills are split into two frames: Global (~/.claude/…) then Project (the session folder's .claude/…). The plugins' and the built-in ones are left out. Each frame shows its first 3 entries; ▾ Expand (+N) shows the rest, ▴ Collapse goes back. What is unfolded stays so until the module reloads.

Putting a command or an agent in the prompt replaces one already there and keeps what you typed after it. Nothing is sent until you send it. The filled dot marks the entry the draft opens with. The lists are read again each time /dash opens, so a file added during the session shows on the next open. The global folder is CLAUDE_CONFIG_DIR where set, ~/.claude otherwise.

Keyboard shortcuts

Hidden by default. Keyboard shortcuts: hidden at the foot of the pane (or /dash set keyboard on) shows them, each next to its row, and binds them while the pane has focus.

The switches take one run of keys: digits for the models then the levels, letters for the presets, then fast mode and ultracode. With the default catalog: 1–4 model, 5–9 effort, b d e preset, f fast mode, i ultracode. The run follows your settings.

The other buttons have a fixed key: o other models, n new preset, v add it, x compact (y to confirm), r rename, t message recipient, a c s p m h the library sections, g Settings, l /reload-plugins, k hide the shortcuts.

Settings (/dash set)

Installing the mod asks nothing: its settings are not in the manifest's userConfig, every line of which Claude Code would show at install time. They are set by command, and the mod keeps them in its own store, from one session to the next.

CommandEffect
/dash setlists the settings and their values
/dash set language frchanges a setting, in effect at once
/dash set presetsno value: back to the default
SettingDefaultEffect
languageenen, fr, es, de. A line a translation lacks shows in English.
presetsdeep=opus/xhigh, daily=sonnet/medium, quick=haikuname=model/effort or name=model (the effort stays as it is).
modelsemptyids to add after the built-in models: id or id=Label (claude-opus-4-1=Opus 4.1).
hideModelsemptykeys or ids to remove from the pane (fable).
maxAgentsemptycap on agents per workflow (3); empty, none.
keyboardoffon shows and binds the shortcuts.

models and hideModels let you follow a model the built-in list does not name yet: /dash set hideModels fable then /dash set models claude-fable-5-2=Fable 5.2 replace Fable's id without touching the code. A preset named like a model, a level, set or ultracode (opus, max) could never be reached by /dash, since that name means something else first: it is refused. Any part of a setting that does not read is set aside and named at the foot of the pane and in /dash set's answer, rather than failing the mod.

The description of /dash in the command list takes the new language at the next session; the pane and the answers take it at once.

How it works

  • A click runs Claude Code's own command, /model <alias> or /effort <level>, as if you typed it. The session's setting changes and the status line follows; the mod does not stand between the session and its model.
  • A click also changes the default for new sessions: /effort saves the level picked as the default setting.
  • A preset runs only what changes: /model if the model differs, then /effort if the level differs and the model takes one. Both land before the next request, so the cache is lost only once.
  • Without /effort (a version that lacks it), the mod keeps the level picked and writes it into each main request at turn.step, and the pane says so. If the engine then sends another level under the same model, the mod lets go of its own.
  • What the pane shows comes from the requests themselves. Each main turn.step carries the model and effort the engine resolved, including after a silent downgrade: the pane shows what was sent, not what was asked. Before the first request, the model comes from $.session.model() and the effort from the effortLevel setting.
  • Picking the value already active runs nothing. The toast says already on …, and the cache stays intact.
  • The active model is recognized by its id first (a model added through models), then by its family. A model the pane does not offer shows by its id in the header, with no row checked.
  • A typed /dash opus cannot run /model from its own hook: the session is waiting on that hook. The mod answers at once, then runs the command on a 0 ms timer. If the session is not ready yet, it says so rather than announcing a switch that will not happen.
  • Haiku takes no effort: the levels are then drawn greyed out, no click or shortcut triggers them, and /dash low answers without running anything.
  • Fast mode has a button only if the session knows /fast. It toggles; no hook reads its state, so the pane shows the one the last /fast reported (yours or its own), and — until one has run.
  • Ultracode has a button only if the session knows /effort. It runs /effort ultracode or /effort ultracode off and leaves the effort level as it is. No hook reads its state: the pane shows the one the mod last asked for (off at session start, since it lasts one session). A /effort ultracode typed by hand escapes it. Its frame also shows the workflow size set in /config, read from .claude.json (read only: a mod does not write there).
  • Max agents, a menu in the Ultracode frame (or /dash set maxAgents 3): a hard cap on how many agents a workflow starts. A workflow's agents pass no hook (agent.spawn only sees the Agent tool), so the mod rewrites the script when the Workflow tool is called, a counter right after meta. Past the cap, agent() fails with dash: agent cap (N) reached, which parallel() and pipeline() turn into null. The cap fails closed: a script run by scriptPath is read and sent inline, capped; a workflow run by name, or a script with no meta literal, is refused with the reason; a nested workflow() is refused, its agents escaping the counter. Finding meta skips strings and comments. There is no token budget: a +300k typed in the message gave workflows no cap (budget.total empty, measured), so the mod adds none.
  • Drop-down menus are missing on mobile, which does not draw them.

Cost

Switching model or effort mid-session invalidates the message cache: the next request writes the whole context again at full price. The pane does not show it; the switch's toast says it, when it happens.

  • Tokens are measured, not estimated: it is the usage the API reported for the last main request (input + cache reads and writes + output), the least the next request will send again.
  • The cache's state is inferred from that request's age. No hook reads the cache TTL (5 min by default, 1 h on some accounts): in between, the toast counts the tokens the cache might still hold.
  • A switch's toast adds · 84 k tokens to cache again when the cache could still hold them.
  • "Full price" means a cache write: 1.25× the input price (5 min TTL) or 2× (1 h TTL), against 0.1× for a read. The toast counts tokens, not dollars.
  • Only main requests count: a subagent has its own cache, which switching the session's model does not touch.

Out of scope

/effort auto is not offered: turn.step reports the resolved level, so an auto button could never show as active. A "session only" mode, which would leave the default for new sessions alone, does not exist yet: for the model, it would mean rewriting e.model at turn.step, and the status line would no longer follow.

Source 26 files
hooks/register.ts 1195 lines
1/**
2 * Switch: a pane that lists the models, the effort levels and the presets
3 * that set both, and switches any of them with one click.
4 *
5 * A click runs the engine's own command, `/model <alias>` or
6 * `/effort <level>`, as if the person typed it: the session's setting
7 * changes, the status line follows, and nothing of the mod stands between
8 * the session and its model. Where the session has no `/effort`, the mod
9 * holds the level and writes it on each main request at `turn.step`.
10 *
11 * The current values come from the requests themselves: every main
12 * `turn.step` carries the model and the effort the engine resolved for it,
13 * after any downgrade, so the pane shows what was sent, not what was asked.
14 * Its result carries the usage the pane prices a switch with.
15 */
16
17import type { ConfigRow, ConfigValue, EngineInterface, FsEntry, PromptBox, PromptFilled, Register, SettingsSource } from 'claude-code'
18
19import { agentsOf, draftWith, mentionOf, mentionedIn } from './agents'
20import type { AgentScope } from './agents'
21import {
22  MORE_MODELS,
23  cappedScriptOf,
24  fastStateOf,
25  choiceOf,
26  effortOf,
27  idleReasonOf,
28  pickOf,
29  planOf,
30  presetText,
31  savedEffortOf,
32  switchedText,
33} from './catalog'
34import type { Effort, Pick } from './catalog'
35import { catalogOf, isOwnPreset, presetPartOf, withoutPreset } from './config'
36import type { Catalog, PresetDraft } from './config'
37import { LONG_TTL_MS, WARM_MS, costSuffixOf, measureOf, switchCostOf } from './cost'
38import type { LastRequest, SwitchCost } from './cost'
39import {
40  EMPTY_LIBRARY,
41  commandNameOf,
42  draftWithCommand,
43  entriesOf,
44  hooksOf,
45  invokedIn,
46  isToggleKind,
47  jsonOf,
48  keysOf,
49  mcpOf,
50  pluginsOf,
51  projectStateOf,
52  serverNameOf,
53  stringsOf,
54  toggleCommandOf,
55} from './library'
56import type { Library, LibraryKind, Sourced } from './library'
57import { addressOf, panelMessageOf, panelTextOf, peersOf, selfOf } from './message'
58import type { MessageMode, PanelMessage, Peer } from './message'
59import { COMMAND, MESSAGE_TO_KEY, NEW_PRESET_KEY, PANE_ID } from './names'
60import { drawPane } from './pane'
61import { LANGUAGES, say, setSay } from './say'
62import {
63  DEFAULT_PREFS,
64  PREF_KEYS,
65  prefKeyOf,
66  prefsLines,
67  prefsOf,
68  setRequestOf,
69  stateFileOf,
70  withPref,
71} from './settings'
72import type { Prefs } from './settings'
73import { USAGE_TICK_MS, usageViewOf } from './usage'
74
75/**
76 * The calls the buttons make after the render that drew them has returned,
77 * captured from `engine.create`.
78 */
79type Host = {
80  invalidate: () => void
81  model: () => Promise<string>
82  run: (command: string, args: string) => Promise<unknown>
83  toast: (text: string, timeoutMs?: number) => void
84  later: (fn: () => void) => void
85  after: (ms: number, fn: () => void) => { cancel: () => void }
86  now: () => Promise<number>
87  read: () => Promise<PromptBox>
88  fill: (text: string) => Promise<PromptFilled>
89  list: (path: string) => Promise<FsEntry[]>
90  exists: (path: string) => Promise<boolean>
91  save: (prefs: Prefs) => Promise<unknown>
92  settings: (source: SettingsSource) => Promise<Record<string, unknown>>
93  file: (path: string) => Promise<string>
94  cwd: () => Promise<string>
95  exec: (argv: readonly string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>
96  setConfig: (key: string, value: ConfigValue) => Promise<{ value?: unknown; deny?: string }>
97  peers: () => Promise<string>
98  send: (to: string, text: string) => Promise<{ isDelivered: boolean; reason?: string }>
99}
100
101/** The settings sources the setup is read from, lowest first, each with its scope. */
102/** How many messages received the pane keeps. */
103const RECEIVED_KEPT = 20
104
105const SETUP_SOURCES: readonly [AgentScope, SettingsSource][] = [
106  ['user', 'user'],
107  ['project', 'project'],
108  ['project', 'local'],
109]
110
111/**
112 * Where the person's own definitions and the engine's state file live;
113 * unreadable variables leave the global folders out, not the pane.
114 *
115 * @param $ the engine
116 * @returns the `.claude` folder and the state file, where known
117 */
118async function pathsOf($: EngineInterface): Promise<{ configDir: string | undefined; stateFile: string | undefined }> {
119  const home =
120    (await $.env.get('USERPROFILE').catch(() => undefined)) ?? (await $.env.get('HOME').catch(() => undefined))
121  const custom = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
122
123  return { configDir: custom ?? (home === undefined ? undefined : `${home}/.claude`), stateFile: stateFileOf(home, custom) }
124}
125
126/** How deep a commands folder's namespaces are followed. */
127const COMMANDS_DEPTH = 3
128
129/** How long a switch's notification stays, in milliseconds. */
130const TOAST_MS = 5000
131
132/**
133 * Registers `/dash`, its pane, and the `turn.step` hook that keeps the
134 * pane's values current. Each switch that takes is announced by a toast
135 * naming the values before and after; one that fails, by its reason.
136 *
137 * The settings come from the mod's store, read at `session.start` and
138 * rewritten by `/dash set`; the manifest declares none, so installing
139 * the mod asks nothing.
140 *
141 * @param on the module's hook registrar
142 */
143export const register: Register = on => {
144  let prefs: Prefs = DEFAULT_PREFS
145  let catalog: Catalog = catalogOf(prefs)
146
147  /**
148   * Puts the settings in force: the language, then the catalog they name.
149   *
150   * @param next the settings
151   */
152  const settle = (next: Prefs): void => {
153    prefs = next
154    setSay(prefs.language)
155    catalog = catalogOf(prefs)
156  }
157
158  settle(prefs)
159
160  let host: Host | undefined
161  let model: string | undefined
162  let effort: Effort | undefined
163  // The level a click asked for where the session has no /effort: the mod
164  // writes it on each main request instead.
165  let held: Effort | undefined
166  let hasEffortCommand = true
167  // The model and level the engine itself last sent, before the mod's: when
168  // the level moves under the same model, the person ran /effort, and their
169  // level wins over the held one. A new model brings its own default level.
170  let engineSeen: { model: string; effort: string | number | undefined } | undefined
171  let hasFast = false
172  // Fast mode's state, from the last /fast output; unknown until one runs.
173  let isFast: boolean | undefined
174  // Ultracode lasts the session and no hook reads it: the mod keeps what it
175  // last asked, off at the start.
176  let isUltracode = false
177  // Whether the paths and the lists were read: a module reloaded mid-session
178  // gets no session.start, and its first draw reads them instead.
179  let isRead = false
180  // True from a click on an effort until a main request carries a level:
181  // till then the pane shows what was asked, not what was sent.
182  let isEffortAsked = false
183  // The last main request, measured, and the timers that redraw the pane
184  // when its cache crosses 5 minutes, then 1 hour.
185  let last: LastRequest | undefined
186  let expiry: { cancel: () => void }[] = []
187  // The redraw that ages the Session frame's durations, armed by a draw.
188  let tick: { cancel: () => void } | undefined
189  // The person's agents, commands and skills, read at session.start and
190  // again each time /dash opens the pane.
191  let library: Library = EMPTY_LIBRARY
192  // Where the person's own definitions live (`~/.claude`), read at session.start.
193  let configDir: string | undefined
194  // The engine's state file (`.claude.json`), read at session.start and on each open.
195  let stateFile: string | undefined
196  // True from a plugin's toggle until `/reload-plugins` has run.
197  let isReloadPending = false
198  // The MCP servers with tools in the context, from the last breakdown.
199  let connected: string[] = []
200  // What the prompt box opens with, by kind: read when /dash opens the pane
201  // and set by a pick, never by the render, which must not wait on the box.
202  let current: Partial<Record<LibraryKind, string>> = {}
203  // The sections the person opened and the scope frames they expanded
204  // (`<kind>:<scope>`), for the life of the module.
205  const openKinds = new Set<LibraryKind>()
206  const expandedFrames = new Set<string>()
207  // The session's name, once /rename gave one, and whether its field is
208  // open again to change it: a named session shows the name, not the field.
209  let sessionName: string | undefined
210  // The person's other sessions, once listed, the one a message goes to,
211  // and the message being typed.
212  let peers: Peer[] | undefined
213  let messageTo: string | undefined
214  let messageDraft = ''
215  // This session as the listing names it, which a message for a pane signs;
216  // where a message goes; and the messages received for this pane, latest first.
217  let self: Omit<Peer, 'state'> | undefined
218  let messageMode: MessageMode = 'panel'
219  let received: (PanelMessage & { id: number })[] = []
220  let receivedCount = 0
221  let isRenaming = false
222  // Whether /compact waits for its confirmation: a stray click summarizes
223  // the conversation for good.
224  let isCompactAsked = false
225  // The instructions typed for it, passed on as `/compact <instructions>`.
226  let compactNote = ''
227  // The preset the person is composing in the pane.
228  let draftPreset: PresetDraft = { name: '', model: '', effort: '' }
229  // The drop-downs the person opened, by key.
230  const openMenus = new Set<string>()
231
232  /**
233   * The commands or the skills defined under one `.claude` folder.
234   *
235   * @param kind `commands` (Markdown files, a subfolder a namespace) or `skills` (folders with a SKILL.md)
236   * @param base the `.claude` folder
237   * @returns their names; none where the folder is missing
238   */
239  const namesIn = async (kind: 'commands' | 'skills', base: string): Promise<string[]> => {
240    const h = host
241
242    if (h === undefined) {
243      return []
244    }
245
246    if (kind === 'skills') {
247      const dirs = await h.list(`${base}/skills`).catch(() => [])
248      const found = await Promise.all(
249        dirs
250          .filter(one => one.kind === 'dir' || one.isLink)
251          .map(async one => ((await h.exists(`${base}/skills/${one.name}/SKILL.md`).catch(() => false)) ? one.name : '')),
252      )
253
254      return found.filter(name => name !== '')
255    }
256
257    const names: string[] = []
258    const walk = async (path: string, depth: number): Promise<void> => {
259      for (const one of await h.list(`${base}/commands${path}`).catch(() => [])) {
260        if (one.kind === 'dir' && depth < COMMANDS_DEPTH) {
261          await walk(`${path}/${one.name}`, depth + 1)
262        } else if (one.kind === 'file') {
263          const name = commandNameOf(`${path}/${one.name}`)
264
265          if (name !== undefined) {
266            names.push(name)
267          }
268        }
269      }
270    }
271
272    await walk('', 0)
273
274    return names
275  }
276
277  /**
278   * Reads the library again: the agents from the breakdown, the commands
279   * and the skills from the global and the project's folders.
280   *
281   * @param agents the breakdown's agents, or `undefined` to keep the last ones
282   */
283  const readLibrary = async (agents: readonly { agentType: string; source: string }[] | undefined): Promise<void> => {
284    const roots: [AgentScope, string | undefined][] = [
285      ['user', configDir],
286      ['project', '.claude'],
287    ]
288    const read = async (kind: 'commands' | 'skills') =>
289      (
290        await Promise.all(
291          roots.map(async ([scope, base]) => (base === undefined ? [] : entriesOf(await namesIn(kind, base), scope))),
292        )
293      ).flat()
294
295    library = {
296      ...library,
297      agents: agents === undefined ? library.agents : agentsOf(agents).map(one => ({ name: one.type, scope: one.scope })),
298      commands: await read('commands'),
299      skills: await read('skills'),
300    }
301  }
302
303  /**
304   * Reads the setup again: the plugins and the hooks from the settings
305   * sources, the MCP servers from the breakdown, the state file and `.mcp.json`.
306   *
307   * @param mcpTools the breakdown's MCP tools, or `undefined` to keep the last servers
308   */
309  const readSetup = async (mcpTools: readonly { serverName: string }[] | undefined): Promise<void> => {
310    const h = host
311
312    if (h === undefined) {
313      return
314    }
315
316    connected = mcpTools === undefined ? connected : mcpTools.map(one => serverNameOf(one.serverName))
317    const sources = await Promise.all(
318      SETUP_SOURCES.map(async ([scope, source]) => [scope, await h.settings(source).catch((): Record<string, unknown> => ({}))] as const),
319    )
320    const state = jsonOf(stateFile === undefined ? '' : await h.file(stateFile).catch(() => ''))
321    const project = projectStateOf(state, await h.cwd().catch(() => ''))
322    const mcpJson = jsonOf(await h.file('.mcp.json').catch(() => '')) as { mcpServers?: unknown } | undefined
323    const of = (key: string): Sourced[] => sources.map(([scope, settings]) => [scope, settings[key]])
324
325    library = {
326      ...library,
327      plugins: pluginsOf(of('enabledPlugins')),
328      hooks: hooksOf(of('hooks')),
329      mcp: mcpOf({
330        connected,
331        user: keysOf((state as { mcpServers?: unknown } | undefined)?.mcpServers),
332        project: [...keysOf(project['mcpServers']), ...keysOf(mcpJson?.mcpServers)],
333        disabled: [...stringsOf(project['disabledMcpServers']), ...stringsOf(project['disabledMcpjsonServers'])],
334      }),
335    }
336  }
337
338  /**
339   * Turns a plugin or an MCP server the other way through the engine's own
340   * command, `/plugin enable|disable` or `/mcp enable|disable`.
341   *
342   * @param kind `plugins` or `mcp`
343   * @param name the entry clicked
344   * @returns once the command has run and the pane redrawn
345   */
346  const toggleEntry = async (kind: 'plugins' | 'mcp', name: string): Promise<void> => {
347    const entry = library[kind].find(one => one.name === name)
348
349    if (host === undefined || entry === undefined) {
350      return
351    }
352
353    const toggle = toggleCommandOf(kind, entry)
354
355    if ('argv' in toggle) {
356      const { exitCode, stdout, stderr } = await host.exec(toggle.argv)
357
358      if (exitCode !== 0) {
359        throw new Error((stderr || stdout).trim())
360      }
361    } else {
362      await host.run(toggle.command, toggle.args)
363    }
364
365    const isOn = entry.isOn === false
366    isReloadPending ||= kind === 'plugins'
367    library = { ...library, [kind]: library[kind].map(one => (one === entry ? { ...one, isOn } : one)) }
368    host.toast(kind === 'plugins' ? say().pluginToggled(name, isOn) : say().serverToggled(name, isOn), TOAST_MS)
369    host.invalidate()
370  }
371
372  /**
373   * Puts a pick at the head of the prompt box, the draft kept after it: an
374   * agent's mention, which hands it the next message, or `/<name>` for a
375   * command or a skill. Nothing is sent.
376   *
377   * @param kind what was picked
378   * @param name its name
379   * @returns once the box is filled and the person told
380   */
381  const pickEntry = async (kind: LibraryKind, name: string): Promise<void> => {
382    if (isToggleKind(kind)) {
383      return toggleEntry(kind, name)
384    }
385
386    if (host === undefined || kind === 'hooks') {
387      return
388    }
389
390    const box = await host.read()
391    const isAgent = kind === 'agents'
392    const filled = await host.fill(isAgent ? draftWith(box.text, name) : draftWithCommand(box.text, name))
393
394    current = filled.isFilled ? { [kind]: name } : current
395    host.toast(
396      filled.isFilled ? (isAgent ? say().agentPut(mentionOf(name)) : say().commandPut(`/${name}`)) : say().agentNotPut,
397      TOAST_MS,
398    )
399    host.invalidate()
400  }
401
402  /**
403   * Tells the person a switch did not take, the command's reason in it.
404   *
405   * @param error what the command rejected with
406   */
407  const failed = (error: unknown): void => {
408    host?.toast(`${say().failed} ${error instanceof Error ? error.message : String(error)}`, TOAST_MS)
409  }
410
411  /**
412   * What a switch would cost right now.
413   *
414   * @returns the cost, none known before the clock answers
415   */
416  const costNow = async (): Promise<SwitchCost> => {
417    const now = await host?.now().catch(() => undefined)
418
419    return now === undefined ? { state: 'none' } : switchCostOf(last, now)
420  }
421
422  /**
423   * Records the last main request and re-arms the redraws at the cache's
424   * two thresholds, so the pane's cost line ages without a request.
425   *
426   * @param request the request just answered
427   */
428  const recordRequest = (request: LastRequest): void => {
429    last = request
430
431    for (const timer of expiry) {
432      timer.cancel()
433    }
434
435    expiry = host === undefined ? [] : [WARM_MS, LONG_TTL_MS].map(ms => host!.after(ms, host!.invalidate))
436  }
437
438  /**
439   * The name the toasts give the current model.
440   *
441   * @returns its label, or its id for a model the pane does not offer
442   */
443  const modelName = (): string | undefined => choiceOf(model, catalog.models)?.label ?? model
444
445  /**
446   * Applies a pick: `/model` first where the model changes, then `/effort`
447   * (or the held level) where the level does, and one toast for both. A
448   * pick that would change nothing runs nothing and says so.
449   *
450   * @param pick a model, a level or a preset
451   * @returns once the commands have run and the pane redrawn
452   */
453  const apply = async (pick: Pick): Promise<void> => {
454    if (host === undefined) {
455      return
456    }
457
458    const idle = idleReasonOf(pick, model, effort, catalog.models)
459
460    if (idle !== undefined) {
461      host.toast(idle, TOAST_MS)
462
463      return
464    }
465
466    const plan = planOf(pick, model, effort, catalog.models)
467    const cost = await costNow()
468    const moves: [string | undefined, string][] = []
469    let kind: 'model' | 'effort' = 'model'
470
471    if (plan.model !== undefined) {
472      const before = modelName()
473      const target = plan.model
474
475      await host.run('model', target.arg)
476      model = await host.model().catch(() => target.arg)
477      moves.push([before, modelName() ?? target.label])
478    }
479
480    if (plan.effort !== undefined) {
481      if (hasEffortCommand) {
482        await host.run('effort', plan.effort)
483      } else {
484        held = plan.effort
485      }
486
487      moves.push([effort, plan.effort])
488      kind = plan.model === undefined ? 'effort' : kind
489      effort = plan.effort
490      isEffortAsked = true
491    }
492
493    host.invalidate()
494
495    const [before, after] = moves[0] ?? [undefined, '']
496    const text = 'preset' in pick ? presetText(pick.preset, moves) : switchedText(kind, before, after)
497
498    host.toast(text + costSuffixOf(cost), TOAST_MS)
499  }
500
501  /**
502   * Runs `/fast`, the engine's own toggle; the pane cannot read its state.
503   *
504   * @returns once the command has run
505   */
506  const toggleFast = async (): Promise<void> => {
507    const ran = (await host?.run('fast', '')) as { text?: string } | undefined
508    isFast = fastStateOf(ran?.text) ?? isFast
509    host?.invalidate()
510  }
511
512  /**
513   * Turns ultracode on or off through `/effort`, the level kept as it is.
514   *
515   * @param isOn the state asked; the other one of the mod's where omitted
516   * @returns once the command has run and the pane redrawn
517   */
518  const setUltracode = async (isOn = !isUltracode): Promise<void> => {
519    if (host === undefined) {
520      return
521    }
522
523    // Set before the command runs, so a second press while it runs flips back.
524    const was = isUltracode
525    isUltracode = isOn
526
527    try {
528      await host.run('effort', isOn ? 'ultracode' : 'ultracode off')
529    } catch (error) {
530      isUltracode = was
531      throw error
532    } finally {
533      host.invalidate()
534    }
535
536    host.toast(`${say().ultracode} ${isOn ? say().ultracodeOn : say().ultracodeOff}`, TOAST_MS)
537  }
538
539  /**
540   * Lists the person's other sessions again; the one picked stays picked
541   * while it is listed, else the first one is.
542   *
543   * @returns once listed and the pane redrawn
544   */
545  const readPeers = async (): Promise<void> => {
546    if (host === undefined) {
547      return
548    }
549
550    const listing = await host.peers().catch(() => '')
551    peers = peersOf(listing)
552    self = selfOf(listing) ?? self
553    const addresses = peers.map(addressOf)
554    messageTo = messageTo !== undefined && addresses.includes(messageTo) ? messageTo : addresses[0]
555    host.invalidate()
556  }
557
558  /**
559   * Sends the message typed to the session picked; a refusal is told.
560   *
561   * @param text the message; an empty one does nothing
562   * @returns once queued at the session, or refused
563   */
564  const sendMessage = async (text: string): Promise<void> => {
565    const trimmed = text.trim()
566
567    if (host === undefined || messageTo === undefined || trimmed === '') {
568      return
569    }
570
571    const to = messageTo
572    // For the pane: signed, so the other dash takes it and can answer.
573    const body = messageMode === 'panel' ? panelTextOf(self ?? { name: sessionName ?? '?', ref: '' }, trimmed) : trimmed
574    const sent = await host.send(to, body)
575    const name = peers?.find(one => addressOf(one) === to)?.name ?? to
576
577    if (sent.isDelivered) {
578      messageDraft = ''
579      host.toast(say().messageSent(name), TOAST_MS)
580    } else {
581      messageDraft = trimmed
582      host.toast(say().messageNotSent(sent.reason ?? ''), TOAST_MS)
583    }
584
585    host.invalidate()
586  }
587
588  /**
589   * Answers a message received: its sender becomes the one a message goes to.
590   *
591   * @param id the message's id
592   * @returns once the sessions are listed again where the sender was not
593   */
594  const replyTo = async (id: number): Promise<void> => {
595    const one = received.find(message => message.id === id)
596
597    if (host === undefined || one === undefined) {
598      return
599    }
600
601    const address = addressOf(one)
602
603    if (!(peers ?? []).some(peer => addressOf(peer) === address)) {
604      await readPeers()
605    }
606
607    // Its ref first; a session listed under its name alone otherwise.
608    const sender = (peers ?? []).find(peer => addressOf(peer) === address) ?? (peers ?? []).find(peer => peer.name === one.name)
609    messageTo = sender === undefined ? messageTo : addressOf(sender)
610    messageMode = 'panel'
611    host.invalidate()
612  }
613
614  /**
615   * Hands a message received to Claude: put in the prompt box, for the
616   * person to send or change.
617   *
618   * @param id the message's id
619   * @returns once the prompt box holds it
620   */
621  const forwardReceived = async (id: number): Promise<void> => {
622    const one = received.find(message => message.id === id)
623
624    if (host === undefined || one === undefined) {
625      return
626    }
627
628    await host.fill(say().messageFrom(one.name, one.text))
629  }
630
631  /**
632   * Renames the session through `/rename`.
633   *
634   * @param name the name typed; an empty one does nothing
635   * @returns once the command has run
636   */
637  const renameSession = async (name: string): Promise<void> => {
638    const trimmed = name.trim()
639
640    if (host === undefined || trimmed === '') {
641      return
642    }
643
644    await host.run('rename', trimmed)
645    sessionName = trimmed
646    isRenaming = false
647    host.toast(say().renamed(trimmed), TOAST_MS)
648    host.invalidate()
649  }
650
651  /**
652   * Changes one `/config` row as the menu would; a refusal is told.
653   *
654   * @param key the row's key
655   * @param value its new value
656   * @returns once written and the pane redrawn
657   */
658  const setConfig = async (key: string, value: ConfigValue): Promise<void> => {
659    if (host === undefined) {
660      return
661    }
662
663    const { deny } = await host.setConfig(key, value)
664
665    if (deny !== undefined) {
666      host.toast(`${say().configRefused} ${deny}`, TOAST_MS)
667    }
668
669    host.invalidate()
670  }
671
672  /**
673   * Adds the preset composed in the pane to the `presets` setting.
674   *
675   * @returns once saved and the pane redrawn, or the refusal told
676   */
677  const addPreset = async (): Promise<void> => {
678    if (host === undefined) {
679      return
680    }
681
682    const draft = { ...draftPreset, model: draftPreset.model || (catalog.models[0]?.key ?? '') }
683    const made = presetPartOf(draft, catalog.models, catalog.presets)
684
685    if ('error' in made) {
686      host.toast(made.error === 'exists' ? say().presetExists : say().presetBadName, TOAST_MS)
687
688      return
689    }
690
691    const set = withPref(prefs, 'presets', prefs.presets.trim() === '' ? made.part : `${prefs.presets}, ${made.part}`)
692
693    if ('error' in set) {
694      return
695    }
696
697    settle(set.prefs)
698    await host.save(set.prefs)
699    draftPreset = { name: '', model: draft.model, effort: draft.effort }
700    openMenus.delete(NEW_PRESET_KEY)
701    host.toast(`${say().presetAdded} ${made.part}`, TOAST_MS)
702    host.invalidate()
703  }
704
705  /**
706   * Deletes a preset of the person's own from the `presets` setting.
707   *
708   * @param key the preset's name
709   * @returns once saved and the pane redrawn
710   */
711  const deletePreset = async (key: string): Promise<void> => {
712    if (host === undefined || !isOwnPreset(key, DEFAULT_PREFS.presets)) {
713      return
714    }
715
716    // An empty setting would read as the default: keep the shipped ones then.
717    const rest = withoutPreset(prefs.presets, key)
718    const set = withPref(prefs, 'presets', rest === '' ? DEFAULT_PREFS.presets : rest)
719
720    if ('error' in set) {
721      return
722    }
723
724    settle(set.prefs)
725    await host.save(set.prefs)
726    host.toast(`${say().presetDeleted} ${key}`, TOAST_MS)
727    host.invalidate()
728  }
729
730  /**
731   * Shows or hides the pane's hotkeys, kept with the settings.
732   *
733   * @returns once saved and the pane redrawn
734   */
735  const toggleKeyboard = async (): Promise<void> => {
736    const set = withPref(prefs, 'keyboard', prefs.keyboard === 'off' ? 'on' : 'off')
737
738    if ('error' in set || host === undefined) {
739      return
740    }
741
742    settle(set.prefs)
743    await host.save(set.prefs)
744    host.invalidate()
745  }
746
747  /**
748   * Switches to a model of the drop-down, which the rows may not hold.
749   *
750   * @param id its id
751   * @returns once the switch has run
752   */
753  const pickMoreModel = async (id: string): Promise<void> => {
754    const known = MORE_MODELS.find(one => one.id === id)
755
756    if (known === undefined) {
757      return
758    }
759
760    await apply({
761      model: {
762        key: id,
763        label: known.label,
764        arg: id,
765        hasEffort: !id.includes('haiku'),
766        color: choiceOf(id, catalog.models)?.color ?? '#888888',
767      },
768    })
769  }
770
771  on('engine.create', async (_$, e, next) => {
772    const beneath = await next(e)
773
774    host ??= {
775      invalidate: () => beneath.ui.invalidate('ui.render'),
776      model: () => beneath.session.model(),
777      run: (command, args) => beneath.command.run({ command, args }),
778      toast: (text, timeoutMs) => beneath.ui.toast(text, { timeoutMs }),
779      later: fn => void beneath.clock.after(0, fn),
780      after: (ms, fn) => beneath.clock.after(ms, fn),
781      now: () => beneath.clock.now(),
782      read: () => beneath.prompt.read(),
783      fill: text => beneath.prompt.fill({ text }),
784      list: path => beneath.fs.list(path),
785      exists: path => beneath.fs.exists(path),
786      save: kept => beneath.store.set('settings', kept),
787      // Async, so an engine without the noun rejects instead of throwing.
788      settings: async source => (await beneath.settings.read({ source })) as Record<string, unknown>,
789      file: async path => String(await beneath.fs.read(path)),
790      cwd: async () => beneath.session.cwd(),
791      exec: argv => beneath.process.run(argv),
792      setConfig: async (key, value) => (await beneath.config.set({ key, value })) as { value?: unknown; deny?: string },
793      // The listing the ListAgents tool gives the model, sessions and all.
794      peers: async () => {
795        const listed = (await beneath.tool.call({ tool: 'ListAgents' })) as { result?: { listing?: unknown }; text?: unknown }
796
797        return String(listed.result?.listing ?? listed.text ?? '')
798      },
799      send: (to, text) => beneath.session.send({ to, text }),
800    }
801
802    return beneath
803  })
804
805  on('session.start', async ($, e, next) => {
806    // Before the command is registered: its description is in the language.
807    settle(prefsOf(await $.store.get('settings').catch(() => undefined)))
808
809    await $.command.register({
810      name: COMMAND,
811      description: say().description,
812      argumentHint: say().argumentHint,
813    })
814
815    const started = await next(e)
816
817    model = await $.session.model().catch(() => undefined)
818
819    const commands = await $.command.list().catch(() => [])
820    hasEffortCommand = commands.some(one => one.name === 'effort')
821    hasFast = commands.some(one => one.name === 'fast')
822
823    effort ??= savedEffortOf(await $.settings.read().catch(() => ({})), model, catalog.models)
824
825    ;({ configDir, stateFile } = await pathsOf($))
826
827    // The summary breakdown is local and free; the agents and the MCP tools are in it.
828    const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
829    await readLibrary(usage?.context.breakdown?.agents ?? [])
830    await readSetup(usage?.context.breakdown?.mcpTools ?? [])
831
832    return started
833  })
834
835  on('command.run', { command: COMMAND }, async ($, e) => {
836    const request = setRequestOf(e.args)
837
838    if (request !== undefined) {
839      if (request.key === undefined) {
840        return { text: [say().settingsTitle, ...prefsLines(prefs)].join('\n') }
841      }
842
843      const key = prefKeyOf(request.key)
844
845      if (key === undefined) {
846        return { text: `${say().settingUnknown} ${PREF_KEYS.join(', ')}` }
847      }
848
849      const set = withPref(prefs, key, request.value ?? '')
850
851      if ('error' in set) {
852        return {
853          text:
854            set.error === 'maxAgents'
855              ? say().settingBadMaxAgents
856              : set.error === 'keyboard'
857                ? say().settingBadKeyboard
858                : `${say().settingBadLanguage} ${Object.keys(LANGUAGES).join(', ')}`,
859        }
860      }
861
862      await $.store.set('settings', set.prefs)
863      settle(set.prefs)
864      $.ui.invalidate('ui.render')
865
866      const lines = [`${say().settingSaved} ${key}`, ...prefsLines(prefs)]
867
868      return { text: catalog.ignored.length === 0 ? lines.join('\n') : [...lines, `${say().ignored} ${catalog.ignored.join(', ')}`].join('\n') }
869    }
870
871    const ultracode = /^ultracode(?:\s+(on|off))?$/i.exec(e.args.trim())
872
873    if (ultracode !== null && hasEffortCommand) {
874      if (host === undefined) {
875        return { text: say().notReady }
876      }
877
878      const isOn = ultracode[1]?.toLowerCase() !== 'off'
879      host.later(() => void setUltracode(isOn).catch(failed))
880
881      return { text: `${say().ultracodeSet} ${isOn ? say().ultracodeOn : say().ultracodeOff}` }
882    }
883
884    const pick = pickOf(e.args, catalog.models, catalog.presets)
885
886    if (pick === undefined && e.args.trim() !== '') {
887      return { text: `${say().unknownPick} ${e.args.trim()}` }
888    }
889
890    if (pick !== undefined) {
891      const idle = idleReasonOf(pick, model, effort, catalog.models)
892
893      if (idle !== undefined) {
894        return { text: idle }
895      }
896
897      if (host === undefined) {
898        return { text: say().notReady }
899      }
900
901      // `/model` and `/effort` cannot run from inside this hook: the session
902      // waits on it. A timer runs them once `/dash` has answered.
903      host.later(() => void apply(pick).catch(failed))
904
905      if ('model' in pick) {
906        return { text: `${say().modelSet} ${pick.model.label}` }
907      }
908
909      return 'effort' in pick
910        ? { text: `${say().effortSet} ${pick.effort}` }
911        : { text: `${say().presetSet} ${pick.preset.key}` }
912    }
913
914    ;({ configDir, stateFile } = await pathsOf($))
915    isRead = true
916
917    // A file added since the session began shows on the next open.
918    const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
919    await readLibrary(usage?.context.breakdown?.agents)
920    await readSetup(usage?.context.breakdown?.mcpTools)
921    await readPeers()
922    const draft = (await $.prompt.read().catch(() => ({ text: '' }))).text
923    current = { agents: mentionedIn(draft), commands: invokedIn(draft), skills: invokedIn(draft) }
924
925    await $.ui.open({ id: PANE_ID, title: say().paneTitle, focus: true })
926
927    return { text: say().opened }
928  })
929
930  // A message another dash sent for this pane: kept and shown here, never
931  // queued for Claude; any other delivery goes on as it came.
932  on('session.receive', async ($, e, next) => {
933    const message = panelMessageOf(e.text)
934
935    if (message === undefined) {
936      return next(e)
937    }
938
939    received = [{ ...message, id: ++receivedCount }, ...received].slice(0, RECEIVED_KEPT)
940    $.ui.toast(say().messageFrom(message.name, message.text), { timeoutMs: TOAST_MS })
941    $.ui.invalidate('ui.render')
942
943    return { consumed: 'dash: a message for the pane' }
944  })
945
946  // A /rename the person types names the session too.
947  on('command.run', { command: 'rename' }, async ($, e, next) => {
948    const result = await next(e)
949    sessionName = e.args.trim() || sessionName
950    isRenaming = false
951    $.ui.invalidate('ui.render')
952
953    return result
954  })
955
956  // A /fast the person types says the new state too.
957  on('command.run', { command: 'fast' }, async ($, e, next) => {
958    const result = await next(e)
959    isFast = fastStateOf(result.text) ?? isFast
960    $.ui.invalidate('ui.render')
961
962    return result
963  })
964
965  on('turn.step', async function* ($, e, next) {
966    if (e.agentId !== undefined) {
967      return yield* next(e)
968    }
969
970    if (engineSeen?.model === e.model && e.effort !== undefined && e.effort !== engineSeen.effort) {
971      held = undefined
972    }
973
974    engineSeen = { model: e.model, effort: e.effort ?? engineSeen?.effort }
975
976    const sent = held !== undefined && e.effort !== undefined ? { ...e, effort: held } : e
977    const seen = effortOf(sent.effort) ?? effort
978    const isSeen = effortOf(sent.effort) !== undefined
979
980    if (sent.model !== model || seen !== effort || (isEffortAsked && isSeen)) {
981      model = sent.model
982      effort = seen
983      isEffortAsked &&= !isSeen
984      $.ui.invalidate('ui.render')
985    }
986
987    const result = yield* next(sent)
988    const measure = measureOf(result.usage)
989
990    if (measure !== undefined) {
991      recordRequest({ ...measure, at: await $.clock.now() })
992      $.ui.invalidate('ui.render')
993    }
994
995    return result
996  })
997
998  // The agent cap: a workflow's agents pass no hook (agent.spawn sees only
999  // the Agent tool's), so the cap goes into the script itself. A script on
1000  // disk is read and sent inline; one run by name cannot be reached and is
1001  // refused, as is any the cap cannot be fitted into: the cap fails closed.
1002  on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
1003    if (prefs.maxAgents === '') {
1004      return next(e)
1005    }
1006
1007    const args = e as unknown as Record<string, unknown>
1008    const path = args['scriptPath']
1009    const script =
1010      typeof path === 'string'
1011        ? String(await $.fs.read(path).catch(() => ''))
1012        : typeof args['script'] === 'string'
1013          ? args['script']
1014          : undefined
1015
1016    if (script === undefined) {
1017      return { deny: say().capNamed(prefs.maxAgents) }
1018    }
1019
1020    const capped = cappedScriptOf(script, Number(prefs.maxAgents))
1021
1022    if (capped === undefined) {
1023      return { deny: say().capUnfit(prefs.maxAgents) }
1024    }
1025
1026    const { scriptPath: _path, ...rest } = args
1027
1028    return next({ ...rest, script: capped } as unknown as typeof e)
1029  })
1030
1031  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
1032    // A module reloaded mid-session starts blank: no session.start comes again.
1033    model ??= await $.session.model().catch(() => undefined)
1034    effort ??= savedEffortOf(await $.settings.read().catch(() => ({})), model, catalog.models)
1035
1036    // Nor are the lists read: the first draw reads them, as /dash would.
1037    if (!isRead) {
1038      isRead = true
1039      ;({ configDir, stateFile } = await pathsOf($))
1040      const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
1041      await readLibrary(usage?.context.breakdown?.agents ?? [])
1042      await readSetup(usage?.context.breakdown?.mcpTools ?? [])
1043      await readPeers()
1044    }
1045
1046    // Both cheap, read on each draw: the gauge follows every request, which
1047    // redraws the pane, and a row changed in /config shows on the next draw.
1048    const sessionUsage = await $.session.usage().catch(() => undefined)
1049    const context = sessionUsage?.context
1050    const now = await $.clock.now().catch(() => undefined)
1051    const configRows: ConfigRow[] = await $.config.list().catch(() => [])
1052
1053    // The durations count in minutes: one redraw a minute ages them while
1054    // the pane draws, and stops with it, since a pane not drawn re-arms none.
1055    tick ??= host?.after(USAGE_TICK_MS, () => {
1056      tick = undefined
1057      host?.invalidate()
1058    })
1059
1060    return drawPane($.ui.resolve(e), {
1061      context: context === undefined ? undefined : { percent: context.percent, tokens: context.tokens, window: context.window },
1062      usage: sessionUsage === undefined || now === undefined ? undefined : usageViewOf(sessionUsage, now),
1063      renameSession: name => void renameSession(name).catch(failed),
1064      sessionName,
1065      isRenaming,
1066      editName: () => {
1067        isRenaming = true
1068        host?.invalidate()
1069      },
1070      peers,
1071      messageTo,
1072      pickMessageTo: to => {
1073        messageTo = to
1074        host?.invalidate()
1075      },
1076      refreshPeers: () => void readPeers().catch(failed),
1077      messageDraft,
1078      setMessageDraft: text => {
1079        messageDraft = text
1080      },
1081      sendMessage: text => void sendMessage(text).catch(failed),
1082      messageMode,
1083      setMessageMode: mode => {
1084        messageMode = mode
1085        host?.invalidate()
1086      },
1087      received,
1088      replyTo: id => void replyTo(id).catch(failed),
1089      forwardReceived: id => void forwardReceived(id).catch(failed),
1090      dropReceived: id => {
1091        received = received.filter(message => message.id !== id)
1092        host?.invalidate()
1093      },
1094      isCompactAsked,
1095      askCompact: (isAsked: boolean) => {
1096        isCompactAsked = isAsked
1097        compactNote = ''
1098        host?.invalidate()
1099      },
1100      compactNote,
1101      setCompactNote: note => {
1102        compactNote = note
1103      },
1104      compact: () => {
1105        const note = compactNote.trim()
1106        isCompactAsked = false
1107        compactNote = ''
1108        host?.invalidate()
1109        void host?.run('compact', note).catch(failed)
1110      },
1111      configRows,
1112      setConfig: (key, value) => void setConfig(key, value).catch(failed),
1113      models: catalog.models,
1114      presets: catalog.presets,
1115      model,
1116      effort,
1117      hasEffortCommand,
1118      isEffortAsked,
1119      hasFast,
1120      isFast,
1121      isUltracode,
1122      ignored: catalog.ignored,
1123      pickModel: key => {
1124        const choice = catalog.models.find(one => one.key === key)
1125
1126        if (choice !== undefined) {
1127          void apply({ model: choice }).catch(failed)
1128        }
1129      },
1130      pickEffort: level => void apply({ effort: level }).catch(failed),
1131      pickPreset: key => {
1132        const preset = catalog.presets.find(one => one.key === key)
1133
1134        if (preset !== undefined) {
1135          void apply({ preset }).catch(failed)
1136        }
1137      },
1138      toggleFast: () => void toggleFast().catch(failed),
1139      toggleUltracode: () => void setUltracode().catch(failed),
1140      pickMoreModel: id => void pickMoreModel(id).catch(failed),
1141      isKeyboard: prefs.keyboard !== 'off',
1142      toggleKeyboard: () => void toggleKeyboard().catch(failed),
1143      library,
1144      current,
1145      openKinds: [...openKinds],
1146      toggleKind: kind => {
1147        if (!openKinds.delete(kind)) {
1148          openKinds.add(kind)
1149        }
1150
1151        host?.invalidate()
1152      },
1153      expandedFrames: [...expandedFrames],
1154      toggleFrame: (kind, scope) => {
1155        if (!expandedFrames.delete(`${kind}:${scope}`)) {
1156          expandedFrames.add(`${kind}:${scope}`)
1157        }
1158
1159        host?.invalidate()
1160      },
1161      pickEntry: (kind, name) => void pickEntry(kind, name).catch(failed),
1162      openMenus: [...openMenus],
1163      draftPreset: { ...draftPreset, model: draftPreset.model || (catalog.models[0]?.key ?? '') },
1164      setDraftPreset: patch => {
1165        draftPreset = { ...draftPreset, ...patch }
1166        host?.invalidate()
1167      },
1168      addPreset: () => void addPreset().catch(failed),
1169      ownPresets: catalog.presets.filter(one => isOwnPreset(one.key, DEFAULT_PREFS.presets)).map(one => one.key),
1170      deletePreset: key => void deletePreset(key).catch(failed),
1171      toggleMenu: key => {
1172        if (!openMenus.delete(key)) {
1173          openMenus.add(key)
1174
1175          // Sessions come and go: the drop-down lists them as they are.
1176          if (key === MESSAGE_TO_KEY) {
1177            void readPeers().catch(failed)
1178          }
1179        }
1180
1181        host?.invalidate()
1182      },
1183      isReloadPending,
1184      reloadPlugins: () =>
1185        void host
1186          ?.run('reload-plugins', '')
1187          .then(() => {
1188            isReloadPending = false
1189            host?.invalidate()
1190          })
1191          .catch(failed),
1192    }, e.surface)
1193  })
1194}
1195
hooks/agents/index.ts 2 lines
1export * from './agents'
2
hooks/catalog/index.ts 2 lines
1export * from './catalog'
2
hooks/config/index.ts 2 lines
1export * from './config'
2
hooks/cost/index.ts 2 lines
1export * from './cost'
2
hooks/library/index.ts 2 lines
1export * from './library'
2
hooks/message/index.ts 2 lines
1export * from './message'
2
hooks/names.ts 102 lines
1/**
2 * The names of the mod, gathered so a test reads the same ones. What the
3 * person reads lives in `say/`, by language.
4 */
5
6/** The slash command, without its slash. */
7export const COMMAND = 'dash'
8
9/** The pane's id. */
10export const PANE_ID = 'dash'
11
12/** The key of a model's button. */
13export const modelKey = (key: string): string => `model:${key}`
14
15/** The key of an effort level's button. */
16export const effortKey = (level: string): string => `effort:${level}`
17
18/** The key of a preset's button. */
19export const presetKey = (key: string): string => `preset:${key}`
20
21/** The keys of the new preset's form: its opener, its fields and its button. */
22export const NEW_PRESET_KEY = 'presets:new'
23export const PRESET_NAME_KEY = 'presets:new:name'
24export const PRESET_MODEL_KEY = 'presets:new:model'
25export const PRESET_EFFORT_KEY = 'presets:new:effort'
26export const PRESET_ADD_KEY = 'presets:new:add'
27
28/** The key of the button that deletes a preset of the person's own. */
29export const presetDeleteKey = (key: string): string => `presets:delete:${key}`
30
31/** The key of the fast mode's button. */
32export const FAST_KEY = 'fast'
33
34/** The key of the ultracode button. */
35export const ULTRACODE_KEY = 'ultracode'
36
37/** The key of the drop-down of the models beside the rows. */
38export const MORE_MODELS_KEY = 'model:more'
39
40/** The key of one option of a drop-down. */
41export const menuOptionKey = (menu: string, value: string): string => `${menu}:${value}`
42
43/** The key of the field that renames the session. */
44export const RENAME_KEY = 'session:rename'
45
46/** The key of the drop-down that picks the session a message goes to. */
47export const MESSAGE_TO_KEY = 'message:to'
48
49/** The key of the field that sends a message to that session. */
50export const MESSAGE_KEY = 'message:text'
51
52/** The key of the button that lists the sessions again. */
53export const MESSAGE_REFRESH_KEY = 'message:refresh'
54
55/** The key of the button that sends to the other session's pane, or to its Claude. */
56export const messageModeKey = (mode: string): string => `message:mode:${mode}`
57
58/** The key of the button that answers a message received. */
59export const receivedReplyKey = (id: number): string => `message:received:${id}:reply`
60
61/** The key of the button that hands a message received to Claude. */
62export const receivedForwardKey = (id: number): string => `message:received:${id}:forward`
63
64/** The key of the button that drops a message received. */
65export const receivedDropKey = (id: number): string => `message:received:${id}:drop`
66
67/** The key of the button that brings the rename field back. */
68export const RENAME_EDIT_KEY = 'session:rename:edit'
69
70/** The key of the button that runs `/compact`. */
71export const COMPACT_KEY = 'session:compact'
72
73/** The key of the button that confirms `/compact`. */
74export const COMPACT_CONFIRM_KEY = 'session:compact:confirm'
75
76/** The key of the button that drops the `/compact` asked for. */
77export const COMPACT_CANCEL_KEY = 'session:compact:cancel'
78
79/** The key of the field that takes `/compact`'s instructions. */
80export const COMPACT_NOTE_KEY = 'session:compact:note'
81
82/** The key of the Settings section's header. */
83export const CONFIG_KEY = 'config:open'
84
85/** The key of one `/config` row's control. */
86export const configKey = (key: string): string => `config:${key}`
87
88/** The key of the button that shows or hides the hotkeys. */
89export const KEYBOARD_KEY = 'keyboard'
90
91/** The key of the button that runs `/reload-plugins`. */
92export const RELOAD_KEY = 'plugins:reload'
93
94/** The key of a library section's header, which opens and closes it. */
95export const sectionKey = (kind: string): string => `${kind}:open`
96
97/** The key of an agent's, a command's or a skill's button. */
98export const entryKey = (kind: string, name: string): string => `${kind}:item:${name}`
99
100/** The key of a scope frame's Expand / Collapse button. */
101export const frameToggleKey = (kind: string, scope: string): string => `${kind}:more:${scope}`
102
hooks/pane/index.ts 2 lines
1export * from './pane'
2
hooks/say/index.ts 3 lines
1export * from './en'
2export * from './say'
3
hooks/settings/index.ts 2 lines
1export * from './settings'
2
hooks/usage/index.ts 2 lines
1export * from './usage'
2