A Claude Code pane showing the model, thinking level, context, usage limits, git state, PR, running agents and your last three prompts.

A Claude Code mod that keeps a pane open beside the conversation, showing:
BL- branch, and the branch's pull request with its status (draft, open, merged, closed)The pane docks beside the transcript in Claude Code's fullscreen layout when the terminal is at least 110 columns wide; otherwise it sits above the prompt. It opens itself when a session starts on a terminal at least 144 columns wide. Type /awareness to open it at any width.
The pull request comes from the GitHub CLI (gh), so gh must be installed and signed in for that part to show.
In a Claude Code terminal session:
/plugin install awareness --marketplace hatton/awareness
Answer y to add the marketplace, then choose the user scope so it loads in every session.
claude --plugin-dir <path to this folder>
claude plugin validate <path to this folder>
claude plugin test <path to this folder>
MIT
hooks/register.tsx 527 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Header, PullRequest, Recent, Repo, RunningAgent, WorkingOn } from '../types'
5
6const SHOWN = 3
7const PANE = 'awareness'
8const TITLE = 'Awareness'
9// Above this share of the context window, the figure turns red.
10const CONTEXT_WARNING_PERCENT = 50
11// Above this share of a usage window, the figure turns red.
12const LIMIT_WARNING_PERCENT = 80
13// How often git, agents and usage are read again, for changes made outside a turn.
14const REFRESH_MS = 10_000
15// The pull request is read over the network, so less often.
16const PULL_REQUEST_REFRESH_MS = 60_000
17const YOUTRACK_ISSUE_URL = 'https://issues.bloomlibrary.org/youtrack/issue/'
18
19// Oldest first, newest last; at most SHOWN entries.
20const recent = atom({ plugin: 'awareness', key: 'recent' } as const, [] as Recent)
21const header = atom({ plugin: 'awareness', key: 'header' } as const, {
22 model: null,
23 effort: null,
24 contextPercent: null,
25 limits: [],
26} as Header)
27const repo = atom({ plugin: 'awareness', key: 'repo' } as const, null as Repo)
28const pullRequest = atom({ plugin: 'awareness', key: 'pullRequest' } as const, null as PullRequest)
29const agents = atom({ plugin: 'awareness', key: 'agents' } as const, [] as readonly RunningAgent[])
30const NOTHING_YET: WorkingOn = { skill: null, tasks: {}, todo: null }
31const workingOn = atom({ plugin: 'awareness', key: 'workingOn' } as const, NOTHING_YET)
32
33/** Collapses a prompt's whitespace so blank lines in it don't stretch the pane. */
34const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
35
36/**
37 * Turns a skill's name into a title: `preflight` reads `Preflight`,
38 * `youtrack-fix` `Youtrack fix`, and a plugin's `vercel:deploy` `Deploy`.
39 */
40export const skillTitle = (name: string) => {
41 const words = (name.split(':').pop() ?? name).replace(/[-_]+/g, ' ').trim()
42 return words.charAt(0).toUpperCase() + words.slice(1)
43}
44
45/**
46 * Lowers the first letter of a step phrase so it reads after a comma
47 * (`Fixing Devin bugs` reads `fixing Devin bugs`), but leaves a word in
48 * capitals alone (`CI`, `PR`).
49 */
50const lowerFirst = (phrase: string) =>
51 /^[A-Z][a-z]/.test(phrase) ? phrase.charAt(0).toLowerCase() + phrase.slice(1) : phrase
52
53/**
54 * Builds the "Working on:" line: the skill, then the step in progress; null
55 * when neither is known. The step is the in-progress task's phrase, else the
56 * in-progress item of the last `TodoWrite` list.
57 */
58export const workingOnText = (state: WorkingOn) => {
59 const task = Object.values(state.tasks).find(t => t.status === 'in_progress')
60 const step = task?.doing ?? state.todo
61 if (state.skill !== null && step !== null) {
62 return `Working on: ${state.skill}, ${lowerFirst(step)}`
63 }
64 if (state.skill !== null) {
65 return `Working on: ${state.skill}`
66 }
67 if (step !== null) {
68 return `Working on: ${step}`
69 }
70 return null
71}
72
73/**
74 * The skill a prompt starts, when it is a slash command: `/preflight thorough`
75 * names `preflight`, as does the `<command-name>/preflight</command-name>`
76 * form the prompt takes once Claude Code expands it.
77 */
78export const typedSkillName = (text: string) =>
79 /<command-name>\/?([\w:.-]+)<\/command-name>/.exec(text)?.[1] ?? /^\/([\w:.-]+)/.exec(text.trim())?.[1]
80
81/**
82 * Turns a model id such as `claude-opus-5-5[1m]` into `Opus 5.5 (1M context)`.
83 * Anything not spelled that way is shown as given.
84 */
85export const friendlyModelName = (model: string) => {
86 const match = /^claude-([a-z]+)-(\d+(?:-\d{1,2})*)(?:-\d{8})?(\[1m\])?$/i.exec(model)
87 if (!match) {
88 return model
89 }
90 const [, family = '', version = '', isLongContext] = match
91 const name = `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.replace(/-/g, '.')}`
92 return isLongContext ? `${name} (1M context)` : name
93}
94
95/** Names a usage window the way people say it. */
96const limitLabel = (kind: string) => {
97 switch (kind) {
98 case 'five_hour':
99 return '5-hour'
100 case 'seven_day':
101 return 'week'
102 case 'spend_limit':
103 return 'spend'
104 default:
105 return kind
106 }
107}
108
109/**
110 * Reads `git status --porcelain=v2 --branch` output into the repo's state.
111 * A detached HEAD shows as `(detached)`.
112 */
113export const parseGitStatus = (stdout: string, worktree: string): NonNullable<Repo> => {
114 let branch = '(detached)'
115 let unpushedCommits: number | null = null
116 let changedFiles = 0
117 for (const line of stdout.split(/\r?\n/)) {
118 if (line.startsWith('# branch.head ')) {
119 branch = line.slice('# branch.head '.length).trim()
120 } else if (line.startsWith('# branch.ab ')) {
121 const ahead = /\+(\d+)/.exec(line)
122 unpushedCommits = ahead ? Number(ahead[1]) : 0
123 } else if (line !== '' && !line.startsWith('#')) {
124 changedFiles += 1
125 }
126 }
127 return { branch, worktree, changedFiles, unpushedCommits }
128}
129
130/** Turns a folder path such as `D:\work` into a `file:///D:/work` URL that opens it. */
131export const folderUrl = (path: string) => `file:///${path.replace(/\\/g, '/').replace(/^\/+/, '')}`
132
133/** Describes the uncommitted files for the pane. */
134const uncommittedText = (count: number) => {
135 if (count === 0) {
136 return 'Nothing uncommitted'
137 }
138 return `${count} ${count === 1 ? 'file' : 'files'} uncommitted`
139}
140
141/** Describes the unpushed commits for the pane. */
142const unpushedText = (count: number | null) => {
143 if (count === null) {
144 return 'Branch not pushed (no upstream)'
145 }
146 if (count === 0) {
147 return 'Nothing unpushed'
148 }
149 return `${count} ${count === 1 ? 'commit' : 'commits'} not pushed`
150}
151
152/** Reads the model, the context window's fill and the usage windows into the header. */
153const refreshHeader = async ($: EngineInterface) => {
154 const [model, usage] = await Promise.all([$.session.model(), $.session.usage()])
155 await update($, header, h => ({
156 ...h,
157 model: friendlyModelName(model),
158 contextPercent: usage.context.percent ?? h.contextPercent,
159 limits: usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed })),
160 }))
161}
162
163/** Reads the thinking level from settings, for before the first request reports it. */
164const readEffortSetting = async ($: EngineInterface) => {
165 const { effortLevel } = await $.settings.read()
166 if (effortLevel === undefined) {
167 return
168 }
169 await update($, header, h => ({ ...h, effort: String(effortLevel) }))
170}
171
172/** Reads the worktree's branch, uncommitted files and unpushed commits. */
173const refreshRepo = async ($: EngineInterface) => {
174 const worktree = await $.session.cwd()
175 const { exitCode, stdout } = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], {
176 timeoutMs: 10_000,
177 })
178 const state: Repo = exitCode === 0 ? parseGitStatus(stdout, worktree) : null
179 await update($, repo, () => state)
180}
181
182/**
183 * Turns a GitHub pull request URL such as
184 * `https://github.com/BloomBooks/BloomDesktop/pull/8315` into its Reviewable
185 * page, `https://reviewable.io/reviews/BloomBooks/BloomDesktop/8315`.
186 */
187export const reviewableUrl = (pullRequestUrl: string) =>
188 pullRequestUrl.replace(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+).*$/, 'https://reviewable.io/reviews/$1/$2/$3')
189
190/**
191 * Reads `gh pr view --json number,url,state,isDraft` output into the pull
192 * request's state: a draft reads `draft`, otherwise the state in lower case.
193 */
194export const parsePullRequest = (stdout: string): NonNullable<PullRequest> => {
195 const pr = JSON.parse(stdout) as { number: number; url: string; state: string; isDraft: boolean }
196 const status = pr.isDraft && pr.state === 'OPEN' ? 'draft' : pr.state.toLowerCase()
197 return { number: pr.number, url: pr.url, status }
198}
199
200/** Reads the current branch's pull request through the GitHub CLI. */
201const refreshPullRequest = async ($: EngineInterface) => {
202 const { exitCode, stdout } = await $.process.run(
203 ['gh', 'pr', 'view', '--json', 'number,url,state,isDraft'],
204 { timeoutMs: 20_000 },
205 )
206 const state: PullRequest = exitCode === 0 ? parsePullRequest(stdout) : null
207 await update($, pullRequest, () => state)
208}
209
210/** Reads which subagents and teammates are still at work. */
211const refreshAgents = async ($: EngineInterface) => {
212 const list = await $.agent.list()
213 const running = list
214 .filter(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting')
215 .map(a => ({ id: a.id, description: a.description, type: a.type }))
216 await update($, agents, () => running)
217}
218
219/**
220 * Applies a `TaskUpdate` to the tracked task list: a new status or phrase, or
221 * the task's removal when it is deleted. An id the list never saw (one
222 * created before the list was last cleared) is added only when the update
223 * names what it does; otherwise the list is left as it was.
224 */
225export const updatedTasks = (
226 tasks: WorkingOn['tasks'],
227 change: { taskId: string; status?: string; activeForm?: string; subject?: string },
228): WorkingOn['tasks'] => {
229 if (change.status === 'deleted') {
230 const { [change.taskId]: _removed, ...rest } = tasks
231 return rest
232 }
233 const known = tasks[change.taskId]
234 const named = change.activeForm ?? change.subject
235 if (known === undefined && named === undefined) {
236 return tasks
237 }
238 const before = known ?? { doing: named ?? '', status: 'pending' as const }
239 const status =
240 change.status === 'in_progress' || change.status === 'completed' || change.status === 'pending'
241 ? change.status
242 : before.status
243 return {
244 ...tasks,
245 [change.taskId]: { doing: change.activeForm ?? change.subject ?? before.doing, status },
246 }
247}
248
249/** Takes the activity from a prompt that runs a skill as a slash command. */
250const noteTypedSkill = async ($: EngineInterface, text: string) => {
251 const name = typedSkillName(text)
252 if (name === undefined) {
253 return
254 }
255 const command = (await $.command.list()).find(c => c.name === name)
256 // Built-in commands (/clear, /model) and this mod's own name no activity.
257 if (command === undefined || command.source === 'builtin' || command.plugin === 'awareness') {
258 return
259 }
260 await update($, workingOn, w => ({ ...w, skill: skillTitle(name) }))
261}
262
263/** Reads everything the pane shows that can change outside a turn. */
264const refreshAll = async ($: EngineInterface) => {
265 await Promise.allSettled([refreshHeader($), refreshRepo($), refreshAgents($)])
266}
267
268export const register: Register = on => {
269 // Claude Code docks the pane beside the transcript in the fullscreen layout
270 // from 110 columns; elsewhere it seats it above the prompt.
271 on('session.start', async ($, e, next) => {
272 await $.command.register({
273 name: 'awareness',
274 description: 'Show the Awareness pane: model, git state, usage, agents and your last three prompts',
275 })
276 void $.ui.open({ id: PANE, title: TITLE })
277 await Promise.allSettled([refreshAll($), readEffortSetting($)])
278 void refreshPullRequest($).catch(() => undefined)
279 $.clock.every(REFRESH_MS, () => refreshAll($))
280 $.clock.every(PULL_REQUEST_REFRESH_MS, () => refreshPullRequest($).catch(() => undefined))
281
282 return next(e)
283 })
284
285 // /clear starts the conversation over, so the activity starts over too.
286 on('classic.SessionStart', async ($, e, next) => {
287 if (e.source === 'clear') {
288 await update($, workingOn, () => NOTHING_YET).catch(() => undefined)
289 }
290 return next(e)
291 })
292
293 // A slash command typed at the prompt; noteTypedSkill skips built-ins.
294 on('command.run', async ($, e, next) => {
295 if (e.command !== 'awareness') {
296 await noteTypedSkill($, `/${e.command}`).catch(() => undefined)
297 }
298 return next(e)
299 })
300
301 on('command.run', { command: 'awareness' }, async $ => {
302 await $.ui.open({ id: PANE, title: TITLE })
303
304 return { text: 'Awareness pane opened.' }
305 })
306
307 // Records only what the person typed (here or through Remote Control), not
308 // prompts raised by plugins, schedules, task notifications or other agents.
309 on('prompt.submit', async ($, e, next) => {
310 const text = oneLine(e.text)
311 if ((e.origin.kind === 'composer' || e.origin.kind === 'bridge') && text) {
312 // A failed write must not hold up the prompt.
313 await update($, recent, list => [...list, text].slice(-SHOWN)).catch(() => undefined)
314 await noteTypedSkill($, e.text).catch(() => undefined)
315 }
316 return next(e)
317 })
318
319 // The model starting a skill names the activity.
320 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
321 const result = await next(e)
322 if (e.agentId === undefined && result.deny === undefined && result.isError !== true) {
323 await update($, workingOn, w => ({ ...w, skill: skillTitle(e.skill) })).catch(() => undefined)
324 }
325 return result
326 })
327
328 // The main conversation's task list names the step in progress.
329 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
330 const result = await next(e)
331 if (e.agentId === undefined && result.deny === undefined && result.isError !== true) {
332 const doing = e.activeForm ?? e.subject
333 await update($, workingOn, w => ({
334 ...w,
335 tasks: { ...w.tasks, [result.result.task.id]: { doing, status: 'pending' as const } },
336 })).catch(() => undefined)
337 }
338 return result
339 })
340
341 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
342 const result = await next(e)
343 if (e.agentId === undefined && result.deny === undefined && result.isError !== true) {
344 await update($, workingOn, w => ({ ...w, tasks: updatedTasks(w.tasks, e) })).catch(() => undefined)
345 }
346 return result
347 })
348
349 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
350 const result = await next(e)
351 if (e.agentId === undefined && result.deny === undefined && result.isError !== true) {
352 const todo = e.todos.find(t => t.status === 'in_progress')?.activeForm ?? null
353 await update($, workingOn, w => ({ ...w, todo })).catch(() => undefined)
354 }
355 return result
356 })
357
358 // Each main-loop request carries the effort it asks for, and its response
359 // moves the context window's fill.
360 on('turn.step', async function* ($, e, next) {
361 if (e.agentId !== undefined) {
362 return yield* next(e)
363 }
364 const effort = e.effort === undefined ? null : String(e.effort)
365 await update($, header, h => ({ ...h, effort })).catch(() => undefined)
366 const response = yield* next(e)
367 await refreshHeader($).catch(() => undefined)
368
369 return response
370 })
371
372 // A tool call can commit, push, edit a file or start an agent.
373 on('tool.call', async ($, e, next) => {
374 const result = await next(e)
375 void Promise.allSettled([refreshRepo($), refreshAgents($)])
376 return result
377 })
378
379 // The main conversation's turn ending leaves Claude idle, so nothing is being worked on.
380 on('turn.complete', async ($, e, next) => {
381 const result = await next(e)
382 if (e.agentId === undefined) {
383 await update($, workingOn, () => NOTHING_YET).catch(() => undefined)
384 }
385 void refreshAll($)
386 void refreshPullRequest($).catch(() => undefined)
387 return result
388 })
389
390 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
391 const { Box, Text, Link } = $.ui.resolve(e)
392 const list = await read($, recent)
393 const top = await read($, header)
394 const git = await read($, repo)
395 const pr = await read($, pullRequest)
396 const working = await read($, agents)
397 const activity = workingOnText(await read($, workingOn))
398 const newest = list.length - 1
399 const percent = top.contextPercent
400 const card = git === null ? undefined : /^BL-\d+/i.exec(git.branch)?.[0].toUpperCase()
401
402 return (
403 <Box flexDirection="column" gap={1} paddingLeft={2}>
404 <Text bold dimColor>
405 Awareness Mod
406 </Text>
407 <Box flexDirection="column">
408 <Text wrap="truncate-end">
409 <Text dimColor>
410 {top.model ?? '…'}
411 {top.effort === null ? '' : ` (${top.effort})`} | context:{' '}
412 </Text>
413 <Text
414 color={percent !== null && percent > CONTEXT_WARNING_PERCENT ? 'error' : undefined}
415 dimColor={percent === null || percent <= CONTEXT_WARNING_PERCENT}
416 >
417 {percent === null ? '…' : `${percent}%`}
418 </Text>
419 </Text>
420 {top.limits.length > 0 && (
421 <Text wrap="truncate-end">
422 <Text dimColor>usage: </Text>
423 {top.limits.map((l, i) => (
424 <Text
425 key={l.kind}
426 color={l.percentUsed > LIMIT_WARNING_PERCENT ? 'error' : undefined}
427 dimColor={l.percentUsed <= LIMIT_WARNING_PERCENT}
428 >
429 {i > 0 ? ' · ' : ''}
430 {limitLabel(l.kind)} {Math.round(l.percentUsed)}%
431 </Text>
432 ))}
433 </Text>
434 )}
435 </Box>
436
437 {git !== null && (
438 <Box flexDirection="column">
439 <Text bold dimColor wrap="truncate-end">
440 Workspace: {git.branch}
441 </Text>
442 <Link href={folderUrl(git.worktree)}>
443 <Text dimColor underline wrap="truncate-middle">
444 {git.worktree}
445 </Text>
446 </Link>
447 {(card !== undefined || pr !== null) && (
448 <Box flexDirection="row" gap={2}>
449 {card !== undefined && (
450 <Link href={`${YOUTRACK_ISSUE_URL}${card}`}>
451 <Text dimColor underline>
452 {card}
453 </Text>
454 </Link>
455 )}
456 {pr !== null && (
457 <Box flexDirection="row" gap={1}>
458 <Link href={pr.url}>
459 <Text dimColor underline>
460 PR #{pr.number}
461 </Text>
462 </Link>
463 <Link href={reviewableUrl(pr.url)}>
464 <Text dimColor underline>
465 Reviewable
466 </Text>
467 </Link>
468 <Text dimColor>({pr.status})</Text>
469 </Box>
470 )}
471 </Box>
472 )}
473 <Text color={git.changedFiles > 0 ? 'warning' : undefined} dimColor={git.changedFiles === 0}>
474 {uncommittedText(git.changedFiles)}
475 </Text>
476 <Text
477 color={git.unpushedCommits !== 0 ? 'warning' : undefined}
478 dimColor={git.unpushedCommits === 0}
479 >
480 {unpushedText(git.unpushedCommits)}
481 </Text>
482 </Box>
483 )}
484
485 {working.length > 0 && (
486 <Box flexDirection="column">
487 <Text bold dimColor>
488 Running Agents
489 </Text>
490 {working.map(a => (
491 <Box key={a.id} paddingLeft={2}>
492 <Text dimColor wrap="truncate-end">
493 {a.description} ({a.type})
494 </Text>
495 </Box>
496 ))}
497 </Box>
498 )}
499
500 {activity !== null && (
501 <Text color="suggestion" wrap="truncate-end">
502 {activity}
503 </Text>
504 )}
505
506 <Box flexDirection="column" gap={1}>
507 <Text bold dimColor>
508 Recent Prompts
509 </Text>
510 {list.length === 0 && <Text dimColor>No prompts yet.</Text>}
511 {list.map((text, i) => (
512 <Box key={`p${i}`} paddingLeft={2}>
513 {i === newest ? (
514 <Text bold color="suggestion">
515 {text}
516 </Text>
517 ) : (
518 <Text dimColor>{text}</Text>
519 )}
520 </Box>
521 ))}
522 </Box>
523 </Box>
524 )
525 })
526}
527types/index.d.ts 74 lines1/** The person's most recent prompts, oldest first. */
2export type Recent = readonly string[]
3
4/** One subscription usage window, as a share used. */
5export type Limit = {
6 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
7 kind: string
8 percentUsed: number
9}
10
11/** What the pane's top lines show; each part null until it is known. */
12export type Header = {
13 /** The main loop's model, as `/model` shows it. */
14 model: string | null
15 /** The effort the main loop's last request asked for. */
16 effort: string | null
17 /** How full the context window is, as a whole percentage. */
18 contextPercent: number | null
19 /** The subscription usage windows; empty off a subscription. */
20 limits: readonly Limit[]
21}
22
23/** The git state of the session's worktree; null outside a repository. */
24export type Repo = {
25 branch: string
26 /** The session's directory. */
27 worktree: string
28 /** Files with changes not yet committed, untracked ones included. */
29 changedFiles: number
30 /** Commits on the branch not on its upstream; null when it has no upstream. */
31 unpushedCommits: number | null
32} | null
33
34/** The branch's pull request; null when it has none or `gh` cannot say. */
35export type PullRequest = {
36 number: number
37 url: string
38 /** `draft`, `open`, `merged` or `closed`. */
39 status: string
40} | null
41
42/** One entry of the main conversation's task list, by its id. */
43export type TrackedTask = {
44 /** The present-continuous phrase shown beside the spinner, else the subject. */
45 doing: string
46 status: 'pending' | 'in_progress' | 'completed'
47}
48
49/** What the "Working on:" line is made from. */
50export type WorkingOn = {
51 /** The skill last started, as the person or the model named it. */
52 skill: string | null
53 /** The `TaskCreate`/`TaskUpdate` task list, by task id. */
54 tasks: Readonly<Record<string, TrackedTask>>
55 /** The in-progress item of the last `TodoWrite` list, if any. */
56 todo: string | null
57}
58
59/** A subagent or teammate still at work. */
60export type RunningAgent = { id: string; description: string; type: string }
61
62declare module 'claude-code' {
63 interface PluginState {
64 awareness: {
65 recent: Recent
66 header: Header
67 repo: Repo
68 pullRequest: PullRequest
69 workingOn: WorkingOn
70 agents: readonly RunningAgent[]
71 }
72 }
73}
74