Shows what Claude is doing and why above the prompt, during long turns and after they end.

During a long turn it can be hard to tell what Claude is doing, and easy to forget what you asked for in the first place. Glance is a Claude Code mod that shows both, in two rows above the prompt. Once a turn has run for 20 seconds, they say in plain language what Claude is working on and why:
Now: Checking that the new release installs and works on both computers
Why: You want to be sure it is ready before sharing it
When the turn ends, the last summary stays there until you send your next message, so you can see at a glance what produced the turn:

The summaries are written by Claude Haiku, for someone who is keeping an eye on the work rather than following each step, so they describe the goal rather than the individual files and commands.
Claude Code, in the terminal or the desktop app's Code tab. Glance is written against Claude Code's mod API, which is in early access and can change between releases. It was tested with Claude Code 2.1.292.
claude plugin marketplace add wmayner/claude-glance
claude plugin install glance@glance
Or, at the prompt of a running session:
/plugin install glance --marketplace wmayner/claude-glance
Installed with /plugin, it starts working in that session right away. Either way, it runs in every session you start afterwards.
Only the main conversation is summarized. The spinner is left as Claude Code draws it.
/glance asks for a summary now, even during a short turn./glance off hides the summaries and stops the Haiku calls, in this session and the ones after it./glance on turns them back on.Each summary is one Haiku request of a few thousand input tokens, made with the same account and provider as the rest of your session. The excerpts of your conversation listed above are sent with each request; nothing is sent anywhere else.
claude plugin update glance@glance
claude plugin uninstall glance@glance
Restart Claude Code after an update, or run /reload-plugins.
Run a session with your working copy loaded:
claude --plugin-dir /path/to/claude-glance
Saving a file reloads the mod at the end of the current turn. To check the manifest and run the tests:
claude plugin validate .
claude plugin test .
hooks/register.tsx registers the hooks, and hooks/logic.ts has the timing rules, the text sent to Haiku and the formatting, so most changes only touch the second file. Claude Code writes the API's type declarations into .claude-plugin/types/ the first time it loads the mod; after that, npx tsc -p . type-checks it.
The idea for glance came from Niall Dalton (@ndalton12). His own version, now-doing, also gave glance its plain-language summaries, the /glance command and the removal of injected text from your requests.
MIT
hooks/register.tsx 118 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { buildDigest, isDue, newTurn, parseReply, stripInjected, SYSTEM, TICK_MS } from './logic'
5import type { Turn } from './logic'
6import type { Summary } from '../types'
7
8const summary = atom({ plugin: 'glance', key: 'summary' } as const, null as Summary | null)
9const isOff = atom({ plugin: 'glance', key: 'isOff' } as const, false)
10
11export const register: Register = on => {
12 // Module state is lost on a hot reload; the next turn.start rebuilds it.
13 let turn: Turn | null = null
14 let isBusy = false
15 let isForced = false // /glance asked for a summary now
16
17 on('session.start', async ($, e, next) => {
18 await $.command.register({
19 name: 'glance',
20 description: 'Summarize what Claude is doing now; `off` hides the summaries, `on` shows them again',
21 })
22 const stored = (await $.store.get('isOff')) === true
23 await update($, isOff, () => stored)
24
25 $.clock.every(TICK_MS, async () => {
26 if (turn === null || isBusy || (await read($, isOff))) return
27 if (!isForced && !isDue(turn, await $.clock.now())) return
28 const t = turn
29 isBusy = true
30 isForced = false
31 // Count the attempt whether or not it succeeds, so a failing model is retried
32 // at the normal pace and an ended turn is not retried at all.
33 t.summarizedActivity = t.activity
34 t.summarizedAt = await $.clock.now()
35 try {
36 const messages = await $.session.messages()
37 if (!Array.isArray(messages)) return
38 const reply = await $.model.complete({
39 model: 'haiku',
40 system: SYSTEM,
41 prompt: buildDigest(messages, t.request, await read($, summary), !t.running),
42 effort: 'low',
43 maxTokens: 200,
44 timeoutMs: 15_000,
45 })
46 if (!reply.isAnswered) throw new Error(reply.reason)
47 const fresh = parseReply(reply.text)
48 if (turn !== t || fresh === null) return // a new turn started while Haiku was answering
49 await update($, summary, () => fresh)
50 } catch (error) {
51 $.ui.log(`glance: no summary (${error instanceof Error ? error.message : error})`, { to: 'debug' })
52 } finally {
53 isBusy = false
54 }
55 })
56 return next(e)
57 })
58
59 on('turn.start', async ($, e, next) => {
60 // A turn started without a typed prompt continues the work before it.
61 if (e.text === '' && turn !== null) {
62 turn.running = true
63 return next(e)
64 }
65 turn = newTurn(stripInjected(e.text), await $.clock.now())
66 await update($, summary, () => null)
67 return next(e)
68 })
69
70 on('tool.call', ($, e, next) => {
71 if (turn?.running) turn.activity += 1
72 return next(e)
73 }).catch(($, e, next) => next(e))
74
75 on('turn.complete', async ($, e, next) => {
76 if (e.agentId === undefined && turn !== null) {
77 turn.running = false
78 turn.endedAt = await $.clock.now()
79 }
80 return next(e)
81 })
82
83 on('command.run', { command: 'glance' }, async ($, e) => {
84 const arg = e.args.trim().toLowerCase()
85 if (arg === 'off' || arg === 'on') {
86 await $.store.set('isOff', arg === 'off')
87 await update($, isOff, () => arg === 'off')
88 return { text: arg === 'off' ? 'Glance is off. `/glance on` turns it back on.' : 'Glance is on.' }
89 }
90 if (await read($, isOff)) return { text: 'Glance is off. `/glance on` turns it back on.' }
91 if (turn === null) return { text: 'Nothing to summarize yet.' }
92 isForced = true
93 return { text: 'Summarizing; the summary appears above the prompt in a few seconds.' }
94 })
95
96 // The band above the prompt, during the turn and after it. The spinner is left
97 // to the engine: its own text after the word changes length (hook status,
98 // effort), so a summary there would wrap the row on some frames.
99 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100 if (e.props.hasSurvey || (await read($, isOff))) return next(e)
101 const current = await read($, summary)
102 if (current === null) return next(e)
103 const { Box, Text } = $.ui.resolve(e)
104 return (
105 <Box flexDirection="column">
106 <Text dimColor wrap="truncate-end">
107 <Text bold>{e.props.isWorking ? 'Now:' : 'Last turn:'}</Text> {current.doing}
108 </Text>
109 {current.why !== '' && (
110 <Text dimColor wrap="truncate-end">
111 <Text bold>Why:</Text> {current.why}
112 </Text>
113 )}
114 </Box>
115 )
116 })
117}
118hooks/logic.ts 118 lines1import type { SessionMessage, ToolUseSummary } from 'claude-code'
2
3import type { Summary } from '../types'
4
5export const MIN_MS = 20_000 // turns shorter than this get no summary unless asked with /glance
6export const EVERY_MS = 60_000 // at most one refresh a minute
7export const TICK_MS = 5_000
8
9export type Turn = {
10 request: string // what the user typed to start the turn, injected text removed
11 running: boolean
12 startedAt: number
13 endedAt: number
14 activity: number // bumped by each tool call; starts at 1 so a tool-less turn still gets a summary
15 summarizedActivity: number
16 summarizedAt: number
17}
18
19export const newTurn = (request: string, now: number): Turn => ({
20 request,
21 running: true,
22 startedAt: now,
23 endedAt: 0,
24 activity: 1,
25 summarizedActivity: 0,
26 summarizedAt: -Infinity,
27})
28
29export function isDue(t: Turn, now: number): boolean {
30 if (!t.running) {
31 // A turn that ran long but ended before its first summary still gets one, for the band.
32 return t.endedAt - t.startedAt >= MIN_MS && t.summarizedActivity === 0
33 }
34 return (
35 now - t.startedAt >= MIN_MS &&
36 t.activity > t.summarizedActivity &&
37 now - t.summarizedAt >= EVERY_MS
38 )
39}
40
41export const SYSTEM = `You write a short status for someone who is supervising an AI coding agent but not following the details. From the excerpt of their session, say at a high level and in plain everyday language what the agent is working on and why.
42
43- "doing": what it is working on, in at most 15 words. Describe the goal, not the individual commands or files.
44- "why": the reason, tied to what the user asked for, in at most 15 words.
45- Leave out jargon, file paths, IDs, tool names and internal names, unless the user used them.
46- If the agent has finished, or is waiting for the user, say so.
47- If a previous status is given, keep its "why" unless the goal has changed.
48
49Reply with JSON only: {"doing": "...", "why": "..."}`
50
51/** One line of at most `limit` characters, whitespace collapsed. */
52export function clip(text: string, limit: number): string {
53 const flat = text.replace(/\s+/g, ' ').trim()
54 return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`
55}
56
57/** A user message without the text Claude Code adds to it: reminders and slash-command wrappers. */
58export function stripInjected(text: string): string {
59 return text
60 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
61 .replace(/<((?:local-)?command-[a-z-]+)>[\s\S]*?<\/\1>/g, '')
62 .trim()
63}
64
65const ARG_KEYS = ['description', 'prompt', 'command', 'file_path', 'pattern', 'url', 'query', 'skill']
66
67function describeCall(use: ToolUseSummary): string {
68 const hint = ARG_KEYS.map(k => use.input[k]).find((v): v is string => typeof v === 'string' && v !== '')
69 const status = use.text === undefined ? ' (running)' : use.isError ? ' (failed)' : ''
70 return `- ${use.tool}${hint ? `: ${clip(hint, 140)}` : ''}${status}`
71}
72
73/**
74 * The excerpt Haiku summarizes: the request that started the turn, the user's
75 * earlier requests, what the agent said recently and its latest tool calls.
76 */
77export function buildDigest(
78 messages: readonly SessionMessage[],
79 request: string,
80 previous: Summary | null,
81 isFinished: boolean,
82): string {
83 const asks = messages
84 .filter(m => m.role === 'user' && !m.toolResults?.length)
85 .map(m => stripInjected(m.text))
86 .filter(text => text !== '')
87 const current = request || asks.at(-1) || ''
88 const earlier = asks.filter(text => text !== current).slice(-2)
89 const assistant = messages.filter(m => m.role === 'assistant')
90 const notes = assistant.map(m => m.text.trim()).filter(text => text !== '').slice(-4)
91 const calls = assistant.flatMap(m => m.toolUses).slice(-15)
92
93 const parts = [`## The user's current request\n${clip(current, 1500) || '(none)'}`]
94 if (earlier.length) parts.push(`## Earlier requests, oldest first\n${earlier.map(a => `- ${clip(a, 500)}`).join('\n')}`)
95 if (notes.length) parts.push(`## What the agent said recently, oldest first\n${notes.map(n => `- ${clip(n, 400)}`).join('\n')}`)
96 if (calls.length) parts.push(`## Its latest actions, oldest first\n${calls.map(describeCall).join('\n')}`)
97 if (isFinished) parts.push('The agent has finished this turn and is waiting for the user.')
98 if (previous) parts.push(`## Previous status\nDoing: ${previous.doing}\nWhy: ${previous.why}`)
99 return parts.join('\n\n')
100}
101
102/** The reply as `{ doing, why }`; a reply that is not JSON is read as the "doing" half. */
103export function parseReply(text: string): Summary | null {
104 const json = text.match(/\{[\s\S]*\}/)?.[0]
105 if (json) {
106 try {
107 const { doing, why } = JSON.parse(json) as Record<string, unknown>
108 if (typeof doing === 'string' && doing.trim() !== '') {
109 return { doing: clip(doing, 160), why: typeof why === 'string' ? clip(why, 160) : '' }
110 }
111 } catch {
112 // Not JSON after all; read it as plain text below.
113 }
114 }
115 const plain = clip(text.trim().split('\n')[0] ?? '', 160)
116 return plain === '' ? null : { doing: plain, why: '' }
117}
118types/index.d.ts 8 lines1export type Summary = { doing: string; why: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 glance: { summary: Summary | null; isOff: boolean }
6 }
7}
8