SLOPSHOPPER

coachline

A split-pane panel for Claude Code: this thread's prompts in white, what can be improved in blue, analysed by Claude on your subscription (no API key) together…

newpanepromptprocesstimer
v0.13.0MITupdated 2026-10-09teransarathchandra/coachline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coachline
│ ┃ coachline ✕ › fix the failing auth test and add an audit log call │ ┃ coachline: pane.py gave a bad answer · run │ ┃ /coach doctor ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · coachline
coachline: pane.py gave a bad answer · run /coach doctor
README

coachline

ci license

A side panel for Claude Code that coaches your prompts.

It sits in a split pane next to Claude, shows the prompts of your current conversation, and tells you how each one can be better. It also hands you an enhanced version you can copy and paste. Claude does the analysis through your own subscription: no API key.

  coachline · this thread · 12 prompts                         ● Claude on
 ───────────────────────────────────────────────────────────────────────
 ┃ Your prompt                                  15:28 · ✓ analysed
 ┃ make the shoe site look more premium

 ┃ Improve                                                 frontend
 ┃ • Name the pages you want: home, catalog, product, cart.
 ┃ • use /impeccable: design the interface

 ┃ Enhanced prompt                                          c Copy
 ┃ Create a modern luxury website for selling high-end shoes...
 ───────────────────────────────────────────────────────────────────────
 2 patterns from your past chats · i to read
 ───────────────────────────────────────────────────────────────────────
 Prompts in this thread
 › ✓ 15:28  make the shoe site look more premium
 ↑↓ prompt  c copy  e analyse  Enter full  i patterns  q quit  m mouse

Features

  • Per-prompt coaching: your prompt in white, what to improve in blue, and a complete enhanced prompt with a copy button.
  • Skill suggestions: Claude reads your past chats and spots requests you keep repeating, so you can turn them into a skill.
  • Research for each new task: Claude searches the web for tools, modern approaches, official docs and reference sites that would make the result better (not only what you already have), matched to your project's framework versions. Every link is checked on your machine before you see it.
  • Remembers what it showed you: a suggestion appears with two prompts at most, never again once you hide it or say you use it, and its checked links are added to the enhanced prompt as References, so Claude Code gets them with your task.
  • Keyboard and mouse: arrows or wheel to move, click to select, click c Copy to copy.
  • Opens by itself: on macOS and Linux the panel appears inside Claude Code as soon as you start; nothing to set up.

Requirements

  • Claude Code, signed in with your Claude subscription (the claude command works in your terminal)
  • Python 3.9 or newer (python3 on macOS and Linux, python or py -3 on Windows)
  • Windows, macOS or Linux
  • Claude Code 2.1.287 or newer for the built-in pane (older versions: use /coach watch)

Install

In Claude Code:

/plugin marketplace add teransarathchandra/coachline
/plugin install coachline@coachline

Restart Claude Code.

On macOS and Linux that is all: the panel opens inside Claude Code (from 144 columns at start, otherwise with your first prompt), and Claude analysis and web research are on. Turn either off with /coach auto-open off or /coach panel-ai off.

On Windows, Claude analysis is on as well; run /coach auto-open on to open the panel in a Windows Terminal split on every session (or use the command from /coach watch).

Usage

Keep the panel open next to Claude Code and send prompts as usual. The panel follows the conversation you are in: a new session starts empty, and a resumed one shows its prompts at once.

Keys

KeyAction
↑ ↓ (or j k)Choose a prompt
g / GOldest / newest prompt
PgUp PgDnScroll a long card
EnterOpen the prompt full width (Esc to close)
cCopy the enhanced prompt
eAsk Claude to analyse the selected prompt now
iShow or hide patterns from your past chats
sInstall the skill Claude drafted for a repeated request
xHide a web suggestion for good (asks which one when there are several)
aMark a web suggestion as one you already use, so it is not suggested again
rLook on the web again for the selected prompt (ignores the cache and a usage-limit pause)
mMouse on or off (off lets you select text with the mouse)
qQuit

With the mouse, the wheel scrolls and a click selects a prompt or presses a button.

Status marks

