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…

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
| Surface | Look |
|---|---|
| Desktop Code tab | Its own pill with a dimmed rounded border and one blank line above Clawd, the limit bars and the cache ring, which stay unchanged below |
| Terminal | Compact, without border or spacing, above the rest of the band |
1/3, right 2/4) if the longest label fits into half the width, otherwise one per row. The layout setting can force either.…. That only affects Claude Code's own suggestion, which is shown in full as grey text in the prompt and is sent in full./clear until the next answer. Without a pill the band keeps its height; quick-replies never moves Clawd or the bars.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.
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.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./clear, a suggestion from the old chat is never sent into the new one.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.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 | Effect |
|---|---|
/replies or /replies status | On/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 off | Turn the mod on or off |
/replies more on · /replies more off | Turn the extra fork suggestions on or off |
/replies help | Help 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.
/config → quick-replies:
| Key | Title in /config | Meaning | Default | |
|---|---|---|---|---|
language | Language / Sprache | Language of the mod's texts and of the fork's suggestions: en English, de German | en | |
more | More suggestions via fork | Besides 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 sessions | off |
layout | Layout | auto: 2 × 2 if the labels fit, otherwise one per row. grid: always 2 × 2. list: always one per row | auto |
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.
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:
prompt.suggest: reads Claude Code's own suggestion and passes it on unchanged.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.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.turn.start, turn.complete: clear the suggestions when you send, mark when an answer is finished.ui.render for AbovePrompt: draws the pill above the rest of the band.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.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.
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
more is on and the fork delivers something. /replies status shows how often Claude Code supplied one in this session.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.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./replies status shows the position.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.
hooks/register.ts 550 lines1// 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}
550hooks/band.ts 108 lines1// 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
108hooks/cost.ts 89 lines1// 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}
89hooks/i18n.ts 193 lines1// 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}
193hooks/help.ts 205 lines1// 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}
205hooks/logic.ts 166 lines1// 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}
166hooks/view.ts 31 lines1// 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