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…

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
c Copy to copy.claude command works in your terminal)python3 on macOS and Linux, python or py -3 on Windows)/coach watch)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).
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.
| Key | Action |
|---|---|
↑ ↓ (or j k) | Choose a prompt |
g / G | Oldest / newest prompt |
PgUp PgDn | Scroll a long card |
Enter | Open the prompt full width (Esc to close) |
c | Copy the enhanced prompt |
e | Ask Claude to analyse the selected prompt now |
i | Show or hide patterns from your past chats |
s | Install the skill Claude drafted for a repeated request |
x | Hide a web suggestion for good (asks which one when there are several) |
a | Mark a web suggestion as one you already use, so it is not suggested again |
r | Look on the web again for the selected prompt (ignores the cache and a usage-limit pause) |
m | Mouse on or off (off lets you select text with the mouse) |
q | Quit |
With the mouse, the wheel scrolls and a click selects a prompt or presses a button.
| Mark | Meaning |
|---|---|
| ✓ | 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 |
| Command | What it does | |||
|---|---|---|---|---|
/coach | Enhance your last prompt and copy it to the clipboard | |||
/coach watch | Print the command that opens the panel | |||
| `/coach panel-ai on\ | off` | Turn Claude analysis on or off (on by default) | ||
/coach review | Analyse your whole history for repeated requests and mistakes (shows a plan first, sends nothing until you agree) | |||
/coach discover | Research 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 setup | Show the current settings | |||
/coach doctor | Check that history, Python and the claude CLI work |
/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.P1, P2.[Pasted text #1 +28 lines] marker.~/.claude/coach/llm-off.txt (one per line) and those projects are never sent.~/.claude/coach/suggestions.json, on your machine only.~/.claude/coach/. There is no telemetry.Claude can invent patterns, so its answers are checked on your machine before you see them:
This shows that a pattern exists. It does not prove the suggested fix is good, so read it critically.
history.jsonl is not a documented format and may change. /coach doctor tells you if parsing breaks./coach watch. The Windows split was tested on Windows Terminal; /coach watch works in any terminal.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.
hooks/coachline.tsx 281 lines1import { 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}
281types/index.d.ts 38 lines1export 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