SLOPSHOPPER

glance

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

newbandguardcommandmodeltimer
v0.3.0MITupdated 2026-10-07wmayner/claude-glance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glance
› fix the failing auth test and add an audit log call ⏺ 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 › /glance ⎿ glance: Nothing to summarize yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

glance

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:

Above the prompt after the turn: "Last turn: Testing glance spinner on mats-dev for 20s" and "Why: to verify time display and animation don't interfere with rendering"

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.

Requirements

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.

Installation

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.

How it works

  • A turn shorter than 20 seconds is left alone and costs nothing.
  • In a longer turn, Glance asks Haiku for a new summary at most once a minute, and only when Claude has called a tool since the last one.
  • Haiku sees the request that started the turn, your two requests before it (so that "yes, do that" still has its context), Claude's last four messages and its last fifteen tool calls, each marked if it is still running or failed. Reminders and other text that Claude Code adds to your messages are removed first. Haiku also sees the previous summary, so the reason stays the same from one update to the next unless the goal changes.
  • If a Haiku call fails, the previous summary stays where it is, and the failure is written to the debug log.

Only the main conversation is summarized. The spinner is left as Claude Code draws it.

The /glance command

  • /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.

Cost and privacy

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.

Updating and uninstalling

claude plugin update glance@glance
claude plugin uninstall glance@glance

Restart Claude Code after an update, or run /reload-plugins.

Development

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.

Credits

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.

License

MIT

Source 3 files
hooks/register.tsx 118 lines
1import { 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}
118
hooks/logic.ts 118 lines
1import 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}
118
types/index.d.ts 8 lines
1export type Summary = { doing: string; why: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    glance: { summary: Summary | null; isOff: boolean }
6  }
7}
8