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…

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.
| Command | Effect |
|---|---|
/dash | opens the pane |
/dash opus, /dash sonnet, /dash haiku, /dash fable | switches model without the pane |
/dash low … /dash max | switches effort without the pane |
/dash deep, /dash quick, … | applies a preset |
/dash ultracode, /dash ultracode off | turns ultracode on or off (/effort ultracode) |
/dash set … | lists or changes the mod's settings (see Settings) |
/model's list, so the mod keeps its own (MORE_MODELS).low to max, each with its gauge. An effort just picked shows (asked) until a main request carries it.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./cost totals it, and how long it has run. The pane redraws once a minute so the durations stay current./compact <instructions>. A stray click summarizes nothing./rename. A /rename you type yourself shows here too.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:
A message that cannot be delivered stays in the field, and the toast says why.
Seven folded sections, each with its count. A click on a title unfolds it in a frame of its color.
| Section | What it lists | A click on an entry | |
|---|---|---|---|
| Subagents | your custom agents (the local /context breakdown) | puts @agent-<name> at the head of the prompt: your next message goes to it | |
| Commands | the .md files under commands/ (a subfolder is a namespace: git/commit.md → /git:commit) | puts /<name> at the head of the prompt | |
| Skills | the folders under skills/ that hold a SKILL.md | puts /<name> at the head of the prompt | |
| Plugins | the plugins enabledPlugins names in your settings | turns it on or off (`claude plugin enable\ | disable`); ↻ /reload-plugins then applies it |
| MCP servers | the connected, configured and disabled servers | turns it on or off (`/mcp enable\ | disable`) |
| Hooks | the hooks in your settings, by event, with how many run on each | nothing: /hooks changes them | |
| Settings | every /config row | changes 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.
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.
/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.
| Command | Effect |
|---|---|
/dash set | lists the settings and their values |
/dash set language fr | changes a setting, in effect at once |
/dash set presets | no value: back to the default |
| Setting | Default | Effect |
|---|---|---|
language | en | en, fr, es, de. A line a translation lacks shows in English. |
presets | deep=opus/xhigh, daily=sonnet/medium, quick=haiku | name=model/effort or name=model (the effort stays as it is). |
models | empty | ids to add after the built-in models: id or id=Label (claude-opus-4-1=Opus 4.1). |
hideModels | empty | keys or ids to remove from the pane (fable). |
maxAgents | empty | cap on agents per workflow (3); empty, none. |
keyboard | off | on 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.
/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./effort saves the level picked as the default setting./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./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.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.already on …, and the cache stays intact.models), then by its family. A model the pane does not offer shows by its id in the header, with no row checked./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./dash low answers without running anything./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./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)./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.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.
· 84 k tokens to cache again when the cache could still hold them./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.
hooks/register.ts 1195 lines1/**
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}
1195hooks/agents/index.ts 2 lines1export * from './agents'
2hooks/catalog/index.ts 2 lines1export * from './catalog'
2hooks/config/index.ts 2 lines1export * from './config'
2hooks/cost/index.ts 2 lines1export * from './cost'
2hooks/library/index.ts 2 lines1export * from './library'
2hooks/message/index.ts 2 lines1export * from './message'
2hooks/names.ts 102 lines1/**
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}`
102hooks/pane/index.ts 2 lines1export * from './pane'
2hooks/say/index.ts 3 lines1export * from './en'
2export * from './say'
3hooks/settings/index.ts 2 lines1export * from './settings'
2hooks/usage/index.ts 2 lines1export * from './usage'
2