MarkMeaning
✓Analysed by Claude
…Being analysed or queued
!Can be improved (local hint)
×Analysis failed, press e to retry
–Project opted out of Claude
·Nothing to add

Commands

CommandWhat it does
/coachEnhance your last prompt and copy it to the clipboard
/coach watchPrint the command that opens the panel
`/coach panel-ai on\off`Turn Claude analysis on or off (on by default)
/coach reviewAnalyse your whole history for repeated requests and mistakes (shows a plan first, sends nothing until you agree)
/coach discoverResearch the web now for your last prompt (ignores the cache and any pause)
`/coach research on\off`Web research once per new task (on by default; `/coach discover on\off` is the old name)
`/coach model fast\research haiku\sonnet\opus`The model of each pass (defaults: fast haiku, research sonnet)
`/coach auto-open on\off`Open the panel by itself at session start (on by default on macOS and Linux)
/coach setupShow the current settings
/coach doctorCheck that history, Python and the claude CLI work

Privacy

  • Analysis and web research are on by default. Turn them off with /coach panel-ai off (or only research with /coach research off); then only built-in local hints run, on your machine. The panel says so in your first three sessions.
  • With it on, the panel sends Claude your prompts with emails, keys, tokens and secrets removed, plus up to five earlier prompts of the same conversation. Once on the first run, then every ~2 days, it also sends up to 90 days of history for the pattern analysis, with project names replaced by P1, P2.
  • Pasted content is never sent, only the [Pasted text #1 +28 lines] marker.
  • List folder names in ~/.claude/coach/llm-off.txt (one per line) and those projects are never sent.
  • Web research sends a short generic task topic, your project's framework names and major versions (read from package.json, pyproject.toml, requirements.txt, go.mod or Cargo.toml; only well-known public frameworks and libraries, never your own packages) and the names of your installed skills. Never your prompt, code, paths or project name. It runs once per new task; a usage limit pauses it until the reset time.
  • What you hid or use is kept in ~/.claude/coach/suggestions.json, on your machine only.
  • State is kept in ~/.claude/coach/. There is no telemetry.

How it stays accurate

Claude can invent patterns, so its answers are checked on your machine before you see them:

  • Keywords must appear in the prompts Claude cites.
  • Counts are recomputed locally; Claude's own counts are ignored.
  • Skill and plugin names must exist. Every web suggestion needs a live page that names it, GitHub repos must exist and be maintained, and anything made for an older major version than your project uses is dropped.
  • A repeated request needs 3 or more matching prompts, a repeated mistake 2 or more.

This shows that a pattern exists. It does not prove the suggested fix is good, so read it critically.

Limits

  • Claude Code's history.jsonl is not a documented format and may change. /coach doctor tells you if parsing breaks.
  • Only Claude Code prompts are analysed, not claude.ai chats or Claude's replies.
  • Suggestions are only as good as the model.
  • The local hints are five keyword rules, English only.
  • The built-in pane needs Claude Code 2.1.287+. On macOS and Linux the panel no longer splits tmux by itself (the pane replaces it); with an older Claude Code, use /coach watch. The Windows split was tested on Windows Terminal; /coach watch works in any terminal.

Development

python -W error::ResourceWarning -m unittest discover -s tests
python scripts/eval_suggest.py --yes   # live: measures suggestions on 10 fixed prompts (not in CI)

CI runs the tests on Ubuntu, macOS and Windows with Python 3.9 and 3.13.

License

MIT

Source 2 files
hooks/coachline.tsx 281 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PaneEntry, PaneLine, PaneState } from '../types'
5
6const PANE = 'coachline'
7const NO_PYTHON = 'coachline needs Python 3.9+ · run /coach doctor'
8const NO_CLIPBOARD = 'no clipboard here: select the text instead'
9
10const state = atom({ plugin: 'coachline', key: 'state' } as const, null as PaneState | null)
11const error = atom({ plugin: 'coachline', key: 'error' } as const, '')
12const sel = atom({ plugin: 'coachline', key: 'sel' } as const, -1)
13const full = atom({ plugin: 'coachline', key: 'full' } as const, false)
14const showPatterns = atom({ plugin: 'coachline', key: 'showPatterns' } as const, false)
15const closed = atom({ plugin: 'coachline', key: 'closed' } as const, false)
16const msg = atom({ plugin: 'coachline', key: 'msg' } as const, '')
17
18// watch.py's colours, by line kind (see CODE in scripts/watch.py)
19const COLOR: Record<string, string> = {
20  prompt: 'whiteBright', blue: 'blueBright', bluedim: 'blueBright', enh: 'blueBright', task: 'blueBright', meta: 'gray',
21}
22const HEAD_COLOR: Record<string, string> = { prompt: 'whiteBright', improve: 'blueBright', enh: 'blueBright', info: 'blueBright', web: 'green' }
23
24// Background work can outlive the module (a reload, the session ending): nothing is left to report to then.
25const ignore = () => undefined
26
27type Run = { ok: true; out: string } | { ok: false; err: string }
28
29let active = false          // an interactive session on macOS / Linux; false under `claude -p`, the SDK and on Windows
30let py: string | null = null
31let pyChecked = false         // asked once: on a Mac without developer tools, each `python3` call can raise the install dialog
32let inflight: Promise<PaneState | null> | null = null
33let again = false
34let width = 80
35let poll: { cancel: () => void } | null = null
36
37async function python($: EngineInterface): Promise<string | null> {
38  if (pyChecked) return py
39  pyChecked = true
40  for (const name of ['python3', 'python']) {
41    try {
42      const r = await $.process.run([name, '--version'], { timeoutMs: 5000 })
43      if (r.exitCode === 0) return (py = name)
44    } catch {
45      // not on PATH: try the next name
46    }
47  }
48  return null
49}
50
51async function run($: EngineInterface, args: string[]): Promise<Run> {
52  const p = await python($)
53  if (!p) return { ok: false, err: NO_PYTHON }
54  try {
55    const r = await $.process.run([p, `${$.plugin.root}/scripts/pane.py`, ...args], { timeoutMs: 20000 })
56    if (r.exitCode !== 0) {
57      const last = r.stderr.trim().split('\n').pop() || `pane.py exited ${r.exitCode}`
58      return { ok: false, err: `coachline: ${last} · run /coach doctor` }
59    }
60    return { ok: true, out: r.stdout }
61  } catch (x) {
62    return { ok: false, err: `coachline: ${String(x)} · run /coach doctor` }
63  }
64}
65
66// One pane.py at a time: a refresh asked for while one runs is folded into a single rerun when it ends.
67async function refresh($: EngineInterface): Promise<PaneState | null> {
68  if (inflight) {
69    again = true
70    return inflight
71  }
72  inflight = (async () => {
73    let s: PaneState | null = null
74    do {
75      again = false
76      s = await refreshOnce($)
77    } while (again)
78    return s
79  })()
80  try {
81    return await inflight
82  } finally {
83    inflight = null
84  }
85}
86
87async function refreshOnce($: EngineInterface): Promise<PaneState | null> {
88  const r = await run($, ['--json', '--session', await $.session.id(), '--width', String(width)])
89  let s: PaneState | null = null
90  if (r.ok) {
91    try {
92      s = JSON.parse(r.out) as PaneState
93    } catch {
94      await update($, error, () => 'coachline: pane.py gave a bad answer · run /coach doctor')
95      return null
96    }
97  } else {
98    await update($, error, () => r.err)       // the last good state stays
99    return null
100  }
101  await update($, error, () => '')
102  await update($, state, () => s)
103  const busy = s.entries.some(e => e.state === 'pending')
104  if (busy && !poll) poll = $.clock.every(3000, () => void refresh($).catch(ignore))
105  if (!busy && poll) {
106    poll.cancel()
107    poll = null
108  }
109  return s
110}
111
112// Opens when the last answer says auto-open is on, or when there is no answer at all (no Python, pane.py failing): the pane then
113// shows why, and the person can close it.
114async function openIfWanted($: EngineInterface, s: PaneState | null): Promise<void> {
115  if (s ? s.auto_open : (await read($, state)) === null) await open($)
116}
117
118async function startUp($: EngineInterface): Promise<void> {
119  await openIfWanted($, await refresh($))
120}
121
122async function open($: EngineInterface): Promise<void> {
123  if (await read($, closed)) return
124  const panes = await $.ui.panes()
125  if (panes.some((p: { id: string; isPlaced: boolean }) => p.id === PANE && p.isPlaced)) return
126  await $.ui.open({ id: PANE, title: 'coachline' })
127}
128
129export const register: Register = on => {
130  on('session.start', async ($, e, next) => {
131    const r = await next(e)
132    active = e.isInteractive && (await $.env.get('OS')) !== 'Windows_NT'
133    if (!active) return r
134    void startUp($).catch(ignore)              // the first prompt never waits for pane.py
135    return r
136  })
137
138  on('prompt.submit', async ($, e, next) => {
139    const r = await next(e)
140    if (!active) return r
141    await update($, sel, () => -1)             // follow the newest prompt
142    let s = await read($, state)
143    if (!s && inflight) s = await inflight     // the session start's first answer, if it is still coming
144    await openIfWanted($, s)                   // from a prompt the person entered: placed at any width
145    void refresh($).catch(ignore)
146    return r
147  })
148
149  on('turn.complete', async ($, e, next) => {
150    const r = await next(e)
151    if (active) await refresh($)
152    return r
153  })
154
155  on('ui.close', async ($, e, next) => {
156    if (e.id === PANE && e.origin.kind === 'person') await update($, closed, () => true)
157    return next(e)
158  })
159
160  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
161    const { Box, Button, Text } = $.ui.resolve(e)
162    width = e.viewport?.columns ?? width
163    const s = await read($, state)
164    const err = await read($, error)
165    const note = await read($, msg)
166    if (!s) return <Text dimColor>{err || 'coachline: loading…'}</Text>
167
168    const header = (
169      <Box key="header">
170        <Text bold>coachline · this thread · {s.entries.length} prompt{s.entries.length === 1 ? '' : 's'}</Text>
171        <Text>{'  '}</Text>
172        <Text color={s.ai ? 'green' : 'gray'}>{s.ai ? '● Claude on' : '○ Claude off'}</Text>
173      </Box>
174    )
175    const footer = [
176      err ? <Text key="err" color="red">{err}</Text> : null,
177      note ? <Text key="msg" dimColor>{note}</Text> : null,
178      s.notice && !s.entries.length ? <Text key="notice" dimColor>{s.notice}</Text> : null,
179    ]
180    if (!s.entries.length) {
181      return (
182        <Box flexDirection="column">
183          {header}
184          <Text dimColor>Send a prompt: coachline coaches it here.</Text>
185          {footer}
186        </Box>
187      )
188    }
189
190    const at = await read($, sel)
191    const i = at < 0 || at >= s.entries.length ? s.entries.length - 1 : at
192    const cur: PaneEntry | undefined = s.entries[i]
193    if (!cur) return <Text dimColor>coachline: loading…</Text>
194    const isFull = await read($, full)
195    const pats = await read($, showPatterns)
196
197    const copy = async () => {
198      const r = await $.ui.copy({ text: cur.after, surface: e.surface })
199      await update($, msg, () => (r.isCopied ? `copied the enhanced prompt (${cur.after.length} characters)` : NO_CLIPBOARD))
200    }
201    const analyse = async () => {
202      const r = await run($, s.session ? ['--enhance', cur.key, '--session', s.session] : ['--enhance', cur.key])
203      await update($, msg, () => (r.ok ? r.out.trim() : r.err))
204      await refresh($)
205    }
206    const mark = async (status: 'dismissed' | 'adopted', id: string) => {
207      const r = await run($, ['--mark', status, id])
208      await update($, msg, () => (r.ok ? r.out.trim() : r.err))
209      await refresh($)
210    }
211    const research = async () => {
212      const r = await run($, s.session ? ['--research', cur.key, '--session', s.session] : ['--research', cur.key])
213      await update($, msg, () => (r.ok ? r.out.trim() : r.err))
214    }
215    const install = async () => {
216      if (!s.skill) return
217      const r = await run($, ['--install', s.skill])
218      await update($, msg, () => (r.ok ? r.out.trim() : r.err))
219    }
220
221    const line = (l: PaneLine, n: number) => {
222      const [section, kind, text, right, action] = l
223      if (kind === 'blank') return <Text key={`l${n}`}> </Text>
224      if (kind === 'head') {
225        return (
226          // the label or button sits right after the title, never against the pane's right edge, where the pane's own close mark is
227          <Box key={`l${n}`}>
228            <Text bold color={HEAD_COLOR[section] ?? 'blueBright'}>{text}</Text>
229            <Text>{'  '}</Text>
230            {action === 'copy' ? <Button key="copy" label="Copy" onPress={copy} />
231              : right ? <Text dimColor>{right}</Text> : null}
232          </Box>
233        )
234      }
235      if (kind === 'itemacts' && action?.startsWith('item:')) {
236        const id = action.slice('item:'.length)
237        return (
238          <Box key={`l${n}`}>
239            <Text>{'  '}</Text>
240            <Button key={`hide:${id}`} label="Hide" onPress={() => mark('dismissed', id)} />
241            <Text>{'  '}</Text>
242            <Button key={`use:${id}`} label="I use this" onPress={() => mark('adopted', id)} />
243          </Box>
244        )
245      }
246      if (action === 'enter') return <Button key="whole" label="Show the whole prompt" onPress={() => update($, full, () => true)} />
247      return <Text key={`l${n}`} color={COLOR[kind]} dimColor={kind === 'bluedim' || kind === 'meta'} bold={kind === 'prompt'}>{text}</Text>
248    }
249
250    const card = isFull
251      ? [<Text key="fullhead" bold>Your prompt</Text>, <Text key="fulltext" color="whiteBright">{cur.text}</Text>,
252         <Button key="less" label="Show less" onPress={() => update($, full, () => false)} />]
253      : cur.lines.map(line)
254    // watch.py's card names keys ("press e"); here one Analyse button stands for them, whatever the prompt's state
255    const canAnalyse = !cur.after && cur.state !== 'off'
256
257    return (
258      <Box flexDirection="column">
259        {header}
260        <Box flexDirection="column" marginY={1}>{card}</Box>
261        {canAnalyse && <Button key="analyse" label={cur.state === 'pending' ? 'Analyse again' : 'Analyse'} onPress={analyse} />}
262        {s.research && !!cur.after && cur.state !== 'off' && <Button key="research" label="Look again on the web" onPress={research} />}
263        {s.patterns.length > 0 && (
264          <Box flexDirection="column">
265            <Button key="patterns" label={pats ? 'Hide patterns' : `${s.patterns.length} pattern${s.patterns.length === 1 ? '' : 's'} from your past chats`}
266              onPress={() => update($, showPatterns, v => !v)} />
267            {pats && s.patterns.map((p, n) => <Text key={`p${n}`} color="blueBright">{p}</Text>)}
268            {s.skill && <Button key="skill" label={`Install the drafted skill /${s.skill}`} onPress={install} />}
269          </Box>
270        )}
271        <Text dimColor>Prompts in this thread</Text>
272        {s.entries.map((x, n) => (
273          <Button key={`e${n}`} label={`${n === i ? '›' : ' '} ${x.glyph} ${x.stamp}  ${x.text.replace(/\s+/g, ' ').slice(0, Math.max(10, width - 16))}`}
274            onPress={() => update($, sel, () => n)} />
275        ))}
276        {footer}
277      </Box>
278    )
279  })
280}
281
types/index.d.ts 38 lines
1export type PaneLine = [section: string, kind: string, text: string, right: string, action: string | null]
2
3export type PaneEntry = {
4  key: string
5  stamp: string
6  glyph: string
7  state: string
8  words: string
9  text: string
10  after: string
11  lines: PaneLine[]
12}
13
14export type PaneState = {
15  ai: boolean
16  research: boolean
17  auto_open: boolean
18  session: string | null
19  entries: PaneEntry[]
20  patterns: string[]
21  skill: string | null
22  notice: string | null
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    coachline: {
28      state: PaneState | null
29      error: string
30      sel: number
31      full: boolean
32      showPatterns: boolean
33      closed: boolean
34      msg: string
35    }
36  }
37}
38