The work item in focus (an ID and a description) on the status line, set with /og-ticket or by Claude

The work item you are on, in sight: an optional ID and a short description at the end of the dim hint line under the prompt, kept per project across sessions. No tracker integration: set it from whatever you read, a Jira ticket, a document, a spreadsheet row.
? for shortcuts · 🎫 SFCOMPRG-1234 · Quote sync fails on renewal
Not the status line: Claude Code leads a plugin's status line with the plugin's name. With og-focus-mode on, the idle hint is hidden and the focus shows alone in its place.
| Command | Does |
|---|---|
/og-ticket <ID> <description> | Sets the focus. The first word is the ID when it looks like one (SFCOMPRG-1234, PLA-41); otherwise the whole text is the description |
/og-ticket | Shows the focus |
/og-ticket clear | Removes it |
Claude can set it too, through the og_ticket_set tool ({ id?, description }): ask it to read a ticket or a document and set the focus. It shows under the prompt at once.
The project is the git root of the session's directory, or the directory itself outside git. The focus is cut to 60 characters.
/plugin install og-ticket --marketplace oscar-guggiana-neto-neocol/claude-modshooks/register.tsx 112 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Focus } from '../types'
5import { focusOf, parseFocus, statusText } from './focus'
6
7const PLUGIN = 'og-ticket'
8const COMMAND = 'og-ticket'
9const TOOL = 'og_ticket_set'
10
11// The focus as drawn at the end of the hint line under the prompt. Not the
12// status line: the engine leads a plugin's status line with the plugin's name.
13const line = atom({ plugin: 'og-ticket', key: 'line' } as const, null)
14
15const projectKey = (root: string) => `project:${root}`
16
17/** The git root of the session's directory, or the directory itself outside git. */
18async function projectOf($: EngineInterface) {
19 const cwd = await $.session.cwd()
20 const ran = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd }).catch(() => undefined)
21 const root = ran?.exitCode === 0 ? ran.stdout.trim() : ''
22
23 return root === '' ? cwd : root
24}
25
26async function load($: EngineInterface, root: string): Promise<Focus | undefined> {
27 const stored = await $.store.get(projectKey(root))
28 return stored !== null && typeof stored === 'object' ? (stored as Focus) : undefined
29}
30
31async function show($: EngineInterface, focus: Focus | undefined) {
32 await update($, line, () => (focus === undefined ? null : statusText(focus)))
33}
34
35/** Stores the project's focus (undefined clears it) and draws it. */
36async function save($: EngineInterface, root: string, focus: Focus | undefined) {
37 if (focus === undefined) await $.store.delete(projectKey(root))
38 else await $.store.set(projectKey(root), focus)
39 await show($, focus)
40}
41
42const explain = (focus: Focus | undefined) =>
43 focus === undefined
44 ? 'No focus. /og-ticket <ID> <description> sets one; the ID is optional.'
45 : `Focus: ${[focus.id, focus.description].filter(Boolean).join(' · ')}`
46
47export const register: Register = on => {
48
49 on('session.start', async ($, e, next) => {
50 const started = await next(e)
51 await $.command.register({
52 name: COMMAND,
53 description: 'The work item in focus (or /og-ticket <ID> <description>, /og-ticket clear)',
54 argumentHint: '[ID] [description] | clear',
55 })
56 await $.tool.register({
57 name: TOOL,
58 description:
59 "Sets the work item the user is on for this project, shown under the prompt: an optional tracker ID (e.g. SFCOMPRG-1234) and a short description (one line). Use it when the user asks you to set the focus, for instance after reading a ticket or a document.",
60 inputSchema: {
61 type: 'object',
62 properties: {
63 id: { type: 'string', description: 'The tracker ID, if the item has one' },
64 description: { type: 'string', description: 'What the item is about, in one short line' },
65 },
66 },
67 })
68 await show($, await load($, await projectOf($)))
69
70 return started
71 })
72
73 on('command.run', { command: COMMAND }, async ($, e) => {
74 const root = await projectOf($)
75 const text = e.args.trim()
76 if (text === 'clear') {
77 await save($, root, undefined)
78 return { text: 'Focus cleared.' }
79 }
80 if (text === '') {
81 const focus = await load($, root)
82 await show($, focus)
83 return { text: explain(focus) }
84 }
85 const focus = parseFocus(text)
86 await save($, root, focus)
87
88 return { text: explain(focus) }
89 }).catch(() => ({ text: `${PLUGIN}: could not read or save the focus.` }))
90
91 on('tool.call', { tool: `mcp__${PLUGIN}__${TOOL}` }, async ($, e) => {
92 const input = e as { id?: unknown; description?: unknown }
93 const focus = focusOf(
94 typeof input.id === 'string' ? input.id : undefined,
95 typeof input.description === 'string' ? input.description : undefined,
96 )
97 if (focus === undefined) return { deny: 'The focus needs an id or a description.' }
98 await save($, await projectOf($), focus)
99
100 return { result: explain(focus) } as never
101 })
102
103 // At the end of the dim line under the prompt; alone when another mod emptied it.
104 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
105 const text = await read($, line)
106 if (text === null) return next(e)
107 if (e.props.hint.trim() === '') return next({ ...e, props: { ...e.props, hint: text, tail: undefined } })
108
109 return next({ ...e, props: { ...e.props, tail: `${e.props.tail ?? ''} · ${text}` } })
110 })
111}
112hooks/focus.ts 35 lines1// Pure logic of the focus: reading what the person typed, and the status line.
2// No `$` here, so tests drive it alone.
3
4import type { Focus } from '../types'
5
6const ID = /^[A-Z][A-Z0-9]+-\d+$/
7const MAX = 60
8
9const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
10
11/** A focus from an ID and a description, either optional; neither is no focus. */
12export const focusOf = (id: string | undefined, description: string | undefined): Focus | undefined => {
13 const key = oneLine(id ?? '')
14 const text = oneLine(description ?? '')
15 if (key === '' && text === '') return undefined
16
17 return key === '' ? { description: text } : { id: key, description: text }
18}
19
20/** `/og-ticket` text: the first word is the ID when it looks like one, the rest the description. */
21export const parseFocus = (text: string): Focus | undefined => {
22 const [first = '', ...rest] = oneLine(text).split(' ')
23
24 return ID.test(first) ? focusOf(first, rest.join(' ')) : focusOf(undefined, text)
25}
26
27/** Cuts a text to `max` characters (an emoji counts as one), ending in `…`. */
28const cut = (text: string, max: number) => {
29 const chars = [...text]
30 return chars.length <= max ? text : `${chars.slice(0, max - 1).join('')}…`
31}
32
33export const statusText = (focus: Focus) =>
34 cut(`🎫 ${[focus.id, focus.description].filter(Boolean).join(' · ')}`, MAX)
35types/index.d.ts 17 lines1/** The work item in focus: an optional ID and what it is about. */
2export type Focus = {
3 /** A tracker key such as `SFCOMPRG-1234`, when the item has one. */
4 id?: string
5 /** Empty only when there is an ID. */
6 description: string
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'og-ticket': {
12 /** The focus as the hint line draws it; null when there is none. */
13 line: string | null
14 }
15 }
16}
17