SLOPSHOPPER

quick-replies

Claude Code's own next-message suggestion as a button above Clawd; click it, type 1 in the empty prompt, or send just 1 to send it. With /replies more on, a…

newbandrowscommandtoastprompt
★ 3v0.5.2MITupdated 2026-10-09FynnXland/fynn-mods/mods/quick-replies
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quick-replies
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /replies ⎿ quick-replies: quick-replies: on · more suggestions via fork off ⎿ quick-replies: Suggestions: ⎿ quick-replies: – ⎿ quick-replies: Last sources: Claude Code no · Fork off ⎿ quick-replies: Fork in this chat: – ⎿ quick-replies: Suggestions from Claude Code in this session: 0× ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

quick-replies

After Claude answers, Claude Code often suggests your next message itself (the grey text in the prompt). quick-replies turns that suggestion into a button in its own pill above Clawd, in the band above the prompt. A click, typing its digit as the first character in an empty prompt, or sending just its digit as a message sends it immediately as your message. Nothing is ever sent automatically. On request (/replies more on) a fork of the session fills up to four places in total; Claude Code's own suggestion stays on 1. Handy for development and debugging, where the answer is often just "yes, do that".

Texts are English by default; set language to de for German.

Tested with Claude Code v2.1.295 · Plugin version 0.5.2

Display

SurfaceLook
Desktop Code tabIts own pill with a dimmed rounded border and one blank line above Clawd, the limit bars and the cache ring, which stay unchanged below
TerminalCompact, without border or spacing, above the rest of the band
  • Layout: a single suggestion stands alone in its row. From two suggestions on, 2 × 2 (left 1/3, right 2/4) if the longest label fits into half the width, otherwise one per row. The layout setting can force either.
  • Labels are at most 40 characters, shortened with …. That only affects Claude Code's own suggestion, which is shown in full as grey text in the prompt and is sent in full.
  • The pill only shows when a turn has finished since your last message, at least one suggestion exists, the prompt is empty and the mod is on. It disappears while Claude is working, while you type, during a survey in the band, while a subagent transcript is open, and after /clear until the next answer. Without a pill the band keeps its height; quick-replies never moves Clawd or the bars.
  • Stability: if suggestions are already shown and you typed or clicked within the last 2 s, newly arriving suggestions wait until 2 s of quiet. If nothing is shown yet, a new suggestion appears at once.
  • While sidekick shows its own row in the band (starting a new chat), the pill is hidden.

Runs in: terminal and the desktop Code tab. In claude -p, the Agent SDK, VS Code and mobile it draws nothing and makes no model calls.

Sending

  • Click a suggestion, or type its digit (1–4) as the first character in an empty prompt: the suggestion is sent right away as your own message. The digit does not end up in the prompt, even if you hold the key a little longer (repeats of the same digit are ignored for a moment). The digits also work as the band's hotkeys.
  • Send just the digit (1–4, nothing else, also repeated like 111) as a message, e.g. when the digit stayed in the prompt: instead of the digit, the suggestion with that number is sent, and the transcript shows its text. This works only for a suggestion the pill has shown since Claude's last answer, while the mod is on, and never for a message typed while Claude is working, with attachments, or from another source (Remote Control, plugins, other sessions). Otherwise the digit is sent as typed. If another hook stops the message, the pill comes back.
  • Only on click or key, never automatically. A suggestion is sent at most once per turn (click and digit in quick succession send once).
  • After /clear, a suggestion from the old chat is never sent into the new one.
  • If sending fails, a toast shows Sending failed. Please send it yourself: … with the text, and the pill comes back.

