SLOPSHOPPER

let-them-talk-suggestions

Sends Claude Code's suggested next prompt to Let Them Talk, so its Send box shows the same suggestion, runs a Chat in IDE started from it on the model picked…

newpromptmodelnetworktimer
A shopper browsing a rack in a slop shop
README

let-them-talk-suggestions

A Claude Code plugin for Let Them Talk, a visual board for your Claude Code sessions. It does three things:

  • It passes Claude Code's suggested next prompt on to the board, so the Send box of a terminal chat shows the same grey suggestion as the chat's own prompt box.
  • It runs a Chat in IDE started from the board on the model you picked in New agent.
  • In a background agent, after an ultracode switch made while it works, it runs /effort status so the board can read the switch.

It needs Let Them Talk running on port 8765, the default. It talks only to the app on your own machine, and does nothing when the app isn't running.

Install

claude plugin marketplace add abdullahkavakli/let-them-talk
claude plugin install let-them-talk-suggestions@let-them-talk

Or, inside a chat: /plugin install let-them-talk-suggestions --marketplace abdullahkavakli/let-them-talk.

The plugin is the mod of the latest release, the same release as the app uvx let-them-talk runs, so the two match. A change made to the mod since then reaches you with the next release.

Chats started before the plugin was installed show no suggestion until they are restarted.

More, including how to load it from a copy of the repository (which then follows that copy, not the release) and what to do if the app runs on another port: Configuration.

Source 1 files
hooks/register.ts 229 lines
1import type { Register } from 'claude-code'
2
3// Let Them Talk (this repo) shows each chat's suggested next prompt in its
4// Send box: exactly what this chat's own prompt box shows, or nothing.
5//
6// - When Claude Code shows its own suggestion, it is passed on.
7// - When it made none (the terminal is unfocused, or a background agent has
8//   nobody attached) and Let Them Talk has this chat open, one is made the
9//   way Claude Code makes its own ($.model.fork over the cached conversation)
10//   and shown in this chat's prompt box too, so both match.
11// - A background agent that ends its turn waiting on you is left alone:
12//   Claude Code makes that one itself and saves it, and the app reads it there.
13// Only text the box really shows is reported; otherwise "none".
14//
15// It also runs a chat on the model picked in the app's New agent, for a Chat in
16// IDE: the editor link that opens one can't name a model, so the app says
17// which when the chat's first turns start, and this names it on the chat's
18// own requests (not its subagents'). A model you then pick in the chat wins.
19//
20// And in a background agent it keeps the app's Ultracode line true. The app
21// reads ultracode from the agent's conversation, where Claude Code notes each
22// /effort answered while it is idle. One run while it works (typed, or Tab
23// in its Effort panel) answers on its screen only, which no hook sees. So
24// after an /effort that may have switched it (not an effort level alone) and
25// got no answer, this runs /effort status (a line the agent shows), which
26// Claude Code queues until the agent is idle: its answer lands in the
27// conversation, where the app reads it. Ordinary terminal and IDE chats are
28// left alone: the app shows ultracode for background agents only.
29
30const APP = 'http://localhost:8765' // Let Them Talk's default port (LTT_PORT)
31const HEADERS = { 'Content-Type': 'application/json', 'X-Let-Them-Talk': '1' }
32const TICK_MS = 2000
33const OWN_WAIT_MS = 12000 // Claude Code makes its own within a few seconds of the turn's end
34const WINDOW_MS = 300_000 // after that the conversation may no longer be cached (server: MOD_WINDOW)
35const HELLO_EVERY_MS = 300_000 // so the app keeps counting this chat as having the mod
36const MODEL_ASKS = 3 // turns at a chat's start the app is asked for a model (it knows by the first)
37const ANSWER_WAIT_MS = 1500 // an idle /effort's answer is appended within ms; a busy one's never
38const BACKGROUND = 'bg' // CLAUDE_CODE_SESSION_KIND of a background agent (claude --bg), its registry kind the app reads
39const COMMAND_NAME = /<command-name>\/?([^<]*)<\/command-name>/
40const COMMAND_OUT = /^\s*<local-command-stdout>/ // a command's answer
41const MAY_SWITCH = /^\s*(ultracode(\s|$)|$)/i // /effort ultracode [on|off], or its panel (no words): Claude Code's own reading
42
43const PROMPT = `[Suggestion only. Do not answer this message or continue the task.]
44Predict what the user will most likely type next in this chat. Predict what they
45would type, not what they should do: they should think "I was about to type that".
46If you just asked whether to go on, answer like "yes" or "go ahead"; if you offered
47options, pick the one this user would; if a task is done and the next step is
48obvious, name it, like "commit it" or "run the tests"; after an error or a
49misunderstanding, suggest nothing. Be specific. Never suggest thanks or praise, a
50question, your own voice ("Let me..."), or anything new the user didn't ask for.
51One sentence of 2 to 12 words, in the user's language and style. If the next step
52isn't obvious, reply with a single hyphen. Reply with only the suggestion.`
53
54const SILENT = /^\W*(-|none|nothing|silence|no suggestion.*|nothing to suggest.*|done)\W*$|^[[(].*[\])]$/i
55
56/** What to tell the app about a suggestion: the text only if the box shows it. */
57export function report(text: string | undefined, isShown: boolean, made: 'claude' | 'fork') {
58  return text?.trim() && isShown ? { text, made } : { text: '', made: 'none' as const }
59}
60
61export function cleanSuggestion(text: string): string | undefined {
62  const line = text.trim().split('\n')[0].trim().replace(/^["'“”]+|["'“”]+$/g, '').trim()
63  return line && !SILENT.test(line) && line.split(/\s+/).length <= 12 ? line : undefined
64}
65
66/** The model a request of this chat names: the app's pick (a full id), until
67 * the chat's own model changes under it (you picked another in the chat), then
68 * the chat's. The chat's own spelling of the same model ([1m]) is kept. */
69export function modelFor(wanted: string | undefined, startedOn: string | undefined, now: string) {
70  if (!wanted) return { model: now, startedOn, dropped: false }
71  if (startedOn !== undefined && now !== startedOn) return { model: now, startedOn, dropped: true }
72  return { model: now.startsWith(wanted) ? now : wanted, startedOn: startedOn ?? now, dropped: false }
73}
74
75/** What the mod keeps of the /effort runs in this chat. */
76type Efforts = {
77  lastCommand?: string // the command whose answer is appended next
78  runs: number // /effort runs so far
79  answered: number // the last /effort run whose answer was appended
80  looking: boolean // this mod's /effort status waits for the chat to be idle
81}
82
83export const register: Register = on => {
84  let turn = 0 // main-loop turns ended so far
85  let starts = 0 // main-loop turns started so far
86  let ownFor = -1 // the turn Claude Code's own suggestion came after
87  let pending: { turn: number; starts: number; waited: number } | undefined // a turn still without one
88  let busy = false
89  let helloFor: string | undefined
90  let sinceHello = 0
91  let unsent: string | undefined // a report the app didn't take (it was down), sent again next tick
92  let wanted: string | undefined // the model the app picked for this chat
93  let startedOn: string | undefined // the chat's own model when that took over
94  let asked = 0
95  const efforts: Efforts = { runs: 0, answered: 0, looking: false }
96
97  // Work after a turn outlives the turn's own hooks, so a timer started here does it.
98  on('session.start', async ($, e, next) => {
99    const started = await next(e)
100    // A headless run (claude -p, such as the app's own TL;DR runs) has no prompt box.
101    if (!e.isInteractive) return started
102    $.clock.every(TICK_MS, () => {
103      if (busy) return
104      busy = true
105      void (async () => {
106        try {
107          // Read each time: /clear and /resume carry on under another id.
108          const sessionId = await $.session.id()
109          sinceHello += TICK_MS
110          if (sessionId !== helloFor || sinceHello >= HELLO_EVERY_MS) {
111            await $.http.fetch(`${APP}/api/suggestion/hello`, {
112              method: 'POST', headers: HEADERS, body: JSON.stringify({ sessionId }),
113            })
114            helloFor = sessionId
115            sinceHello = 0
116          }
117          if (unsent) {
118            const sent = await $.http.fetch(`${APP}/api/suggestion`, { method: 'POST', headers: HEADERS, body: unsent })
119            if (sent.ok) unsent = undefined
120          }
121          if (!pending) return
122          pending.waited += TICK_MS
123          const { turn: forTurn, starts: forStarts, waited } = pending
124          if (ownFor === forTurn || waited > WINDOW_MS) { // Claude Code made its own, or too late
125            pending = undefined
126            return
127          }
128          if (waited < OWN_WAIT_MS) return
129          const wanted = await $.http.fetch(`${APP}/api/suggestion/wanted?session=${sessionId}`)
130          if (!wanted.ok || JSON.parse(wanted.text).wanted !== true) return // ask again next tick
131          pending = undefined
132          const reply = await $.model.fork({ prompt: PROMPT })
133          const text = reply.isAnswered ? cleanSuggestion(reply.text) : undefined
134          if (ownFor === forTurn || forTurn !== turn || forStarts !== starts) return // a newer turn
135          const shown = text ? await $.prompt.suggest({ text }) : undefined
136          // "none" tells the app this turn has nothing, so it stops waiting for one
137          const body = JSON.stringify({ sessionId, ...report(text, shown?.isShown === true, 'fork') })
138          const sent = await $.http.fetch(`${APP}/api/suggestion`, { method: 'POST', headers: HEADERS, body })
139          if (!sent.ok) unsent = body
140        } catch {
141          // Let Them Talk isn't running, or the fork failed: nothing to show.
142        } finally {
143          busy = false
144        }
145      })()
146    })
147    return started
148  })
149
150  on('prompt.suggest', async ($, e, next) => {
151    const shown = await next(e)
152    // Claude Code's own guess; one this mod (a plugin) proposed is already sent.
153    if (e.origin?.kind === 'plugin' || !e.text.trim()) return shown
154    ownFor = turn
155    let body: string | undefined
156    try {
157      const sessionId = await $.session.id()
158      body = JSON.stringify({ sessionId, ...report(e.text, shown.isShown, 'claude') })
159      const sent = await $.http.fetch(`${APP}/api/suggestion`, { method: 'POST', headers: HEADERS, body })
160      if (!sent.ok) unsent = body
161    } catch {
162      unsent = body // Let Them Talk isn't running: the suggestion shows here all the same
163    }
164    return shown
165  })
166
167  on('turn.start', async ($, e, next) => {
168    starts++
169    pending = undefined // the user moved on: a suggestion for the last turn is moot
170    if (!wanted && asked < MODEL_ASKS) {
171      asked++
172      try {
173        const reply = await $.http.fetch(`${APP}/api/mod/model?session=${await $.session.id()}`)
174        if (reply.ok) wanted = JSON.parse(reply.text).model || undefined
175      } catch {
176        // Let Them Talk isn't running: the chat's own model
177      }
178    }
179    return next(e)
180  })
181
182  on('turn.step', async function* ($, e, next) {
183    if (e.agentId || !wanted) return yield* next(e)
184    const pick = modelFor(wanted, startedOn, e.model)
185    startedOn = pick.startedOn
186    if (pick.dropped) wanted = undefined
187    return yield* next(pick.model === e.model ? e : { ...e, model: pick.model })
188  })
189
190  on('turn.complete', async ($, e, next) => {
191    const done = await next(e)
192    // The main loop's answers only: not subagents, interrupts or errors.
193    if (!e.agentId && !e.isAborted && e.reason === 'answer') pending = { turn: ++turn, starts, waited: 0 }
194    return done
195  })
196
197  // An /effort run: while the chat is idle its answer is appended at once
198  // (below); while it works, never. Then, in a background agent, for one that
199  // may have switched ultracode: /effort status, queued until it is idle.
200  on('command.run', { command: 'effort' }, async ($, e, next) => {
201    const run = ++efforts.runs
202    const ran = await next(e)
203    if (!MAY_SWITCH.test(e.args) || (await $.env.get('CLAUDE_CODE_SESSION_KIND')) !== BACKGROUND) return ran
204    $.clock.after(ANSWER_WAIT_MS, () => {
205      if (efforts.answered >= run || efforts.looking) return
206      efforts.looking = true
207      $.command.run({ command: 'effort', args: 'status' })
208        .catch(() => undefined) // the agent ended: its next prompt tells the app
209        .finally(() => { efforts.looking = false })
210    })
211    return ran
212  }).catch(($, e, next) => next(e))
213
214  // The rows a command run while the chat is idle leaves in its conversation:
215  // its name, then its answer. A subagent's are its own.
216  on('session.append', { door: 'command' }, async ($, e, next) => {
217    const kept = await next(e)
218    if (e.agentId) return kept
219    const text = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join('')
220    const name = COMMAND_NAME.exec(text)
221    if (name) efforts.lastCommand = name[1]?.trim()
222    else if (COMMAND_OUT.test(text)) {
223      if (efforts.lastCommand === 'effort') efforts.answered = efforts.runs
224      efforts.lastCommand = undefined
225    }
226    return kept
227  }).catch(($, e, next) => next(e))
228}
229