Sources

  1. Claude Code's own suggestion (one per turn) is always on 1. quick-replies only reads it; the grey suggestion in the prompt stays as it is. Without it (and with more off) there is no pill.
  2. More suggestions from a fork (only with more on): after each answer of the main session, quick-replies asks a fork of the session (full chat context, the session's model and prompt cache) for up to four likely next messages. Claude Code's own suggestion always takes place 1; the fork's move down one place and the fourth is dropped (if it repeats one of them, the fourth stays). If the fork answers first, its four are shown and shift when Claude Code's suggestion arrives (not within 2 s of your last input). A message that starts with / runs as a slash command (observed, not documented), so the fork is told to start a suggestion with / only when it means to run that command; when a suggestion only talks about a command, the command is put in quotes (“/replies” in en, „/replies“ in de). The fork is asked to write them in the configured language (language), in your voice, short and concrete. Suggestions longer than 40 characters are dropped, not shortened, so nothing is sent that the button does not show. Duplicates are removed (ignoring case and punctuation), and model output is cleaned (control sequences and invisible characters removed; text with hidden Unicode tag characters is dropped). If the fork fails, gives no answer or returns nothing usable, only Claude Code's suggestion remains, without a toast.

The fork only runs after a normal answer of at least 40 characters, not for subagents, aborted or failed turns, and only where the band is drawn. If a new turn starts first, the pending fork is skipped.

Cost: more is off by default. When it is on, every answer triggers one $.model.fork of the session, roughly the cost of one short answer, mostly read from the prompt cache. It counts toward your own usage (on a subscription, toward your usage limits). /replies status shows what the fork used in the current chat: calls, tokens, the share read from the cache, and an estimate in dollars at API prices (price table copied from cost-ledger, Haiku 5.5 and Sonnet 5.5 as of 2026-10-07, unknown models priced like Opus 5.5). On a subscription that dollar figure is only a yardstick. The count starts over after /clear.

Command

CommandEffect
/replies or /replies statusOn/off, more on/off, the current suggestions with their source, fork state, the fork calls in this chat with their estimated cost, how often Claude Code supplied a suggestion in this session, surface, band width, layout and position in the band
/replies on · /replies offTurn the mod on or off
/replies more on · /replies more offTurn the extra fork suggestions on or off
/replies helpHelp as a table: all commands, how to send a suggestion (click, key, lone digit), the current state of the suggestions and the fork, and your settings with their values, plus how to change them and how to turn the mod off. Also /replies ?

Help: /replies help draws a table in the terminal and the desktop app (accent violet, the theme's auto-accept color, so it adapts to light and dark), with the state as of the moment you ran it; run it again to see a change. Everywhere else (claude -p, the Agent SDK, VS Code, mobile) you get a short Markdown version, which is also what Claude reads. Unknown arguments answer with the usage line and a pointer to /replies help.

on/off and more on|off are saved in the mod's plugin store, so they apply in all projects and sessions. Every session rereads them after each answer, so a change takes effect everywhere from the next answer on. They override the userConfig defaults.

Configuration

/config → quick-replies:

KeyTitle in /configMeaningDefault
languageLanguage / SpracheLanguage of the mod's texts and of the fork's suggestions: en English, de Germanen
moreMore suggestions via forkBesides Claude Code's suggestion, ask a fork of the session for suggestions after every answer, up to four in total; Claude Code's own stays first. Costs roughly one short answer per answer, mostly from the cache. `/replies more on\off` overrides it for all sessionsoff
layoutLayoutauto: 2 × 2 if the labels fit, otherwise one per row. grid: always 2 × 2. list: always one per rowauto

Language

language switches everything the mod writes: the /replies output, the help, the command description and the toast. With more on, it also sets the language the fork writes its suggestions in. The fork's question itself is always English. Claude Code's own suggestion is not affected; it comes in whatever language Claude Code uses. Default is en; set de for German, e.g. in /config → quick-replies. Commands and arguments (on, off, more on|off, status, help) are English in both languages.

Rights

claude plugin validate shows:

hooks: session.start, turn.start, turn.complete, prompt.suggest, prompt.edit, prompt.submit, command.run{command=replies}, ui.render{component=CommandOutput, props has {command=replies}}, ui.render{component=AbovePrompt}
calls: $.clock.every, $.command.register, $.model.fork, $.prompt.submit, $.session.id, $.store.get, $.store.set, $.ui.invalidate, $.ui.log, $.ui.resolve, $.ui.toast

In plain language:

  • Hook prompt.suggest: reads Claude Code's own suggestion and passes it on unchanged.
  • Hook prompt.edit: notices whether you are typing; a single digit (or the same digit repeated by a held key) typed into the empty prompt while the pill shows sends that suggestion; further repeats of that digit right afterwards are dropped. Any other input passes through unchanged.
  • Hook prompt.submit: this hook sees every message you send and could change it. quick-replies only replaces a message that is just a digit 1–4 (or the same digit repeated) with the suggestion shown under that number, before the turn starts. Every other message passes through unchanged and is not stored or logged.
  • Hooks turn.start, turn.complete: clear the suggestions when you send, mark when an answer is finished.
  • Hook ui.render for AbovePrompt: draws the pill above the rest of the band.
  • Hook ui.render for CommandOutput of /replies (since 0.5.0): draws /replies help as a table in place of the command's text line. Other /replies output stays text.
  • Hook command.run and $.command.register: the /replies command.
  • $.prompt.submit (as your message): sends a suggestion, only on click or key.
  • $.model.fork: the extra suggestions, only with more on (off by default).
  • $.clock.every: one-shot timers to start the fork outside the hook and for the 2-s quiet period after input.
  • $.session.id: suggestions per session; detects /clear.
  • $.store.get, $.store.set: the on/off settings.
  • $.ui.resolve, $.ui.invalidate: draw and redraw the pill. $.ui.toast: message when sending fails. $.ui.log: errors to the debug log.

No file system, no processes, no network, no environment variables, no tokens or credentials. Every event hook passes the result of the chain on unchanged; errors in its own drawing leave the band as it was.

Installation

Add the marketplace once, then install the mod:

claude plugin marketplace add FynnXland/fynn-mods
claude plugin install quick-replies@fynn-mods

Inside a session the same works with /plugin marketplace add FynnXland/fynn-mods and /plugin install quick-replies@fynn-mods. The mod loads in the next session, or after /reload-plugins.

Check: /plugin shows … mod active · quick-replies.

Update: claude plugin update quick-replies@fynn-mods, or turn on auto-update for fynn-mods under Marketplaces in /plugin.

Remove: disable it under Installed in /plugin, or run claude plugin uninstall quick-replies@fynn-mods.

Try it for one session without installing (from a clone of the repo):

claude --plugin-dir <path-to-clone>/mods/quick-replies

Known limitations

  • No suggestion, no pill. Claude Code does not always provide a suggestion (for example on the first turn or with a cold cache). Then there is no pill, unless more is on and the fork delivers something. /replies status shows how often Claude Code supplied one in this session.
  • Language switch and the command description: after changing language in /config, the /replies output and the toast switch right away; the description of /replies in the command list may only switch in the next session.
  • Messages starting with a digit: while the pill shows, a message cannot start with a digit from 1 to the number of suggestions; typing it sends the suggestion instead. This also applies to the same digit repeated, whether a held key repeats it or 11 is pasted. A digit after other text, a higher digit, or a digit pasted together with other characters (12) stays normal text. Likewise, a message that is only such a digit (or repeated digit) is sent as the suggestion, not as the digit; this also applies when Claude offered numbered options in its answer. Write 1. or option 1 to send the number itself.
  • Same digit right after sending: after a digit has sent a suggestion, the same digit is ignored as the first character of the prompt until about 1.2 s after its last repeat, so a held key does not fill the prompt. The delay is chosen for the longest key-repeat delay Windows offers (1 s); with a longer delay on another system, a repeat may still land in the prompt.
  • Order in the band is not documented. Claude Code does not define in which order mods that draw into the band run, and with marketplace installs the order is not guaranteed. The pill sits above Clawd when quick-replies runs as the outer band mod. The other band mods from fynn-mods (clawd-buddy, limit-bars, sidekick) share a small layout protocol that keeps the pill on top in either order. With other band mods that do not know it, quick-replies may end up inside them, and the buttons then appear squeezed next to their content (e.g. next to Clawd). quick-replies cannot prevent that from inside. /replies status shows the position.
  • Haiku 5.5 above 100k tokens: Haiku 5.5 costs five times as much when a request has more than 100,000 prompt tokens. The fork reports only the sum over its requests, so the cost line always uses the lower price. Each fork request carries the whole chat, so in a Haiku 5.5 chat whose context is over 100,000 tokens the estimate is about five times too low.
  • Hover flicker in the desktop app: next to animated mods in the band (e.g. clawd-buddy's Clawd), the buttons can flicker under the mouse. Clicks still work.

Credits

The idea of forking the session to ask for more next-message suggestions comes from the community plugin next-steps (anthropics/claude-plugins-community). The prompt, output cleaning and everything else are an independent implementation; no code was taken from it.

Source 7 files
hooks/register.ts 550 lines
1// quick-replies: Hooks-Modul. Nach jeder Antwort von Claude stehen vorgeschlagene nächste Nachrichten als eigene Pille über Clawd
2// im Band über dem Prompt (AbovePrompt, docs/raw/en/interface.md:207-213). Klick oder Ziffer 1–4 als erstes Zeichen im leeren
3// Prompt (prompt.edit, types@2.1.289:8128-8203) schickt den Vorschlag als Nachricht des Nutzers ab ($.prompt.submit mit asUser,
4// types@2.1.289:8516-8529). Wird die Ziffer allein abgeschickt, ersetzt prompt.submit sie durch den Vorschlag.
5// Quellen: der eine Vorschlag von Claude Codes eigenem Dienst (prompt.suggest); auf Wunsch (/replies more on) bis zu vier
6// aus einem Fork der Session (Claude Codes Vorschlag vorn, die aus dem Fork rücken nach) ($.model.fork, voller Kontext, gleicher Cache), nach dem Vorbild von next-steps.
7// Der Mod beobachtet nur: Jeder Event-Hook gibt das Ergebnis von next(e) weiter; nur eine Ziffer, die einen Vorschlag sendet,
8// landet nicht im Prompt bzw. wird beim Absenden durch den Vorschlag ersetzt. Er sendet nie von selbst, nur auf Klick oder Taste.
9import type { EngineInterface, On, RenderNode, Timer } from 'claude-code'
10import { joinBand, layer, LEVEL, nameOf, splitBand } from './band.ts'
11import { addCall, NO_COST } from './cost.ts'
12import type { ForkCost } from './cost.ts'
13import { forkCostText, forkStateText, langOf, T } from './i18n.ts'
14import type { ForkState, Lang, Position } from './i18n.ts'
15import { helpMarkdown, helpTree } from './help.ts'
16import type { HelpData } from './help.ts'
17import { forkPrompt, heldDigit, HELP_WORDS, MIN_ANSWER, merge, MORE_WORD, parseFork, repliesHelp, STATUS_WORDS, TOGGLE_WORDS } from './logic.ts'
18import type { Reply } from './logic.ts'
19import { chooseLayout, GAP, label } from './view.ts'
20import type { Layout, LayoutPref } from './view.ts'
21
22const CMD = 'replies'
23const ARG_HINT = '[status|on|off|more on|more off|help]'
24// Akzent der Hilfe: Theme-Key, passt sich hell und dunkel an (docs/HELP-SPEC.md §4, Violett; types@2.1.295:12590)
25const ACCENT = 'autoAccept'
26// Rahmen (2) und Innenabstand (2) der eigenen Pille (nur Desktop)
27const FRAME = 4
28// Keine Neubelegung so lange nach einer Eingabe (SPEC → Stabilität)
29const QUIET_MS = 2000
30// So lange nach der letzten Ziffer zählt dieselbe Ziffer als Wiederholung der gehaltenen Taste. Über der längsten
31// Verzögerung bis zur ersten Wiederholung, die Windows einstellen lässt (1 s), und dem längsten Abstand danach.
32const HOLD_MS = 1200
33// Abgeschickte Ziffer zählt nur, wenn der Nutzer sie selbst geschickt hat: Prompt im Terminal oder SDK-Host (Desktop). Nicht
34// Remote Control: Telefon und Web zeigen die Pille nicht (types@2.1.290:8554-8575)
35const USER_ORIGINS: ReadonlySet<string> = new Set(['composer', 'sdk'])
36
37type Settings = { enabled: boolean; more: boolean }
38
39// Einstellungen: userConfig als Standard, /replies überschreibt in $.store. Der Store ist eine Datei des Plugins im Benutzerordner
40// (types@2.1.289:3243-3248), gilt also für alle Projekte und Sessions; jede Session liest ihn nach jeder Antwort neu.
41let defaults: Settings = { enabled: true, more: false }
42let settings: Settings = { ...defaults }
43let layoutPref: LayoutPref = 'auto'
44let lang: Lang = 'en'
45
46// Vorschläge der Hauptsession (flüchtig, pro Session-ID)
47let sid = ''
48let ready = false // ein Turn ist fertig und seither wurde kein Vorschlag gesendet
49let engine = ''
50let fork: string[] = []
51let replies: Reply[] = []
52let forkGen = 0
53let forkState: ForkState = { kind: 'idle' }
54// Fork-Aufrufe dieses Chats (für /replies status); der Fork läuft auf dem Modell der Hauptschleife (types@2.1.291:2552-2556)
55let forkCost: ForkCost = NO_COST
56let mainModel = ''
57
58// Eingabe und Stabilität
59let promptText = ''
60// Die Pille stand beim letzten Zeichnen (nur dann schickt eine Ziffer im leeren Prompt den Vorschlag ab)
61let shown = false
62// Was die Pille in diesem Turn zuletzt gezeigt hat: Eine allein abgeschickte Ziffer sendet nur einen Vorschlag, der zu sehen war
63let seen: string[] = []
64let quiet: Timer | null = null
65let pending = false
66// Ziffer, deren Taste gerade einen Vorschlag gesendet hat: ihre Wiederholungen (Taste noch gehalten) landen nicht im Prompt
67let held = 0
68let holdTimer: Timer | null = null
69
70// Zeichnen: die Pille bleibt dasselbe Objekt, solange sich nichts ändert. Clawd zeichnet das Band im Desktop etwa 13-mal pro
71// Sekunde neu; neu gebaute Knöpfe nahmen dort keinen Klick an.
72let cached: { sig: string; node: RenderNode } | null = null
73let surface = ''
74let bodyColumns = 0
75let layout: Layout | '' = ''
76let position: Position | '' = ''
77let engineSeen = 0
78// /clear-Prüfung im Desktop nicht bei jedem der ~13 Bilder pro Sekunde: bei neuen Vorschlägen und sonst jedes 10. Zeichnen.
79// Im Terminal wird das Band nur bei Änderungen gezeichnet, dort jedes Mal.
80const SID_EVERY = 10
81let sidCheck = SID_EVERY
82
83// /replies help: Schnappschüsse unter einer Kennung `#…`, höchstens 10 (docs/HELP-SPEC.md §3). Die Kennung ohne $.clock.now,
84// das wäre ein neuer Call: Zähler plus Zufall, damit eine neu geladene Session keine alte Kennung trifft.
85const drawn = new Map<string, HelpData>()
86let helpNo = 0
87
88function remember(data: HelpData): string {
89  helpNo += 1
90  const tag = `#${helpNo.toString(36)}${Math.random().toString(36).slice(2, 7).padEnd(5, '0')}`
91  drawn.set(tag, data)
92  while (drawn.size > 10) drawn.delete(drawn.keys().next().value as string)
93  return tag
94}
95
96/** Einstellungen aus dem Store; ohne Store oder ohne Eintrag gilt der Standard aus userConfig. */
97async function load($: EngineInterface) {
98  try {
99    settings = cleanSettings(await $.store.get('settings'), defaults)
100  } catch {
101    // bleibt beim Stand dieser Session
102  }
103}
104
105function cleanSettings(v: unknown, base: Settings): Settings {
106  const o = v && typeof v === 'object' ? (v as Record<string, unknown>) : {}
107  return {
108    enabled: typeof o.enabled === 'boolean' ? o.enabled : base.enabled,
109    more: typeof o.more === 'boolean' ? o.more : base.more,
110  }
111}
112
113function promptEmpty(text: string): boolean {
114  return text.trim() === ''
115}
116
117function resetTurn() {
118  ready = false
119  engine = ''
120  fork = []
121  replies = []
122  seen = []
123  forkGen += 1
124  pending = false
125}
126
127/** Neuer Chat (/clear): Vorschläge und Fork-Kosten gehören zum alten. */
128function newChat(now: string) {
129  sid = now
130  resetTurn()
131  forkCost = NO_COST
132}
133
134function recompute() {
135  replies = merge(engine, fork)
136  sidCheck = SID_EVERY
137}
138
139/** Später ankommende Vorschläge (Engine, Fork): sofort einsetzen, außer der Nutzer hat in den letzten 2 s getippt oder gedrückt. */
140function arrive($: EngineInterface) {
141  if (!ready) return
142  // Die Ruhe schützt nur, was schon zu sehen ist; ohne sichtbaren Vorschlag kommt der neue sofort
143  if (quiet && replies.length > 0) {
144    pending = true
145    return
146  }
147  recompute()
148  $.ui.invalidate('ui.render')
149}
150
151/** Eingabe oder Druck: 2 s Ruhe für die Plätze (einmaliger Timer: erster Tick, dann cancel()). */
152function markInput($: EngineInterface) {
153  quiet?.cancel()
154  const t = $.clock.every(QUIET_MS, () => {
155    t.cancel()
156    if (quiet !== t) return
157    quiet = null
158    if (pending) {
159      pending = false
160      if (ready) {
161        recompute()
162        $.ui.invalidate('ui.render')
163      }
164    }
165  })
166  quiet = t
167}
168
169/** Taste der Ziffer `n` gehalten: Wiederholungen bis HOLD_MS nach der letzten schlucken (einmaliger Timer wie bei markInput). */
170function hold($: EngineInterface, n: number) {
171  unhold()
172  const t = $.clock.every(HOLD_MS, () => {
173    t.cancel()
174    if (holdTimer === t) unhold()
175  })
176  // Erst mit laufendem Timer: ohne ihn bliebe die Ziffer bis zur nächsten anderen Eingabe gesperrt
177  holdTimer = t
178  held = n
179}
180
181function unhold() {
182  holdTimer?.cancel()
183  holdTimer = null
184  held = 0
185}
186
187/** Fork nach dem Turn, außerhalb von turn.complete (Lehre 12). Fehler, Unsinn oder nichts: es bleibt beim Vorschlag der Engine. */
188function askFork($: EngineInterface, gen: number) {
189  forkState = { kind: 'running' }
190  const chat = sid
191  $.model
192    .fork({ prompt: forkPrompt(lang) })
193    .then((r) => {
194      // Kosten zählen auch, wenn inzwischen ein neuer Turn läuft; nach /clear gehören sie zum alten Chat
195      if ('usage' in r && chat === sid) forkCost = addCall(forkCost, r.usage, mainModel)
196      if (gen !== forkGen) return
197      if (!r.isAnswered) {
198        forkState = { kind: 'unanswered', reason: r.reason }
199        return
200      }
201      const list = parseFork(r.text)
202      forkState = list.length > 0 ? { kind: 'found', count: list.length } : { kind: 'none' }
203      if (list.length === 0) return
204      fork = list
205      arrive($)
206    })
207    .catch((err: unknown) => {
208      if (gen === forkGen) forkState = { kind: 'error', message: String(err) }
209    })
210}
211
212/** Sendet einen Vorschlag als Nachricht des Nutzers; die Pille verschwindet sofort. Nur auf Klick oder Taste. */
213function send($: EngineInterface, text: string) {
214  // Schon gesendet (Klick und Ziffer kurz nacheinander): nicht doppelt
215  if (!ready) return
216  ready = false
217  markInput($)
218  $.ui.invalidate('ui.render')
219  // Nach /clear nie den Vorschlag des alten Chats in den neuen schicken
220  $.session
221    .id()
222    .then((now) => {
223      if (sid && now !== sid) {
224        newChat(now)
225        return
226      }
227      return $.prompt.submit({ text, asUser: true }).then((r) => {
228        if (r.drop !== undefined) fail($, text)
229      })
230    })
231    .catch(() => fail($, text))
232}
233
234function fail($: EngineInterface, text: string) {
235  ready = replies.length > 0
236  $.ui.invalidate('ui.render')
237  // Der Host nennt den Mod beim Toast selbst (docs/raw/en/api.md:133)
238  $.ui.toast(T[lang].sendFailed(text))
239}
240
241/** Wo quick-replies in der Kette sitzt: Der Kern antwortet ohne weitere Mods mit `{type:'engine'}` (types@2.1.289:9160-9170). */
242function positionOf(theirs: RenderNode | null | undefined): Position {
243  if (theirs === null || theirs === undefined) return 'alone'
244  if (typeof theirs === 'object' && 'type' in theirs && theirs.type === 'engine') return 'inner'
245  return 'outer'
246}
247
248function status(): string {
249  const t = T[lang]
250  const onOff = (v: boolean) => (v ? t.on : t.off)
251  const source = (r: Reply) => (r.source === 'engine' ? t.sourceEngine : t.sourceFork)
252  const list = replies.length > 0 ? replies.map((r, i) => `  ${i + 1}: ${r.text} (${source(r)})`) : ['  –']
253  return [
254    `quick-replies: ${onOff(settings.enabled)} · ${t.moreViaFork} ${onOff(settings.more)}`,
255    `${t.suggestions}${ready ? '' : t.hidden}:`,
256    ...list,
257    `${t.lastSources}: ${t.sourceEngine} ${engine ? t.yes : t.no} · ${t.sourceFork} ${settings.more ? forkStateText(t, forkState) : t.off}`,
258    forkCostText(t, forkCost),
259    `${t.engineSeen}: ${engineSeen}×`,
260    `${t.surface}: ${surface || t.notDrawn} · ${t.band(bodyColumns)} · ${t.layout} ${layout || '–'} · ${t.position} ${position ? t.positions[position] : '–'}`,
261  ].join('\n')
262}
263
264export function register(on: On, options: Readonly<Record<string, string | number | boolean | readonly string[]>>) {
265  defaults = { enabled: true, more: options.more === true }
266  settings = { ...defaults }
267  layoutPref = options.layout === 'grid' || options.layout === 'list' ? options.layout : 'auto'
268  lang = langOf(options.language)
269
270  on('session.start', async ($, e, next) => {
271    await load($)
272    try {
273      sid = await $.session.id()
274    } catch {
275      sid = ''
276    }
277    resetTurn()
278    forkCost = NO_COST
279    // Commands zuletzt und in try/catch: ein belegter Name wirft (docs/raw/en/api.md:45)
280    try {
281      await $.command.register({ name: CMD, description: T[lang].description, argumentHint: ARG_HINT })
282    } catch (err) {
283      $.ui.log(`/${CMD} not registered: ${String(err)}`, { to: 'debug' })
284    }
285    return next(e)
286  })
287
288  on('turn.start', async ($, e, next) => {
289    resetTurn()
290    // Der Prompt wurde gerade abgeschickt
291    promptText = ''
292    $.ui.invalidate('ui.render')
293    return next(e)
294  })
295
296  on('turn.complete', async ($, e, next) => {
297    if (!e.agentId && e.usage?.model) mainModel = e.usage.model
298    if (!e.agentId && !e.isAborted && e.reason === 'answer') {
299      try {
300        // Nach /clear gibt es eine neue Session-ID ohne session.start (Lehre 13)
301        const now = await $.session.id()
302        if (sid && now !== sid) newChat(now)
303        else sid = now
304      } catch {
305        // bleibt beim bisherigen Wert
306      }
307      // Eine andere Session kann /replies umgestellt haben: die Einstellung gilt global
308      await load($)
309      // Die Vorschläge kommen danach: der von Claude Code über prompt.suggest, die weiteren aus dem Fork
310      ready = true
311      pending = false
312      recompute()
313      $.ui.invalidate('ui.render')
314      // Fork nur, wo gezeichnet wird (erst das Band nennt die Oberfläche, Lehre 9), also nie in -p, SDK, VS Code oder mobil
315      if (settings.enabled && settings.more && surface && e.answer.trim().length >= MIN_ANSWER) {
316        const gen = forkGen
317        const t = $.clock.every(10, () => {
318          t.cancel()
319          // Inzwischen ein neuer Turn: kein Fork mehr für die alte Antwort
320          if (gen === forkGen) askFork($, gen)
321        })
322      }
323    }
324    return next(e)
325  })
326
327  // Vorschlag von Claude Codes eigenem Dienst nach einem Turn (types@2.1.289:8653-8691): nur lesen, der graue Vorschlag bleibt
328  on('prompt.suggest', async ($, e, next) => {
329    if (e.origin.kind === 'suggestion' && e.text.trim()) {
330      engineSeen += 1
331      engine = e.text
332      arrive($)
333    }
334    return next(e)
335  })
336
337  // Wird getippt, verschwindet die Pille; jede Eingabe hält die Plätze 2 s fest (Limit 50 ms: nur next wird abgewartet)
338  on('prompt.edit', async ($, e, next) => {
339    const r = await next(e)
340    // Ziffer als erstes Zeichen in den leeren Prompt, während die Pille steht: Vorschlag sofort senden, die Ziffer landet nicht im
341    // Prompt (Antwort mit leerem Text, types@2.1.291:8276-8283). Die Pause des Band-Hotkeys (docs/raw/en/reference.md:244) greift im
342    // Desktop nicht, solange der Prompt den Fokus hat. Eine gehaltene Taste kommt als „111…“ in einer Eingabe an und zählt wie
343    // die einzelne Ziffer; spätere Wiederholungen derselben Ziffer landen nicht im Prompt und senden nie erneut (auch nicht, wenn
344    // das Senden scheiterte und die Pille zurück ist, oder schon der nächste Turn fertig ist).
345    const n = e.text === '' && r.text === e.inputText ? heldDigit(e.inputText) : 0
346    if (n > 0 && n === held) {
347      hold($, n)
348      return { ...r, text: '', cursor: 0 }
349    }
350    unhold()
351    const pick = n > 0 ? replies[n - 1] : undefined
352    if (pick && shown && ready) {
353      if (e.inputText.length > 1) $.ui.log(`quick-replies: held digit ${n} (${e.inputText.length}×)`, { to: 'debug' })
354      promptText = ''
355      hold($, n)
356      send($, pick.text)
357      return { ...r, text: '', cursor: 0 }
358    }
359    const was = promptEmpty(promptText)
360    promptText = r.text
361    markInput($)
362    if (was !== promptEmpty(promptText)) $.ui.invalidate('ui.render')
363    return r
364  })
365
366  // Nur die Ziffer als ganze Nachricht abgeschickt (z. B. im Desktop, wenn die Ziffer im Prompt stehen blieb): stattdessen geht
367  // der Vorschlag mit dieser Nummer raus, im Transkript steht sein Text (types@2.1.290:4024-4033, 8740-8791). Alles andere,
368  // auch die eigenen Sendungen des Mods (origin plugin), läuft unverändert durch.
369  on('prompt.submit', async ($, e, next) => {
370    // Auch mehrfach dieselbe Ziffer (Taste gehalten, dann abgeschickt)
371    const digit = e.text.trim()
372    const n = heldDigit(digit)
373    if (!n) return next(e)
374    const pick = seen[n - 1]
375    const why = !USER_ORIGINS.has(e.origin.kind)
376      ? `origin ${e.origin.kind}`
377      : e.turnId !== undefined || e.attachments?.length
378        ? 'mid-turn or attachments'
379        : !settings.enabled || !ready || !pick
380          ? 'no suggestion shown'
381          : ''
382    if (why || !pick) {
383      $.ui.log(`quick-replies: "${digit}" sent as typed (${why})`, { to: 'debug' })
384      return next(e)
385    }
386    // Nach /clear nie den Vorschlag des alten Chats
387    let now: string
388    try {
389      now = await $.session.id()
390    } catch {
391      return next(e)
392    }
393    if (sid && now !== sid) {
394      newChat(now)
395      return next(e)
396    }
397    ready = false
398    promptText = ''
399    $.ui.invalidate('ui.render')
400    const r = await next({ ...e, text: pick })
401    // Ein Hook weiter innen oder ein UserPromptSubmit-Hook hat die Nachricht gestoppt: die Pille kommt zurück
402    if (r.drop !== undefined) {
403      ready = replies.length > 0
404      $.ui.invalidate('ui.render')
405    }
406    return r
407  })
408
409  on('command.run', { command: CMD }, async ($, e) => {
410    // Der Befehl stand gerade im Prompt; ob das Leeren beim Absenden prompt.edit auslöst, ist nicht belegt
411    if (!promptEmpty(promptText)) {
412      promptText = ''
413      $.ui.invalidate('ui.render')
414    }
415    const args = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
416    // Vorher neu lesen, damit eine zweite Session ihre Einstellung nicht verliert
417    const change = async (patch: Partial<Settings>) => {
418      try {
419        settings = cleanSettings(await $.store.get('settings'), settings)
420      } catch {
421        // bleibt beim Stand dieser Session
422      }
423      settings = { ...settings, ...patch }
424      try {
425        await $.store.set('settings', settings)
426      } catch {
427        // gilt dann nur bis zum Reload
428      }
429      $.ui.invalidate('ui.render')
430    }
431    const t = T[lang]
432    // Hilfe nur als einziges Wort (docs/HELP-SPEC.md §2); Zustand beim Aufruf, die Zeichnung schreibt sich nicht um
433    if (args.length === 1 && HELP_WORDS.includes(args[0]!)) {
434      const data = repliesHelp({ lang, enabled: settings.enabled, more: settings.more, config: { more: defaults.more, layout: layoutPref } })
435      return { text: helpMarkdown(data, remember(data)) }
436    }
437    if (args.length === 0 || (args.length === 1 && STATUS_WORDS.includes(args[0]!))) {
438      // Direkt nach /clear: Vorschläge und Fork-Kosten gehören noch zum alten Chat
439      try {
440        const now = await $.session.id()
441        if (sid && now !== sid) newChat(now)
442      } catch {
443        // bleibt beim bisherigen Stand
444      }
445      return { text: status() }
446    }
447    if (args.length === 1 && TOGGLE_WORDS.includes(args[0]!)) {
448      await change({ enabled: args[0] === 'on' })
449      return { text: `quick-replies ${settings.enabled ? t.on : t.off}` }
450    }
451    if (args.length === 2 && args[0] === MORE_WORD && TOGGLE_WORDS.includes(args[1]!)) {
452      await change({ more: args[1] === 'on' })
453      return { text: settings.more ? t.moreOn : t.moreOff }
454    }
455    return { text: t.usage }
456  })
457
458  // /replies help gezeichnet an der Stelle der Befehlsausgabe (types@2.1.295:10025-10068). Die Kennung steht in der ersten Zeile,
459  // aber nicht am Anfang: Claude Code setzt „quick-replies: “ davor (templates/help/README.md). Sonst die Engine-Fassung.
460  on('ui.render', { component: 'CommandOutput', props: { command: CMD } }, async ($, e, next) => {
461    if (e.props.isErrored || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
462    // Nur die Ausgabe von /replies help (Absicherung wie templates/help und worklist K2; bei quick-replies wiederholt keine
463    // andere Antwort das Argument, eine Kennung kann also nur aus help stammen)
464    if (!HELP_WORDS.includes(e.props.args.trim().toLowerCase())) return next(e)
465    const tag = /#[0-9a-z]{5,}/.exec(e.props.text.split('\n')[0] ?? '')?.[0]
466    const data = tag ? drawn.get(tag) : undefined
467    return data ? helpTree(data, e.viewport?.columns ?? 100, e.surface, ACCENT) : next(e)
468  })
469
470  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
471    let called = false
472    let nextFailed = false
473    let theirs: RenderNode | null | undefined = null
474    shown = false
475    // Das Band gibt es nur auf Terminal und Desktop (types@2.1.289:9711)
476    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
477    try {
478      called = true
479      try {
480        theirs = await next(e)
481      } catch (err) {
482        nextFailed = true
483        throw err
484      }
485      // Die Oberfläche nennt erst das Zeichnen zuverlässig (Lehre 9)
486      surface = e.surface
487      bodyColumns = e.props.bodyColumns
488      position = positionOf(theirs)
489      // Nur zeichnen, wenn alles passt; sonst den Bandinhalt unverändert lassen, keine zusätzliche Höhe (Lehren 3 und 8)
490      if (!settings.enabled || !ready || replies.length === 0) return theirs
491      if (e.props.hasSurvey || e.props.isWorking || !promptEmpty(promptText)) return theirs
492      // Transkript eines Subagents offen: die Vorschläge gehören zur Hauptsession (types@2.1.289:9750-9758)
493      if (e.props.view.agentId) return theirs
494      // Nach /clear: neue Session-ID, die Vorschläge gehören zum alten Chat (Lehre 13)
495      sidCheck += 1
496      if (e.surface === 'terminal' || sidCheck >= SID_EVERY) {
497        sidCheck = 0
498        const now = await $.session.id()
499        if (sid && now !== sid) {
500          newChat(now)
501          // Auch der Prompt mit „/clear“ ist abgeschickt
502          promptText = ''
503          return theirs
504        }
505      }
506
507      const { Box, Button } = $.ui.resolve(e)
508      // Desktop: gerahmte Pille. Terminal: kompakt ohne Rahmen und Abstand, sonst läuft das Band neben Clawd über („n more“)
509      const framed = e.surface === 'desktop'
510      const inner = e.props.bodyColumns - (framed ? FRAME : 0)
511      const texts = replies.map((r) => r.text)
512      layout = chooseLayout(texts, inner, layoutPref)
513      const sig = `${e.surface}|${inner}|${layout}|${texts.join('\u0000')}`
514      if (!cached || cached.sig !== sig) {
515        const half = Math.floor((inner - GAP) / 2)
516        const buttons = texts.map((text, i) =>
517          Button({ key: `reply-${i + 1}`, label: label(text), hotkey: String(i + 1), plain: true, onPress: () => send($, text) }),
518        )
519        const cell = (b: RenderNode) => Box({ width: e.surface === 'terminal' ? half : '50%', flexShrink: 0, children: [b] })
520        const rows: RenderNode[] = []
521        if (layout === 'grid') {
522          // 2 × 2: links 1/3, rechts 2/4
523          for (let i = 0; i < buttons.length; i += 2) {
524            rows.push(Box({ flexDirection: 'row', columnGap: GAP, flexShrink: 0, children: buttons.slice(i, i + 2).map(cell) }))
525          }
526        } else {
527          for (const b of buttons) rows.push(Box({ flexDirection: 'row', flexShrink: 0, children: [b] }))
528        }
529        // Eigene Pille: gedimmter Rahmen, eine Zeile Abstand zu Clawd und den Balken darunter
530        const node = framed
531          ? Box({ key: 'quick-replies', flexDirection: 'column', flexShrink: 0, borderStyle: 'round', borderDimColor: true, paddingX: 1, marginBottom: 1, children: rows })
532          : Box({ key: 'quick-replies', flexDirection: 'column', flexShrink: 0, children: rows })
533        cached = { sig, node }
534      }
535      // Eigene Ebene über dem Grund (Clawd, Balken), unter sidekick; gilt in jeder Reihenfolge der Mods (band.ts, docs/BAND.md)
536      const { layers, base } = splitBand(theirs)
537      // Während sidekick einen neuen Chat startet, keine Pille; sidekick weiter innen sieht sie nicht
538      if (layers.some((l) => nameOf(l) === 'sidekick')) return joinBand(layers, base)
539      shown = true
540      seen = texts
541      return joinBand([...layers, layer(LEVEL.quickReplies, 'quick-replies', cached.node)], base)
542    } catch (err) {
543      // Fehler aus der Kette weiterwerfen (docs/raw/en/events.md:313-316); eigener Fehler: Clawd und Balken bleiben stehen
544      if (nextFailed) throw err
545      $.ui.log(`quick-replies ui.render: ${String(err)}`, { to: 'debug' })
546      return called ? theirs : next(e)
547    }
548  })
549}
550
hooks/band.ts 108 lines
1// band.ts: gemeinsames Protokoll für das Band über dem Prompt (AbovePrompt). Gleiche Kopie in limit-bars, clawd-buddy,
2// quick-replies und sidekick (Mods importieren nur relativ); Beschreibung in docs/BAND.md, Änderungen immer in allen Kopien.
3//
4// Die Reihenfolge der Mods in der Kette hängt von der Installation ab (docs/raw/en/events.md:291-305). Damit das Band trotzdem
5// immer gleich aussieht, gibt es zwei Arten von Inhalt:
6// - Grund: was nebeneinander in einer Zeile steht (Limit-Balken links, Clawd rechts). Jeder Mod setzt seinen Teil neben den Grund.
7// - Ebenen: was als eigene Zeile über dem Grund steht (Quick-Replies, sidekick). Ein Box-Knoten mit key `layer:<Höhe>:<Name>`.
8//   Jeder Mod holt die Ebenen aus dem, was `next` liefert, heraus, baut nur den Grund um und setzt die Ebenen wieder obenauf,
9//   die höchste zuoberst. So wandert keine Ebene in die Zeile eines anderen Mods, egal wer außen liegt.
10import type { RenderElement, RenderNode } from 'claude-code'
11
12const LAYER = 'layer:'
13const ROOT = 'band'
14const BASE = 'band-base'
15
16export type Split = { layers: RenderElement[]; base: RenderNode | null }
17
18type Data = { type?: unknown; props?: Record<string, unknown>; children?: unknown[] }
19
20const keyOf = (n: unknown): string => {
21  const k = (n as Data | null)?.props?.key
22  return typeof k === 'string' ? k : ''
23}
24
25/** Höhe einer Ebene aus ihrem key (`layer:20:quick-replies` → 20); kein Ebenen-key → -1. */
26export function levelOf(n: unknown): number {
27  const k = keyOf(n)
28  if (!k.startsWith(LAYER)) return -1
29  const v = Number(k.slice(LAYER.length).split(':')[0])
30  return Number.isFinite(v) ? v : 0
31}
32
33/** Name einer Ebene (`layer:20:quick-replies` → quick-replies). */
34export function nameOf(n: unknown): string {
35  const k = keyOf(n)
36  return k.startsWith(LAYER) ? k.slice(LAYER.length).split(':').slice(1).join(':') : ''
37}
38
39/** Den Grund aus einer Wurzel holen, die joinBand gebaut hat. */
40function baseOf(root: Data): unknown {
41  const wrap = (root.children ?? []).find((c) => keyOf(c) === BASE) as Data | undefined
42  const inner = wrap?.children?.[0] as Data | undefined
43  return inner?.children?.[0] ?? null
44}
45
46function lift(node: unknown, out: RenderElement[]): unknown {
47  if (!node || typeof node !== 'object') return node
48  const n = node as Data
49  if (levelOf(n) >= 0) {
50    out.push(n as RenderElement)
51    return null
52  }
53  // Wurzel eines anderen Mods: ihre Ebenen einsammeln, an ihrer Stelle steht nur noch ihr Grund
54  if (keyOf(n) === ROOT) {
55    for (const c of n.children ?? []) if (levelOf(c) >= 0) out.push(c as RenderElement)
56    return lift(baseOf(n), out)
57  }
58  if (!Array.isArray(n.children)) return node
59  let changed = false
60  const kids: unknown[] = []
61  for (const c of n.children) {
62    const l = lift(c, out)
63    if (l !== c) changed = true
64    if (l !== null) kids.push(l)
65  }
66  // Nichts gefunden: derselbe Knoten, damit ein Band ohne Ebenen genau so bleibt, wie es war
67  return changed ? { ...n, children: kids } : node
68}
69
70/** Ebenen aus dem Ergebnis von `next` herausholen, auch aus fremden Hüllen; der Rest ist der Grund. */
71export function splitBand(theirs: RenderNode | null | undefined): Split {
72  const layers: RenderElement[] = []
73  const base = lift(theirs ?? null, layers) as RenderNode | null
74  return { layers, base }
75}
76
77/** Ebenen (höchste oben) über den Grund setzen; ohne Ebenen bleibt der Grund unverändert. */
78export function joinBand(layers: readonly RenderElement[], base: RenderNode | null | undefined): RenderNode | null {
79  if (layers.length === 0) return base ?? null
80  const sorted = layers
81    .map((l, i) => ({ l, i }))
82    .sort((a, b) => levelOf(b.l) - levelOf(a.l) || a.i - b.i)
83    .map((x) => x.l)
84  const kids: RenderNode[] = [...sorted]
85  if (base !== null && base !== undefined) {
86    // Der Grund steht in einer eigenen Zeile unten bündig, nie direkt in der Spalte (quick-replies, Lehren 4 und 5)
87    kids.push(
88      box({ key: BASE, flexDirection: 'row', alignItems: 'flex-end' }, [
89        box({ flexGrow: 1, flexDirection: 'column', justifyContent: 'flex-end' }, [base]),
90      ]),
91    )
92  }
93  return box({ key: ROOT, flexDirection: 'column', justifyContent: 'flex-end' }, kids)
94}
95
96/** Eine Ebene: eigener Box-Knoten mit `layer:<Höhe>:<Name>`, der Inhalt unverändert darin. */
97export function layer(level: number, name: string, content: RenderNode): RenderElement {
98  return box({ key: `${LAYER}${level}:${name}`, flexDirection: 'column', flexShrink: 0 }, [content])
99}
100
101// Elemente als reine Daten (types@2.1.289:11659-11684), ohne $.ui.resolve
102function box(props: Record<string, string | number | boolean>, children: RenderNode[]): RenderElement {
103  return { type: 'Box', props, children } as RenderElement
104}
105
106/** Höhen der Ebenen: weiter oben = größere Zahl. */
107export const LEVEL = { quickReplies: 20, sidekick: 30 } as const
108
hooks/cost.ts 89 lines
1// quick-replies: geschätzter API-Wert der Fork-Aufrufe (ohne `$`). Nur Anzeige in /replies status; im Abo zählt der Fork gegen die
2// Nutzungslimits, nicht in Dollar. Preistabelle und Rechnung wie mods/cost-ledger/hooks/logic.ts (priceFor, callCost); Imports
3// zwischen Mods gibt es nicht, deshalb eine Kopie.
4
5import type { ModelUsage } from 'claude-code'
6
7// Dollar je Million Tokens. haiku-5-5 und sonnet-5-5: Stand 2026-10-07 (platform.claude.com/docs/en/about-claude/pricing,
8// SPEC Nachtrag 0.4.3); übrige Zeilen aus mods/sidekick/hooks/cache.ts, Stand 2026-09-25. Kopie von cost-ledger 0.4.3, mit zwei
9// Abweichungen: `bareId` steht in `priceFor`, und `callCost` nimmt `ModelUsage` mit Pflichtfeldern (types@2.1.291:6214-6236;
10// gebucht wird nur, wenn der Fork `usage` liefert).
11// Längere IDs zuerst: 'opus-5' darf 'opus-5-5' nicht schlucken. `long`: ab `above` Prompt-Tokens gilt das `factor`-Fache für
12// den ganzen Aufruf (Haiku 5.5: zweite Preiszeile „for prompts over 100,000 tokens“, alle Spalten ×5).
13type Price = { input: number; output: number; read: number; long?: { above: number; factor: number } }
14const TABLE: readonly [string, Price][] = [
15  ['fable-5-1', { input: 10, output: 50, read: 0.25 }],
16  ['mythos-5-1', { input: 10, output: 50, read: 0.25 }],
17  ['fable-5', { input: 10, output: 50, read: 1 }],
18  ['opus-5-5', { input: 4, output: 20, read: 0.2 }],
19  ['opus-5', { input: 5, output: 25, read: 0.5 }],
20  ['opus-4-8', { input: 5, output: 25, read: 0.5 }],
21  ['opus-4-7', { input: 5, output: 25, read: 0.5 }],
22  ['opus-4-6', { input: 5, output: 25, read: 0.5 }],
23  ['sonnet-5-5', { input: 2, output: 10, read: 0.1 }],
24  ['sonnet-5', { input: 2, output: 10, read: 0.2 }],
25  ['sonnet-4-6', { input: 3, output: 15, read: 0.3 }],
26  ['haiku-5-5', { input: 0.1, output: 0.5, read: 0.01, long: { above: 100_000, factor: 5 } }],
27  ['haiku-4-5', { input: 1, output: 5, read: 0.1 }],
28]
29// Alias → Eintrag, wie cost-ledger (a57f8d4): `haiku` ist Haiku 5.5 (Probe 2.1.295; Haiku 5.5 als Hauptmodell ab 2.1.293,
30// rel/advisor.md:110), bis 2.1.291 war es Haiku 4.5. Der Fork läuft auf dem Modell der Hauptschleife (types@2.1.295:2620-2624);
31// quick-replies kennt es als API-ID aus turn.complete (types@2.1.295:13654, :13658-13660), die die TABLE direkt trifft.
32const FAMILY: readonly [string, string][] = [
33  ['fable', 'fable-5-1'],
34  ['mythos', 'mythos-5-1'],
35  ['opus', 'opus-5-5'],
36  ['sonnet', 'sonnet-5-5'],
37  ['haiku', 'haiku-5-5'],
38]
39
40/** Preis je Million Tokens für eine Modell-ID oder einen Alias (`claude-opus-5-5-20260101`, `opus[1m]` …); unbekannt → Opus 5.5. */
41export function priceFor(model: string): { id: string } & Price {
42  const id = String(model || '')
43    .toLowerCase()
44    .replace(/^claude-/, '')
45    .replace(/\[.*?\]/g, '')
46    .replace(/-\d{8}$/, '')
47    .trim()
48  for (const [key, p] of TABLE) if (id === key || id.startsWith(key)) return { id: key, ...p }
49  for (const [fam, key] of FAMILY) {
50    const hit = TABLE.find(([k]) => k === key)
51    if (id.includes(fam) && hit) return { id: key, ...hit[1] }
52  }
53  return { id: 'opus-5-5', input: 4, output: 20, read: 0.2 }
54}
55
56/**
57 * API-Wert in $; Cache-Schreiben wie 5-min-TTL (1,25 × Input).
58 * Preisstufe (`long`) nur bei `single`, also wenn `u` genau eine Anfrage ist. Die Usage eines Forks ist eine Summe über mehrere
59 * Antworten (types@2.1.291:6079); dort wäre die Summe kein Prompt, also Faktor 1. quick-replies bucht nur Forks und übergibt
60 * `single` deshalb nie; der Parameter bleibt, damit die Rechnung eine Kopie von cost-ledger bleibt.
61 * Prompt = `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`. Dass Cache-Tokens mitzählen, ist ein Schluss
62 * aus zwei Seiten, wörtlich steht es nirgends: die Preisseite (…/about-claude/pricing) nennt nur „prompt“, die Seite
63 * …/build-with-claude/context-windows sagt „all three count toward the window“.
64 */
65export function callCost(u: ModelUsage, model: string, single = false): number {
66  const p = priceFor(model)
67  const prompt = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
68  const factor = single && p.long && prompt > p.long.above ? p.long.factor : 1
69  return (
70    (factor *
71      (u.input_tokens * p.input + u.cache_read_input_tokens * p.read + u.cache_creation_input_tokens * p.input * 1.25 + u.output_tokens * p.output)) /
72    1e6
73  )
74}
75
76/** Summe der Fork-Aufrufe eines Chats. */
77export type ForkCost = { calls: number; usd: number; tokens: number; cached: number }
78
79export const NO_COST: ForkCost = { calls: 0, usd: 0, tokens: 0, cached: 0 }
80
81export function addCall(sum: ForkCost, u: ModelUsage, model: string): ForkCost {
82  return {
83    calls: sum.calls + 1,
84    usd: sum.usd + callCost(u, model),
85    tokens: sum.tokens + u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
86    cached: sum.cached + u.cache_read_input_tokens,
87  }
88}
89
hooks/i18n.ts 193 lines
1// quick-replies: Texte in beiden Sprachen (ohne `$`). Die Sprache kommt aus userConfig `language` (Standard en).
2
3import type { ForkCost } from './cost.ts'
4
5export type Lang = 'en' | 'de'
6
7/** Kleine Beträge mit drei Nachkommastellen, sonst zwei. */
8function money(v: number): string {
9  const n = Number(v) || 0
10  return n.toFixed(n < 0.1 ? 3 : 2)
11}
12
13/** Tokens: unter 1000 genau, sonst in Tausend. */
14function count(v: number): string {
15  const n = Number(v) || 0
16  return n < 1000 ? String(n) : `${Math.round(n / 1000)}k`
17}
18
19export function langOf(v: unknown): Lang {
20  return v === 'de' ? 'de' : 'en'
21}
22
23/** Was der Fork zuletzt geliefert hat; als Text erst in `/replies status`. */
24export type ForkState =
25  | { kind: 'idle' }
26  | { kind: 'running' }
27  | { kind: 'unanswered'; reason: string }
28  | { kind: 'found'; count: number }
29  | { kind: 'none' }
30  | { kind: 'error'; message: string }
31
32/** Wo quick-replies in der Kette des Bands sitzt. */
33export type Position = 'alone' | 'inner' | 'outer'
34
35export const T = {
36  en: {
37    description: 'Reply suggestions above Clawd: status, on/off, more suggestions via fork, help',
38    usage: 'Usage: /replies [status|on|off|more on|more off|help] · All commands: /replies help',
39    on: 'on',
40    off: 'off',
41    yes: 'yes',
42    no: 'no',
43    moreViaFork: 'more suggestions via fork',
44    moreOn:
45      "quick-replies: more suggestions via fork on, in all sessions from the next answer. Claude Code's suggestion stays on 1. Costs roughly one short answer per answer, mostly from the cache.",
46    moreOff: "quick-replies: more suggestions off; all sessions show only Claude Code's suggestion.",
47    suggestions: 'Suggestions',
48    hidden: ' (hidden until the next finished turn)',
49    sourceEngine: 'Claude Code',
50    sourceFork: 'Fork',
51    lastSources: 'Last sources',
52    engineSeen: 'Suggestions from Claude Code in this session',
53    surface: 'Surface',
54    notDrawn: 'not drawn yet',
55    band: (columns: number) => `band ${columns} columns`,
56    layout: 'layout',
57    position: 'position',
58    positions: { alone: 'alone', inner: 'inner (only the core after me)', outer: 'outer (other mods draw below)' },
59    fork: {
60      running: 'running',
61      unanswered: (reason: string) => `no answer (${reason})`,
62      found: (count: number) => (count === 1 ? '1 suggestion' : `${count} suggestions`),
63      none: 'no suggestions',
64      error: (message: string) => `error: ${message}`,
65    },
66    sendFailed: (text: string) => `Sending failed. Please send it yourself: ${text}`,
67    /** Sprache, in der der Fork die Vorschläge schreiben soll */
68    forkLanguage: 'English',
69    forkQuotes: ['“', '”'],
70    forkInChat: 'Fork in this chat',
71    usd: (v: number) => (Number(v) < 0.001 ? '<$0.001' : `~$${money(v)}`),
72    costNote: 'API price, estimated; on a subscription it counts toward the usage limits',
73    tokens: (n: number) => `${count(n)} tokens`,
74    fromCache: (pct: number) => `${pct} % from cache`,
75    // /replies help (0.5.0, docs/HELP-SPEC.md)
76    helpIntro: "Shows Claude Code's own suggestion for your next message as a button above Clawd and sends it on click or key; on request a fork of the session adds more.",
77    helpStatus: 'Status: suggestions, sources, fork state and cost in this chat (also without an argument)',
78    helpOn: 'Turn the suggestions on',
79    helpOff: 'Turn the suggestions off (no pill, no fork)',
80    helpMoreOn: 'More suggestions via fork, up to four in total, in all sessions',
81    helpMoreOff: "Only Claude Code's own suggestion, in all sessions",
82    helpHelp: 'This help (also: ?)',
83    helpForkCost: "Fork: costs about one short answer per answer, mostly from the cache; this chat's cost is in /replies status.",
84    helpMoreOverrides: '/replies more on|off overrides the setting “More suggestions via fork” for all sessions.',
85    ctlClick: 'Click a suggestion',
86    ctlClickDoes: 'Sends it right away as your message',
87    ctlKey: '1–4 in the empty prompt',
88    ctlKeyDoes: 'Sends that suggestion right away; the digit does not stay in the prompt',
89    ctlSend: 'Send just 1–4',
90    ctlSendDoes: 'Sends the suggestion shown under that number instead of the digit',
91    featReplies: 'Suggestions',
92    featMore: 'More via fork',
93    featLayout: 'Layout',
94    paused: 'on (paused)',
95    setting: 'setting',
96    setLanguage: 'Language',
97    setMore: 'More suggestions via fork',
98    setLayout: 'Layout',
99    helpFooterTerminal: 'Change settings: /plugin configure quick-replies · Turn the mod off: /plugin disable quick-replies',
100    helpFooterDesktop: 'Turn the mod off: + → Plugins → Manage plugins · Change settings: /plugin configure quick-replies in a terminal',
101  },
102  de: {
103    description: 'Antwort-Vorschläge über Clawd: Status, an/aus, weitere Vorschläge per Fork, Hilfe',
104    usage: 'Nutzung: /replies [status|on|off|more on|more off|help] · Alle Befehle: /replies help',
105    on: 'an',
106    off: 'aus',
107    yes: 'ja',
108    no: 'nein',
109    moreViaFork: 'weitere Vorschläge per Fork',
110    moreOn:
111      'quick-replies: weitere Vorschläge per Fork an, in allen Sessions ab der nächsten Antwort. Der Vorschlag von Claude Code bleibt auf 1. Kostet pro Antwort etwa eine kurze Antwort, großteils aus dem Cache.',
112    moreOff: 'quick-replies: weitere Vorschläge aus, in allen Sessions nur noch der Vorschlag von Claude Code.',
113    suggestions: 'Vorschläge',
114    hidden: ' (ausgeblendet bis zum nächsten fertigen Turn)',
115    sourceEngine: 'Claude Code',
116    sourceFork: 'Fork',
117    lastSources: 'Quellen zuletzt',
118    engineSeen: 'Vorschlag von Claude Code in dieser Session',
119    surface: 'Oberfläche',
120    notDrawn: 'noch nicht gezeichnet',
121    band: (columns: number) => `Band ${columns} Spalten`,
122    layout: 'Anordnung',
123    position: 'Position',
124    positions: { alone: 'allein', inner: 'innen (nach mir nur der Kern)', outer: 'außen (andere Mods zeichnen darunter)' },
125    fork: {
126      running: 'läuft',
127      unanswered: (reason: string) => `keine Antwort (${reason})`,
128      found: (count: number) => (count === 1 ? '1 Vorschlag' : `${count} Vorschläge`),
129      none: 'keine Vorschläge',
130      error: (message: string) => `Fehler: ${message}`,
131    },
132    sendFailed: (text: string) => `Senden ging nicht. Bitte selbst senden: ${text}`,
133    forkLanguage: 'German',
134    forkQuotes: ['„', '“'],
135    forkInChat: 'Fork in diesem Chat',
136    usd: (v: number) => (Number(v) < 0.001 ? '<0,001 $' : `~${money(v).replace('.', ',')} $`),
137    costNote: 'API-Preis, geschätzt; im Abo zählt es gegen die Nutzungslimits',
138    tokens: (n: number) => `${count(n)} Tokens`,
139    fromCache: (pct: number) => `${pct} % aus dem Cache`,
140    helpIntro: 'Zeigt den Vorschlag von Claude Code für deine nächste Nachricht als Knopf über Clawd und sendet ihn per Klick oder Taste; auf Wunsch ergänzt ein Fork der Session weitere.',
141    helpStatus: 'Status: Vorschläge, Quellen, Fork-Zustand und Kosten in diesem Chat (auch ohne Argument)',
142    helpOn: 'Vorschläge einschalten',
143    helpOff: 'Vorschläge ausschalten (keine Pille, kein Fork)',
144    helpMoreOn: 'Weitere Vorschläge per Fork, bis zu vier insgesamt, in allen Sessions',
145    helpMoreOff: 'Nur der Vorschlag von Claude Code, in allen Sessions',
146    helpHelp: 'Diese Hilfe (auch: ?)',
147    helpForkCost: 'Fork: kostet pro Antwort etwa eine kurze Antwort, großteils aus dem Cache; die Kosten dieses Chats stehen in /replies status.',
148    helpMoreOverrides: '/replies more on|off überschreibt die Einstellung „Mehr Vorschläge per Fork“ für alle Sessions.',
149    ctlClick: 'Klick auf einen Vorschlag',
150    ctlClickDoes: 'Sendet ihn sofort als deine Nachricht',
151    ctlKey: '1–4 im leeren Prompt',
152    ctlKeyDoes: 'Sendet diesen Vorschlag sofort; die Ziffer bleibt nicht im Prompt',
153    ctlSend: 'Nur 1–4 abschicken',
154    ctlSendDoes: 'Sendet statt der Ziffer den Vorschlag mit dieser Nummer',
155    featReplies: 'Vorschläge',
156    featMore: 'Mehr per Fork',
157    featLayout: 'Anordnung',
158    paused: 'an (pausiert)',
159    setting: 'Einstellung',
160    setLanguage: 'Sprache',
161    setMore: 'Mehr Vorschläge per Fork',
162    setLayout: 'Anordnung',
163    helpFooterTerminal: 'Einstellungen ändern: /plugin configure quick-replies · Mod abschalten: /plugin disable quick-replies',
164    helpFooterDesktop: 'Mod abschalten: + → Plugins → Manage plugins · Einstellungen ändern: im Terminal /plugin configure quick-replies',
165  },
166} as const
167
168export type Texts = (typeof T)[Lang]
169
170/** Zeile für /replies status: Fork-Aufrufe dieses Chats mit geschätztem API-Wert. */
171export function forkCostText(t: Texts, c: ForkCost): string {
172  if (c.calls === 0) return `${t.forkInChat}: –`
173  const pct = c.tokens > 0 ? Math.round((c.cached / c.tokens) * 100) : 0
174  return `${t.forkInChat}: ${c.calls}× · ${t.usd(c.usd)} (${t.costNote}) · ${t.tokens(c.tokens)}, ${t.fromCache(pct)}`
175}
176
177export function forkStateText(t: Texts, s: ForkState): string {
178  switch (s.kind) {
179    case 'idle':
180      return '–'
181    case 'running':
182      return t.fork.running
183    case 'unanswered':
184      return t.fork.unanswered(s.reason)
185    case 'found':
186      return t.fork.found(s.count)
187    case 'none':
188      return t.fork.none
189    case 'error':
190      return t.fork.error(s.message)
191  }
192}
193
hooks/help.ts 205 lines
1// help.ts: die gezeichnete Hilfe-Tabelle für `/<befehl> help` (docs/HELP-SPEC.md §3-§4). Allgemein gehalten: Eine Mod
2// liefert nur `HelpData` und ihre Akzentfarbe; Aufbau, Spalten, Farben der Schalter und die Markdown-Fassung stehen hier.
3// Vorlage für alle Mods: templates/help/ (README dort). Ohne `$`, nur Daten → Baum bzw. Text, deshalb ohne Engine testbar.
4//
5// Baum aus reinen Daten {type, props, children} (types RenderElement), nur Box und Text mit erlaubten Props. Desktop:
6// Spaltenbreiten nur als ganzzahlige Prozent, sonst verwirft er den ganzen Baum (cost-ledger 0.3.1). Unter 60 Spalten
7// stehen die Spalten untereinander.
8import type { RenderElement, RenderNode } from 'claude-code'
9
10export type HelpLang = 'en' | 'de'
11export type HelpSurface = 'terminal' | 'desktop'
12
13/** Eine Zeile unter BEFEHLE bzw. BEDIENUNG: Befehl oder Bedienelement (Akzentfarbe) und seine Wirkung. */
14export type HelpCommand = { cmd: string; does: string }
15/**
16 * Zustand einer Funktion: Schalter (`on` → „● an“ in `success`, `off` → „○ aus“ in `inactive`) oder ein Wert als Text, mit
17 * „(Standard)“, wenn `isDefault`. `text` ersetzt „an“/„aus“; bei `on` steht er dann in der normalen Schriftfarbe (lange
18 * Texte bleiben lesbar), nur der Punkt ist grün.
19 */
20export type HelpState = { kind: 'on' | 'off'; text?: string } | { kind: 'value'; text: string; isDefault?: boolean }
21/** Eine Zeile unter FUNKTIONEN; `toggle` ist der Befehl, der den Zustand ändert, sonst z. B. „Einstellung“ oder „nur Info“. */
22export type HelpFeature = { name: string; state: HelpState; toggle: string }
23/** Eine Zeile unter EINSTELLUNGEN: Titel des userConfig-Felds (übersetzt) und sein aktueller Wert. */
24export type HelpSetting = { title: string; value: string; isDefault?: boolean }
25/** Schnappschuss beim Aufruf von `help`; die Zeichnung schreibt sich danach nicht um (HELP-SPEC §3 Punkt 3). */
26export type HelpData = {
27  /** Name der Mod im Titel */
28  mod: string
29  lang: HelpLang
30  /** Ein Satz, was die Mod macht */
31  intro: string
32  commands: HelpCommand[]
33  /** Gedimmte Zeilen unter den Befehlen (z. B. Aliase) */
34  notes?: string[]
35  /** BEDIENUNG: nur, wenn es Klicks oder Tasten gibt */
36  controls?: HelpCommand[]
37  features: HelpFeature[]
38  settings: HelpSetting[]
39  /** Weg zum Ändern der Einstellungen und zum Abschalten der Mod; Terminal und Desktop brauchen verschiedene Wege */
40  footer: { terminal: string; desktop: string }
41}
42
43const LABELS = {
44  en: {
45    help: 'Help',
46    commands: 'COMMANDS',
47    controls: 'CONTROLS',
48    features: 'FEATURES',
49    status: 'STATUS',
50    toggle: 'TOGGLE',
51    settings: 'SETTINGS (/plugin)',
52    value: 'VALUE',
53    on: 'on',
54    off: 'off',
55    isDefault: '(default)',
56  },
57  de: {
58    help: 'Hilfe',
59    commands: 'BEFEHLE',
60    controls: 'BEDIENUNG',
61    features: 'FUNKTIONEN',
62    status: 'STATUS',
63    toggle: 'UMSCHALTEN',
64    settings: 'EINSTELLUNGEN (/plugin)',
65    value: 'WERT',
66    on: 'an',
67    off: 'aus',
68    isDefault: '(Standard)',
69  },
70} as const
71
72export function helpLabels(lang: HelpLang) {
73  return LABELS[lang]
74}
75
76type Props = Record<string, string | number | boolean>
77const el = (type: 'Box' | 'Text', props: Props, children: RenderNode[]): RenderElement => ({ type, props, children })
78const text = (s: string, props: Props = {}) => el('Text', props, [s])
79const dim = (s: string) => text(s, { dimColor: true })
80const row = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'row', ...props }, kids)
81const col = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'column', ...props }, kids)
82const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
83
84/** Zustand als Text (für die Markdown-Fassung und zum Messen der Spaltenbreite). */
85export function stateText(s: HelpState, lang: HelpLang): string {
86  const L = LABELS[lang]
87  if (s.kind === 'value') return s.isDefault ? `${s.text} ${L.isDefault}` : s.text
88  return `${s.kind === 'on' ? '●' : '○'} ${s.text ?? (s.kind === 'on' ? L.on : L.off)}`
89}
90
91/**
92 * Zustand gezeichnet: „● an“ in `success`, „○ aus“ in `inactive`; mit eigenem Text bei `on` nur der Punkt grün. Werte in
93 * der normalen Schriftfarbe, „(Standard)“ gedimmt.
94 */
95function stateNode(s: HelpState, lang: HelpLang): RenderElement {
96  const L = LABELS[lang]
97  if (s.kind === 'value') return el('Text', {}, [text(s.text), ...(s.isDefault ? [dim(` ${L.isDefault}`)] : [])])
98  const on = s.kind === 'on'
99  const label = s.text ?? (on ? L.on : L.off)
100  const labelProps: Props = on ? (s.text === undefined ? { color: 'success' } : {}) : { color: 'inactive' }
101  return el('Text', {}, [text(on ? '● ' : '○ ', { color: on ? 'success' : 'inactive' }), text(label, labelProps)])
102}
103
104/**
105 * Spalten einer Zeile. Terminal: feste Zellen, die letzte füllt den Rest. Desktop: ganzzahlige Prozent mit Summe 100
106 * (`share` = gewünschte Breite in Zeichen, daraus die Anteile).
107 */
108function columns(sf: HelpSurface, inner: number, widths: number[], kids: RenderNode[], props: Props = {}): RenderElement {
109  if (sf === 'desktop') {
110    const pct = widths.slice(0, -1).map((w) => clamp(Math.round((w / inner) * 100), 10, 60))
111    const rest = Math.max(10, 100 - pct.reduce((a, b) => a + b, 0))
112    const all = [...pct, rest]
113    // Summe genau 100: Überhang vom größten Anteil abziehen
114    const over = all.reduce((a, b) => a + b, 0) - 100
115    if (over > 0) all[all.indexOf(Math.max(...all))]! -= over
116    return row(props, kids.map((k, i) => el('Box', { width: `${all[i]}%`, paddingRight: 1 }, [k])))
117  }
118  return row(
119    props,
120    kids.map((k, i) => (i < kids.length - 1 ? el('Box', { width: widths[i]!, flexShrink: 0 }, [k]) : el('Box', { flexGrow: 1, flexShrink: 1 }, [k]))),
121  )
122}
123
124const heading = (s: string, accent: string) => text(s, { color: accent, bold: true })
125
126/**
127 * Der ganze Baum für eine `CommandOutput`-Zeile. `columns` ist `e.viewport?.columns` (begrenzt auf 30-140), `accent` die
128 * Akzentfarbe der Mod (Hex oder Theme-Key), nur für Titel, Überschriften und Befehle.
129 */
130export function helpTree(d: HelpData, columnsHint: number, surface: HelpSurface, accent: string): RenderElement {
131  const L = LABELS[d.lang]
132  const cols = clamp(columnsHint || 100, 30, 140)
133  const inner = cols - 4 // Rahmen und paddingX
134  const narrow = cols < 60
135  const kids: RenderNode[] = [heading(`${d.mod} · ${L.help}`, accent), text(d.intro)]
136
137  const commandRows = (title: string, list: HelpCommand[]) => {
138    if (!list.length) return
139    kids.push(el('Box', { marginTop: 1 }, [heading(title, accent)]))
140    const cmdW = clamp(Math.max(...list.map((c) => c.cmd.length)) + 2, 12, Math.floor(inner * 0.5))
141    for (const c of list)
142      kids.push(
143        narrow
144          ? col({}, [text(c.cmd, { color: accent }), el('Box', { paddingLeft: 2 }, [text(c.does)])])
145          : columns(surface, inner, [cmdW, inner - cmdW], [text(c.cmd, { color: accent }), text(c.does)]),
146      )
147  }
148  commandRows(L.commands, d.commands)
149  for (const n of d.notes ?? []) kids.push(dim(n))
150  commandRows(L.controls, d.controls ?? [])
151
152  if (d.features.length) {
153    const nameW = clamp(Math.max(L.features.length, ...d.features.map((f) => f.name.length)) + 2, 12, Math.floor(inner * 0.34))
154    const stateW = clamp(Math.max(L.status.length, ...d.features.map((f) => stateText(f.state, d.lang).length)) + 2, 10, Math.floor(inner * 0.4))
155    if (narrow) {
156      kids.push(el('Box', { marginTop: 1 }, [heading(L.features, accent)]))
157      // Zustand und Umschalten in einem Text, damit sie als ein Absatz umbrechen statt als zwei schmale Spalten
158      for (const f of d.features)
159        kids.push(col({}, [text(f.name), el('Box', { paddingLeft: 2 }, [el('Text', {}, [stateNode(f.state, d.lang), dim(` · ${f.toggle}`)])])]))
160    } else {
161      const widths = [nameW, stateW, inner - nameW - stateW]
162      kids.push(columns(surface, inner, widths, [heading(L.features, accent), heading(L.status, accent), heading(L.toggle, accent)], { marginTop: 1 }))
163      for (const f of d.features) kids.push(columns(surface, inner, widths, [text(f.name), stateNode(f.state, d.lang), dim(f.toggle)]))
164    }
165  }
166
167  if (d.settings.length) {
168    const titleW = clamp(Math.max(L.settings.length, ...d.settings.map((s) => s.title.length)) + 2, 12, Math.floor(inner * 0.5))
169    const value = (s: HelpSetting) => stateNode({ kind: 'value', text: s.value, isDefault: s.isDefault }, d.lang)
170    if (narrow) {
171      kids.push(el('Box', { marginTop: 1 }, [heading(L.settings, accent)]))
172      for (const s of d.settings) kids.push(col({}, [text(s.title), el('Box', { paddingLeft: 2 }, [value(s)])]))
173    } else {
174      const widths = [titleW, inner - titleW]
175      kids.push(columns(surface, inner, widths, [heading(L.settings, accent), heading(L.value, accent)], { marginTop: 1 }))
176      for (const s of d.settings) kids.push(columns(surface, inner, widths, [text(s.title), value(s)]))
177    }
178  }
179
180  kids.push(el('Box', { marginTop: 1 }, [dim(surface === 'desktop' ? d.footer.desktop : d.footer.terminal)]))
181  return col({ borderStyle: 'round', borderDimColor: true, paddingX: 1, width: '100%', key: `${d.mod}-help` }, kids)
182}
183
184/**
185 * Kompakte Markdown-Fassung: was Claude mitliest und was `-p`, das SDK und VS Code zeigen. `tag` (Kennung `#…`) steht in
186 * der ersten Zeile, darüber findet der Render-Hook den Schnappschuss. Fußzeile mit dem Terminal-Weg. Leerzeilen zwischen
187 * den Blöcken: Sonst hängt Markdown (CommonMark) alles nach einer Liste an deren letzten Punkt.
188 */
189export function helpMarkdown(d: HelpData, tag: string): string {
190  const L = LABELS[d.lang]
191  const lines = [`**${d.mod} · ${L.help}**${tag ? ` · ${tag}` : ''}`, '', d.intro]
192  const list = (title: string, items: HelpCommand[]) => {
193    if (!items.length) return
194    lines.push('', `**${title}**`, ...items.map((c) => `- \`${c.cmd}\`: ${c.does}`))
195  }
196  list(L.commands, d.commands)
197  if (d.notes?.length) lines.push('', ...d.notes)
198  list(L.controls, d.controls ?? [])
199  if (d.features.length) lines.push('', `**${L.features}:** ${d.features.map((f) => `${f.name} ${stateText(f.state, d.lang)} (${f.toggle})`).join(' · ')}`)
200  if (d.settings.length)
201    lines.push('', `**${L.settings}:** ${d.settings.map((s) => `${s.title} ${s.value}${s.isDefault ? ` ${L.isDefault}` : ''}`).join(' · ')}`)
202  lines.push('', d.footer.terminal)
203  return lines.join('\n')
204}
205
hooks/logic.ts 166 lines
1// quick-replies: reine Logik (ohne `$`): Bereinigen, Zusammenführen auf höchstens 4 Plätze, Fork-Frage und -Antwort.
2// Die Idee, die Session zu forken und nach den nächsten Nachrichten zu fragen, stammt vom Community-Plugin next-steps
3// (anthropics/claude-plugins-community). Umsetzung, Frage und Bereinigung sind eigene; aus dem Plugin ist kein Code übernommen.
4
5import type { HelpData } from './help.ts'
6import type { Lang } from './i18n.ts'
7import { T } from './i18n.ts'
8
9export type Source = 'engine' | 'fork'
10export type Reply = { text: string; source: Source }
11
12const SLOTS = 4
13/** Vorschläge aus dem Fork. Kommt einer von der Engine, steht er vorn und der letzte aus dem Fork fällt weg (merge). */
14const FORK_MAX = 4
15/** Längster Fork-Vorschlag. Längere werden verworfen, nicht gekürzt: Der Knopf zeigt höchstens so viel (view.ts MAX_LABEL), und
16 *  gesendet werden darf nur, was auf dem Knopf steht. */
17const FORK_TEXT_MAX = 40
18/** Kürzere Antworten lohnen keinen Fork. */
19export const MIN_ANSWER = 40
20/** Längster Vorschlag von Claude Code; er steht ohnehin vollständig grau im Prompt (types@2.1.289:3996-4002). */
21const TEXT_MAX = 300
22
23// Modell-Ausgabe kann fremden Text aus dem Chat nachplappern (Dateien, Tool-Ergebnisse, Webseiten). Bevor sie gezeigt oder gesendet
24// wird: Terminal-Steuersequenzen weg, alles Unsichtbare weg (Steuer-, Format-, private und nicht belegte Zeichen sowie alles, was
25// Unicode als „standardmäßig ignorierbar“ führt), Leerraum falten, Häufungen von Kombinationszeichen kappen. Text mit Unicode-Tag-
26// Zeichen (U+E0000 bis U+E007F) wird ganz verworfen; sie dienen in einem Prompt nur zum Verstecken.
27const ANSI = /\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-_])/g
28const TAGS = /[\u{E0000}-\u{E007F}]/u
29const INVISIBLE = /[\p{C}\p{Default_Ignorable_Code_Point}]/gu
30const MARK_PILES = /\p{M}{4,}/gu
31
32export function clean(text: string, max = TEXT_MAX): string {
33  if (TAGS.test(text)) return ''
34  const visible = text
35    .replace(ANSI, '')
36    .replace(/\s+/g, ' ')
37    .replace(INVISIBLE, '')
38    .replace(MARK_PILES, (pile) => [...pile].slice(0, 3).join(''))
39    .replace(/ +/g, ' ')
40    .trim()
41  const chars = [...visible]
42  return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : visible
43}
44
45/** Schlüssel für „doppelt“: ohne Groß-/Kleinschreibung, Satzzeichen und Leerraum. */
46export function keyOf(text: string): string {
47  return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
48}
49
50/** Die Nummer eines Platzes, wenn der Text nur aus einer Ziffer `1`–`4` besteht, auch mehrfach dieselbe: eine gehaltene Taste
51 *  wiederholt sie, und der Editor fasst solche Tasten zu einer Eingabe zusammen (types@2.1.291:8228-8233). Sonst 0. */
52export function heldDigit(text: string): number {
53  const m = /^([1-4])\1*$/.exec(text)
54  return m ? Number(m[1]) : 0
55}
56
57/** Höchstens 4 Plätze: zuerst der Vorschlag der Engine, dann die aus dem Fork, die nachrücken; Doppelte fliegen raus.
58 *  Ohne Quellen: leer. */
59export function merge(engine: string, fork: readonly string[]): Reply[] {
60  const out: Reply[] = []
61  const seen = new Set<string>()
62  const add = (text: string, source: Source) => {
63    const t = clean(text)
64    const k = keyOf(t)
65    if (!t || !k || seen.has(k) || out.length >= SLOTS) return
66    seen.add(k)
67    out.push({ text: t, source })
68  }
69  add(engine, 'engine')
70  for (const f of fork.slice(0, FORK_MAX)) add(f, 'fork')
71  return out
72}
73
74/** Die Frage an den Fork der Session: Er kennt den ganzen Chat, soll aber nicht weitermachen. Die Frage ist englisch, die
75 *  Vorschläge kommen in der eingestellten Sprache. Eine Nachricht, die mit `/` beginnt, führt Claude Code als Befehl aus; nur
76 *  gewollte Aufrufe dürfen so beginnen, sonst steht der Befehl in typografischen Anführungszeichen (kein Escaping im JSON). */
77export function forkPrompt(lang: Lang): string {
78  const [open, close] = T[lang].forkQuotes
79  return [
80    'Do not continue the task. Instead, predict what the user is most likely to write to you next:',
81    `up to ${FORK_MAX} concrete messages in the user's voice, written in ${T[lang].forkLanguage}, terse like a developer giving`,
82    `instructions, each at most ${FORK_TEXT_MAX} characters and specific to this chat (name the file, test or next step).`,
83    'Prefer the obvious next step (run the tests, commit, fix what was reported) over generic sentences.',
84    'A message that starts with "/" is executed as a slash command when sent. Start a message with "/" only if it is exactly',
85    'the command the user wants to run (/<command> <args>), and only name commands that appear in this chat. Whenever a',
86    `message only mentions or asks about a command, wrap the command in ${open}${close}, even at the start (${open}/<command>${close} fails).`,
87    'If the chat is clearly finished or nothing useful comes to mind, return an empty list.',
88    'Reply ONLY with a JSON array of strings, without any text around it and without a code block.',
89  ].join(' ')
90}
91
92/** Fork-Antwort → bis zu 4 Vorschläge; nicht auswertbar → []. Nimmt Strings oder Objekte mit `prompt`; zu lange fliegen raus. */
93export function parseFork(reply: string): string[] {
94  const start = reply.indexOf('[')
95  const end = reply.lastIndexOf(']')
96  if (start < 0 || end <= start) return []
97  let data: unknown
98  try {
99    data = JSON.parse(reply.slice(start, end + 1))
100  } catch {
101    return []
102  }
103  if (!Array.isArray(data)) return []
104  const out: string[] = []
105  for (const entry of data) {
106    const raw = typeof entry === 'string' ? entry : entry && typeof entry === 'object' ? (entry as { prompt?: unknown }).prompt : undefined
107    if (typeof raw !== 'string') continue
108    // Zu lang: verwerfen statt kürzen, damit nie mehr gesendet wird, als auf dem Knopf steht
109    const t = clean(raw, Number.MAX_SAFE_INTEGER)
110    if (t && [...t].length <= FORK_TEXT_MAX) out.push(t)
111    if (out.length === FORK_MAX) break
112  }
113  return out
114}
115
116// Wörter, die /replies annimmt (erstes Wort klein geschrieben). Der Parser in register.ts nutzt sie, und ein Test prüft jedes
117// gegen die Hilfe (docs/HELP-SPEC.md §6 Punkt 5).
118export const STATUS_WORDS: readonly string[] = ['status']
119export const TOGGLE_WORDS: readonly string[] = ['on', 'off']
120export const MORE_WORD = 'more'
121export const HELP_WORDS: readonly string[] = ['help', '?']
122
123/** Was die Hilfe zeigt: Zustand dieser Session (`enabled`, `more` nach /replies) und die Werte aus userConfig. */
124export type HelpInput = {
125  lang: Lang
126  enabled: boolean
127  more: boolean
128  config: { more: boolean; layout: 'auto' | 'grid' | 'list' }
129}
130
131/** Schnappschuss für `/replies help` (docs/HELP-SPEC.md §5): Befehle genau so, wie der Parser sie annimmt. */
132export function repliesHelp(s: HelpInput): HelpData {
133  const t = T[s.lang]
134  return {
135    mod: 'quick-replies',
136    lang: s.lang,
137    intro: t.helpIntro,
138    commands: [
139      { cmd: '/replies status', does: t.helpStatus },
140      { cmd: '/replies on', does: t.helpOn },
141      { cmd: '/replies off', does: t.helpOff },
142      { cmd: '/replies more on', does: t.helpMoreOn },
143      { cmd: '/replies more off', does: t.helpMoreOff },
144      { cmd: '/replies help', does: t.helpHelp },
145    ],
146    notes: [t.helpForkCost, t.helpMoreOverrides],
147    controls: [
148      { cmd: t.ctlClick, does: t.ctlClickDoes },
149      { cmd: t.ctlKey, does: t.ctlKeyDoes },
150      { cmd: t.ctlSend, does: t.ctlSendDoes },
151    ],
152    features: [
153      { name: t.featReplies, state: { kind: s.enabled ? 'on' : 'off' }, toggle: s.enabled ? '/replies off' : '/replies on' },
154      // Bei ausgeschalteten Vorschlägen läuft auch der Fork nicht (register.ts, turn.complete): „an (pausiert)“
155      { name: t.featMore, state: s.more ? { kind: 'on', ...(s.enabled ? {} : { text: t.paused }) } : { kind: 'off' }, toggle: s.more ? '/replies more off' : '/replies more on' },
156      { name: t.featLayout, state: { kind: 'value', text: s.config.layout, isDefault: s.config.layout === 'auto' }, toggle: t.setting },
157    ],
158    settings: [
159      { title: t.setLanguage, value: s.lang, isDefault: s.lang === 'en' },
160      { title: t.setMore, value: s.config.more ? t.on : t.off, isDefault: !s.config.more },
161      { title: t.setLayout, value: s.config.layout, isDefault: s.config.layout === 'auto' },
162    ],
163    footer: { terminal: t.helpFooterTerminal, desktop: t.helpFooterDesktop },
164  }
165}
166
hooks/view.ts 31 lines
1// quick-replies: reine Anzeige-Logik (ohne `$`), damit sie sich ohne Band testen lässt.
2
3/** Längste Beschriftung; der volle Text wird trotzdem gesendet. */
4const MAX_LABEL = 40
5/** Spalten zwischen linker und rechter Hälfte im 2 × 2. */
6export const GAP = 2
7/** `1: ` vor der Beschriftung (Terminal, `plain`, types@2.1.289:1028-1031). */
8const PREFIX = 3
9
10export type Layout = 'grid' | 'list'
11export type LayoutPref = 'auto' | Layout
12
13export function label(text: string): string {
14  const t = text.replace(/\s+/g, ' ').trim()
15  return t.length > MAX_LABEL ? t.slice(0, MAX_LABEL - 1).trimEnd() + '…' : t
16}
17
18/** Halbe Breite des Bands für eine Spalte des 2 × 2 (bodyColumns zieht das `[-]` der Engine schon ab, types@2.1.289:9735-9741). */
19function halfWidth(bodyColumns: number): number {
20  return Math.floor((bodyColumns - GAP) / 2)
21}
22
23/** 2 × 2, wenn die längste Beschriftung samt Ziffer in die halbe Breite passt, sonst vier Zeilen. */
24export function chooseLayout(texts: readonly string[], bodyColumns: number, pref: LayoutPref = 'auto'): Layout {
25  // Ein einzelner Vorschlag (nur der von Claude Code) steht allein in seiner Zeile
26  if (texts.length <= 1) return 'list'
27  if (pref !== 'auto') return pref
28  const longest = texts.reduce((m, t) => Math.max(m, label(t).length), 0)
29  return longest + PREFIX <= halfWidth(bodyColumns) ? 'grid' : 'list'
30}
31