A pane beside the transcript that fetches one Jira issue by key and lets you attach it to your next prompt.

A Claude Code plugin (a Claude Mod). /jira DEMO-1 fetches one Jira issue from an MCP server already connected to the session, and draws it in a pane beside the transcript. The fetch runs inside the plugin. It never runs as part of a model turn. A button on the pane arms the issue text to ride the next prompt as context.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. This is early access. The API can change between releases.-p has no pane.claude plugin marketplace add meganemura/jira-ticket-pane
claude plugin install jira-ticket-pane@jira-ticket-pane
To develop against a checkout, run the plugin from its working tree:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/jira-ticket-pane/plugin
To set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 for each session, add it to the env of settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
/jira/jira <KEY> fetches the issue and draws it in the pane. It opens the pane if the pane is closed./jira with no key toggles the pane's display.issue and meta tabs on the left, the ↻ and attach buttons on the right. The tab not open draws dim.↻ <HH:MM> button refetches the current issue and shows the time of the last fetch. The plugin polls nothing on a timer.issue tab shows the description.meta tab shows the fields and, under the raw json fold, the raw JSON.attach button arms the issue text; its label then reads attached ✓. Press it again to drop the text. The text drops on its own once a prompt goes out. The plugin does not write into the prompt box.prompt.submit's context field holds up to 32,000 characters in total, across every block already on the prompt. The plugin fits the armed issue text to the room left in that budget.[A-Z][A-Z0-9]+-\d+./jira configBy default the plugin finds the tool to call at run time. The search rule: an MCP tool whose name contains both "jira" and "issue", and one of "get", "fetch", "read", or "show". When more than one tool matches, the plugin picks the one with the shortest name.
When the search finds no match, the pane lists the names of the MCP tools connected to the session.
/jira config server=<name> tool=<name> pins a specific server and tool./jira config clear returns to the default search.A pinned value lives in the plugin's own store and outlasts the session.
To call the tool, the plugin tries three argument names in order: issueIdOrKey, issueKey, key.
Atlassian's own MCP server also needs a cloudId, naming the Atlassian cloud site the issue lives on. When the search finds the issue tool on a server that also carries a matching site-listing tool, the plugin calls that tool once a session. It keeps the id the tool returns. More than one accessible site shows an error naming each one, with no call to the issue tool.
/jira config server=<name> tool=<name> cloud=<id> pins a site by hand. Add cloud=<id> when a pinned server and tool skip the search: a pinned tool never looks for the site-listing tool on its own.
Set JIRA_TICKET_PANE_FIXTURE=<dir> to read <dir>/<KEY>.json in place of a call to a real MCP server. Each JSON file takes the shape of an MCP tool result: a content field, an isError field, and an optional structuredContent field.
Try it:
JIRA_TICKET_PANE_FIXTURE="$PWD/plugin/tests/fixtures" CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir "$PWD/plugin"
Then, inside the session, run /jira DEMO-1.
Run /plugin-types once in a new checkout. It writes .claude/types/claude-code.d.ts, which the type checker reads.
Three gates, run before a commit:
claude plugin validate plugin
npx -p typescript tsc -p plugin/hooks
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugin
A hook failure fails open: the session keeps running, and the failure appears only in ~/.claude/debug/<session>.txt.
issue tab draws the summary and the description, and the meta tab draws the other fields. A response in another shape draws as its raw content blocks.hooks/mod.ts 971 lines1// The plugin's one function-hooks module. `/jira <KEY>` fetches one Jira issue through an MCP
2// server the session already has, calling `$.mcp.call` directly inside this hook, and draws it
3// in a pane beside the transcript. A press on the pane's attach button arms the issue text to
4// ride the person's next prompt as context, the same way the model reads any other attached text.
5//
6// Must NOT know about: which MCP server serves Jira (learned from `$.tool.list()`, or pinned
7// once with `/jira config`); which Atlassian cloud site holds the issue (resolved through
8// `getAccessibleAtlassianResources`, or pinned with `/jira config cloud=<id>`); writing back to
9// Jira; a poll timer (an issue does not change minute to minute, so one refresh press stays
10// enough); drag-select over the issue text (a later milestone).
11//
12// It loads only where Claude Code has function hooks enabled. The engine's validator reads this
13// file statically, so every call on `$` is spelled `$.noun.event(...)` and `$` is handed only to
14// the function declarations at the top of the file; the rest of the module holds a `Host`, a
15// bundle of closures built once at `session.start`.
16
17import type { Elements, McpToolResult, On, RenderElement, ToolInfo } from 'claude-code'
18
19const PANE_ID = 'jira-ticket-pane'
20const PANE_TITLE = 'jira-ticket-pane'
21const COMMAND = 'jira'
22
23const KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/
24
25// Jira MCP tools spell the issue key argument differently server to server; tried in order
26// until one answers `isError: false`. Atlassian's own MCP server confirmed `issueIdOrKey`; the
27// other two stay as a fallback for a server that spells the argument another way.
28const ARG_NAMES = ['issueIdOrKey', 'issueKey', 'key']
29
30// Matches the tool that lists the Atlassian cloud sites a session can reach, by name alone
31// (Atlassian's own server calls it `getAccessibleAtlassianResources`). Its result names the
32// `cloudId` a Jira issue call needs.
33const ACCESSIBLE_RESOURCES_RE = /accessible.*resources/i
34
35// `PROMPT_CONTEXT_MAX_CHARS` copies the cap the d.ts states for `prompt.submit`'s `context`, so
36// `fittedContextTextOf` can size an attachment up front, without spending a prompt just to learn
37// the number by its rejection.
38const PROMPT_CONTEXT_MAX_CHARS = 32_000
39const CONTEXT_CUT_NOTE = '(The rest of this issue was cut: it did not fit in the prompt.)'
40
41const STORE_KEY = 'config'
42
43type McpConfig = { server: string; tool: string; cloudId?: string }
44
45type Armed = { key: string; text: string }
46
47type Host = {
48 envFixture: () => Promise<string | undefined>
49 mcpCall: (server: string, tool: string, args: Record<string, unknown>) => Promise<McpToolResult>
50 toolList: () => Promise<ToolInfo[]>
51 fsRead: (path: string) => Promise<string>
52 status: (text: string | undefined) => void
53 open: () => Promise<void>
54 close: () => Promise<void>
55 invalidate: () => void
56 log: (text: string) => void
57 register: () => Promise<unknown>
58 storeGet: (key: string) => Promise<unknown>
59 storeSet: (key: string, value: unknown) => Promise<void>
60}
61
62type State = {
63 host: Host | null
64 isOpen: boolean
65 issueKey: string | null
66 result: McpToolResult | null
67 error: string | null
68 toolNames: string[]
69 lastCall: string | null
70 config: McpConfig | null
71 isLoading: boolean
72 fetchedAt: string | null
73 isStructuredOpen: boolean
74 armed: Armed | null
75 fetchSeq: number
76 // Which of the two tabs the pane draws. Stays as the person left it across a refetch or a new
77 // key: no reset to `'issue'` on a fresh fetch.
78 tab: 'issue' | 'meta'
79 // The Atlassian cloud site id, resolved once per session through
80 // `getAccessibleAtlassianResources` and kept here for every later fetch; never written to the
81 // store, since a pin belongs to `/jira config cloud=<id>` alone.
82 cloudId: string | null
83 siteLines: string[]
84}
85
86// The host is a bundle of closures over `$`, built once at `session.start`: `$` stays inside
87// `hostOf` and the function declarations at the top of the file the validator reads, and the
88// rest of the module works through this `Host`. That boundary is also the seam a test fakes:
89// every world a test builds stubs these same calls with `on(...)`.
90function hostOf($: any): Host {
91 return {
92 envFixture: () => $.env.get('JIRA_TICKET_PANE_FIXTURE'),
93 mcpCall: (server, tool, args) => $.mcp.call(server, tool, args),
94 toolList: () => $.tool.list(),
95 fsRead: (path) => $.fs.read(path),
96 status: (text) => $.ui.status(text),
97 open: () => $.ui.open({ id: PANE_ID, title: PANE_TITLE }),
98 close: () => $.ui.close({ id: PANE_ID }),
99 invalidate: () => $.ui.invalidate('ui.render'),
100 log: (text) => $.ui.log(text),
101 register: () => $.command.register({ name: COMMAND, description: 'Show or hide the jira-ticket-pane pane, or fetch one Jira issue by key' }),
102 storeGet: (key) => $.store.get(key),
103 storeSet: (key, value) => $.store.set(key, value),
104 }
105}
106
107function messageOf(error: unknown): string {
108 return error instanceof Error ? error.message : String(error)
109}
110
111// The store holds this file's own past write, and an earlier version of this file may have saved
112// a different shape; configFromStore checks each field before state trusts it as an McpConfig.
113function configFromStore(value: unknown): McpConfig | null {
114 if (typeof value !== 'object' || value === null) return null
115 const server = Reflect.get(value, 'server')
116 const tool = Reflect.get(value, 'tool')
117 if (typeof server !== 'string' || typeof tool !== 'string') return null
118 const cloudId = Reflect.get(value, 'cloudId')
119 return { server, tool, ...(typeof cloudId === 'string' ? { cloudId } : {}) }
120}
121
122// A person writes a fixture file, so its shape is unchecked until here: the two fields
123// fetchFromFixture reads off it, `content` and `isError`, are checked before anything trusts the
124// rest.
125function mcpResultOf(value: unknown): McpToolResult | null {
126 if (typeof value !== 'object' || value === null) return null
127 const content = Reflect.get(value, 'content')
128 const isError = Reflect.get(value, 'isError')
129 if (!Array.isArray(content) || typeof isError !== 'boolean') return null
130 const structuredContent = Reflect.get(value, 'structuredContent')
131 return { content, isError, ...(structuredContent === undefined ? {} : { structuredContent }) }
132}
133
134// An MCP tool name is `mcp__<server>__<tool>`, and the tool name itself may contain `__`;
135// parsedToolNameOf cuts only at the first `__` past the prefix, so the rest of the name stays
136// whole as the tool.
137function parsedToolNameOf(name: string): McpConfig | null {
138 const prefix = 'mcp__'
139 if (!name.startsWith(prefix)) return null
140 const rest = name.slice(prefix.length)
141 const cut = rest.indexOf('__')
142 if (cut === -1) return null
143 const server = rest.slice(0, cut)
144 const tool = rest.slice(cut + 2)
145 if (server === '' || tool === '') return null
146 return { server, tool }
147}
148
149// A Jira "get one issue" tool, guessed from its name alone: connected, about Jira, about an
150// issue, and named with a read verb (get, fetch, read or show). Of several candidates the
151// shortest name wins (a plainer name reads as the more likely single-purpose tool); ties keep
152// the order `$.tool.list()` gave, since `Array.prototype.sort` is stable.
153export function discoverTool(tools: ToolInfo[]): McpConfig | null {
154 const candidates = tools
155 .filter((tool) => tool.mcp && /jira/i.test(tool.name) && /issue/i.test(tool.name) && /get|fetch|read|show/i.test(tool.name))
156 .map((tool) => ({ name: tool.name, parsed: parsedToolNameOf(tool.name) }))
157 .filter((candidate): candidate is { name: string; parsed: McpConfig } => candidate.parsed !== null)
158
159 if (candidates.length === 0) return null
160 candidates.sort((a, b) => a.name.length - b.name.length)
161 return candidates[0]!.parsed
162}
163
164// The tool that lists a session's accessible Atlassian cloud sites, on the same server as the
165// Jira issue tool: connected, name matching `ACCESSIBLE_RESOURCES_RE`, server cut from its full
166// name matching `server`. Absent from `tool.list()`, some servers need no `cloudId` at all.
167function accessibleResourcesToolOf(tools: ToolInfo[], server: string): McpConfig | null {
168 const match = tools.find((tool) => tool.mcp && ACCESSIBLE_RESOURCES_RE.test(tool.name) && parsedToolNameOf(tool.name)?.server === server)
169 return match === undefined ? null : parsedToolNameOf(match.name)
170}
171
172// One Jira issue, read off the shape Atlassian's own MCP server answers with: every field a
173// plain string, always, with `''` standing in for an absent or a `null` field, so a caller
174// never needs its own null check on top of this one.
175type IssueView = {
176 key: string
177 summary: string
178 type: string
179 status: string
180 // Read off `fields.status.statusCategory.key`: `''` when the field or the category is absent,
181 // the same rule every other field on this type follows. `statusColorOf` reads this alone.
182 statusCategory: string
183 priority: string
184 assignee: string
185 reporter: string
186 labels: string[]
187 description: string
188 url: string
189 created: string
190 updated: string
191}
192
193// A category maps to the color a Jira board would use for it (new work, work under way, done
194// work); a category this file does not know draws with no color, rather than guessing one.
195export function statusColorOf(category: string): string | undefined {
196 if (category === 'new') return 'blue'
197 if (category === 'indeterminate') return 'yellow'
198 if (category === 'done') return 'green'
199 return undefined
200}
201
202// An ISO timestamp's date and minute, with no seconds and no timezone offset: `2026-09-19
203// 16:32`. A value that does not start with the ISO shape passes through unchanged, so a server
204// sending something else still shows, rather than a mangled cut of it.
205function shortDateOf(value: string): string {
206 return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) ? value.slice(0, 16).replace('T', ' ') : value
207}
208
209function statusCategoryKeyOf(value: unknown): string {
210 if (typeof value !== 'object' || value === null) return ''
211 const category = Reflect.get(value, 'statusCategory')
212 if (typeof category !== 'object' || category === null) return ''
213 return stringOf(Reflect.get(category, 'key'))
214}
215
216function stringOf(value: unknown): string {
217 return typeof value === 'string' ? value : ''
218}
219
220// A Jira field carried as `{ name: string }` (issue type, status, priority) or `null`.
221function nameOf(value: unknown): string {
222 if (typeof value !== 'object' || value === null) return ''
223 return stringOf(Reflect.get(value, 'name'))
224}
225
226// A Jira field carried as `{ displayName: string }` (assignee, reporter) or `null`.
227function displayNameOf(value: unknown): string {
228 if (typeof value !== 'object' || value === null) return ''
229 return stringOf(Reflect.get(value, 'displayName'))
230}
231
232// Reads plain text out of an Atlassian Document Format node. A `text` node gives its own text;
233// `paragraph` and `heading` join their children then end with a newline; a `bulletList` or
234// `orderedList` gives one `- `-led line per `listItem`; `codeBlock` joins its children's text;
235// `hardBreak` is a bare newline. Every other node, `doc` included, recurses into its children.
236// A plain string passes through unchanged; `null` or `undefined` reads as `''`.
237export function adfTextOf(node: unknown): string {
238 if (typeof node === 'string') return node
239 if (node === null || node === undefined) return ''
240 if (typeof node !== 'object') return ''
241 const type = Reflect.get(node, 'type')
242 const contentRaw = Reflect.get(node, 'content')
243 const children = Array.isArray(contentRaw) ? contentRaw : []
244
245 if (type === 'text') return stringOf(Reflect.get(node, 'text'))
246 if (type === 'hardBreak') return '\n'
247 // Every block-level node ends with its own newline, so a doc's children join with no
248 // separator of their own and still land one block per line.
249 if (type === 'paragraph' || type === 'heading') return `${children.map(adfTextOf).join('')}\n`
250 if (type === 'codeBlock') return `${children.map(adfTextOf).join('')}\n`
251 if (type === 'bulletList' || type === 'orderedList') {
252 const lines = children
253 .filter((item): item is object => typeof item === 'object' && item !== null && Reflect.get(item, 'type') === 'listItem')
254 .map((item) => {
255 const itemContent = Reflect.get(item, 'content')
256 const itemChildren = Array.isArray(itemContent) ? itemContent : []
257 return `• ${itemChildren.map(adfTextOf).join('').trimEnd()}`
258 })
259 return `${lines.join('\n')}\n`
260 }
261 return children.map(adfTextOf).join('')
262}
263
264// One issue, read off `parsed`: wrapped in `{ issues: { nodes: [...] } }` (a model's own tool
265// call answers this way), or the issue object itself with `key` and `fields` at the top level (a
266// call made through `$.mcp.call` from inside this module answers this way, with no wrapper).
267// `null` when `parsed` carries neither shape.
268function issueNodeOf(parsed: unknown): unknown | null {
269 if (typeof parsed !== 'object' || parsed === null) return null
270 const issues = Reflect.get(parsed, 'issues')
271 if (typeof issues === 'object' && issues !== null) {
272 const nodes = Reflect.get(issues, 'nodes')
273 if (Array.isArray(nodes) && nodes.length > 0) return nodes[0]
274 }
275 const key = Reflect.get(parsed, 'key')
276 const fields = Reflect.get(parsed, 'fields')
277 if (typeof key === 'string' && typeof fields === 'object' && fields !== null) return parsed
278 return null
279}
280
281// The first text content block that parses as JSON carrying an issue, wrapped or not (see
282// `issueNodeOf`); `null` when no block parses as either shape (an older or a different server's
283// plain-text answer, for instance).
284function issueJsonOf(result: McpToolResult): unknown | null {
285 for (const block of result.content) {
286 if (block.type !== 'text' || typeof block.text !== 'string') continue
287 let parsed: unknown
288 try {
289 parsed = JSON.parse(block.text)
290 } catch {
291 continue
292 }
293 if (issueNodeOf(parsed) !== null) return parsed
294 }
295 return null
296}
297
298// `issueJsonOf`'s issue, read into an `IssueView`; `null` when no content block carries either
299// known shape, so the caller falls back to drawing the raw blocks.
300export function issueViewOf(result: McpToolResult): IssueView | null {
301 const parsed = issueJsonOf(result)
302 if (parsed === null) return null
303 const node = issueNodeOf(parsed)
304 if (typeof node !== 'object' || node === null) return null
305 const fieldsRaw = Reflect.get(node, 'fields')
306 const fields = typeof fieldsRaw === 'object' && fieldsRaw !== null ? fieldsRaw : {}
307 const labelsRaw = Reflect.get(fields, 'labels')
308 const labels = Array.isArray(labelsRaw) ? labelsRaw.filter((label): label is string => typeof label === 'string') : []
309 return {
310 key: stringOf(Reflect.get(node, 'key')),
311 summary: stringOf(Reflect.get(fields, 'summary')),
312 type: nameOf(Reflect.get(fields, 'issuetype')),
313 status: nameOf(Reflect.get(fields, 'status')),
314 statusCategory: statusCategoryKeyOf(Reflect.get(fields, 'status')),
315 priority: nameOf(Reflect.get(fields, 'priority')),
316 assignee: displayNameOf(Reflect.get(fields, 'assignee')),
317 reporter: displayNameOf(Reflect.get(fields, 'reporter')),
318 labels,
319 // Trimmed: every block-level node in adfTextOf ends its own line with a newline, so the
320 // last block of a description leaves one trailing behind with nothing after it.
321 description: adfTextOf(Reflect.get(fields, 'description')).trimEnd(),
322 // `webUrl` alone: a call through `$.mcp.call` carries `self`, an api.atlassian.com API link
323 // with no site name in it, so there is no host to build a browse url from when `webUrl` is
324 // absent.
325 url: stringOf(Reflect.get(node, 'webUrl')),
326 created: stringOf(Reflect.get(fields, 'created')),
327 updated: stringOf(Reflect.get(fields, 'updated')),
328 }
329}
330
331// The `type · status · priority · assignee` line: an empty type, status or priority drops out;
332// assignee always shows, `unassigned` standing in for an empty one.
333function metaLineOf(view: IssueView): string {
334 const parts = [view.type, view.status, view.priority].filter((part) => part !== '')
335 parts.push(view.assignee !== '' ? view.assignee : 'unassigned')
336 return parts.join(' · ')
337}
338
339// The text a fetched issue reads as once it rides a prompt or is shown to the person. A parsed
340// `IssueView` reads as a heading block (key, summary, the meta line, labels when any, the url),
341// a blank line, then the description. Otherwise every `text` content block joins as its own
342// paragraph, unchanged from before field-by-field rendering existed.
343export function issueTextOf(result: McpToolResult): string {
344 const view = issueViewOf(result)
345 if (view !== null) {
346 const heading = [`${view.key}: ${view.summary}`, metaLineOf(view)]
347 if (view.labels.length > 0) heading.push(`labels: ${view.labels.join(', ')}`)
348 heading.push(view.url)
349 return [heading.join('\n'), view.description].join('\n\n')
350 }
351 return result.content
352 .filter((block) => block.type === 'text')
353 .map((block) => (typeof block.text === 'string' ? block.text : ''))
354 .join('\n\n')
355}
356
357// What an armed issue reads as once it rides a prompt as context: a sentence telling the model
358// what the person attached, then the issue text quoted line by line (an empty line becomes a
359// bare `>`).
360export function contextTextOf(key: string, text: string): string {
361 const header = `The user attached Jira issue ${key} from jira-ticket-pane to this prompt. Read it as context for what they ask:`
362 const quoted = text.split('\n').map((line) => (line === '' ? '>' : `> ${line}`))
363 return [header, ...quoted].join('\n')
364}
365
366// Whole when it fits the context room left, else as many whole lines as fit plus a cut note; a
367// kept line is always whole, cut only between lines. `undefined` when not even the header fits,
368// so the caller drops the attach; a note with no body under it would tell the model nothing.
369export function fittedContextTextOf(text: string, room: number): string | undefined {
370 if (text.length <= room) return text
371 const kept: string[] = []
372 let used = CONTEXT_CUT_NOTE.length
373 for (const line of text.split('\n')) {
374 const cost = line.length + 1
375 if (used + cost > room) break
376 kept.push(line)
377 used += cost
378 }
379 const hasBody = kept.length > 1
380 return hasBody ? `${kept.join('\n')}\n${CONTEXT_CUT_NOTE}` : undefined
381}
382
383// What a fetch found, kept out of `state` until the caller knows this is still the fetch that
384// gets to write it (a `fetchIssue` for an older key can land after a newer one). `cloudId` is
385// the value to keep in `state.cloudId` once this fetch lands: unchanged from what came in, when
386// this fetch never touched it.
387type FetchOutcome = {
388 result: McpToolResult | null
389 error: string | null
390 toolNames: string[]
391 lastCall: string | null
392 cloudId: string | null
393 siteLines: string[]
394}
395
396// Fetches `key` into `state`, from a fixture file when `JIRA_TICKET_PANE_FIXTURE` names a
397// directory, else from the connected MCP server. Refresh, or a fast run of `/jira <KEY>`, can
398// start a second fetch before the first one lands. `fetchSeq` numbers each attempt; `fetchIssue`
399// compares its own number to the one currently in `state` before it writes `result`, `error`,
400// `toolNames`, `lastCall`, `cloudId`, `siteLines`, `isLoading` or `fetchedAt`, so a late answer
401// from an older fetch leaves standing whatever a newer fetch already wrote.
402async function fetchIssue(state: State, key: string): Promise<void> {
403 const host = state.host
404 if (host === null) return
405 const seq = ++state.fetchSeq
406 state.isLoading = true
407 state.result = null
408 state.error = null
409 state.toolNames = []
410 state.lastCall = null
411 state.siteLines = []
412 host.invalidate()
413 try {
414 const dir = await host.envFixture()
415 const outcome =
416 dir !== undefined && dir !== '' ? await fetchFromFixture(host, dir, key, state.cloudId) : await fetchFromMcp(host, state.config, state.cloudId, key)
417 if (seq === state.fetchSeq) {
418 state.result = outcome.result
419 state.error = outcome.error
420 state.toolNames = outcome.toolNames
421 state.lastCall = outcome.lastCall
422 state.cloudId = outcome.cloudId
423 state.siteLines = outcome.siteLines
424 }
425 } finally {
426 if (seq === state.fetchSeq) {
427 state.isLoading = false
428 state.fetchedAt = hhmmOf(new Date())
429 }
430 host.invalidate()
431 }
432}
433
434// The clock time the refresh button shows, with the seconds dropped: `13:52`, always two digits
435// each side of the colon.
436function hhmmOf(date: Date): string {
437 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
438}
439
440// Fixture mode never calls `mcp.call` or `tool.list`: a fixture stands in for both the
441// discovery and the call, for local development with no MCP server connected. It never touches
442// `cloudId` either; `priorCloudId` passes through unchanged.
443async function fetchFromFixture(host: Host, dir: string, key: string, priorCloudId: string | null): Promise<FetchOutcome> {
444 const path = `${dir}/${key}.json`
445 let text: string
446 try {
447 text = await host.fsRead(path)
448 } catch (error) {
449 return { result: null, error: `jira-ticket-pane: could not read ${path}: ${messageOf(error)}`, toolNames: [], lastCall: null, cloudId: priorCloudId, siteLines: [] }
450 }
451 let parsed: unknown
452 try {
453 parsed = JSON.parse(text)
454 } catch (error) {
455 return { result: null, error: `jira-ticket-pane: could not parse ${path}: ${messageOf(error)}`, toolNames: [], lastCall: null, cloudId: priorCloudId, siteLines: [] }
456 }
457 const result = mcpResultOf(parsed)
458 if (result === null) {
459 return { result: null, error: `jira-ticket-pane: ${path} is not a Jira MCP tool result`, toolNames: [], lastCall: null, cloudId: priorCloudId, siteLines: [] }
460 }
461 return { result, error: null, toolNames: [], lastCall: null, cloudId: priorCloudId, siteLines: [] }
462}
463
464// One Atlassian cloud site, out of `getAccessibleAtlassianResources`'s own answer.
465type Site = { id: string; url: string }
466
467// Reads the array `getAccessibleAtlassianResources` answers with, `[{ id, url, name, ... }]`,
468// into the `id` and `url` of each entry; an entry with no string `id` is dropped.
469function sitesOf(value: unknown): Site[] {
470 if (!Array.isArray(value)) return []
471 const sites: Site[] = []
472 for (const entry of value) {
473 if (typeof entry !== 'object' || entry === null) continue
474 const id = Reflect.get(entry, 'id')
475 if (typeof id !== 'string') continue
476 const url = Reflect.get(entry, 'url')
477 sites.push({ id, url: typeof url === 'string' ? url : '' })
478 }
479 return sites
480}
481
482// What resolving the cloud site found: `cloudId` set on exactly one accessible site, `error` (and
483// `siteLines` naming each candidate) on zero or on more than one.
484type SiteResolution = { cloudId: string | null; error: string | null; siteLines: string[]; lastCall: string | null }
485
486async function siteResolutionOf(host: Host, server: string, tool: string): Promise<SiteResolution> {
487 const lastCall = `${tool} on ${server}`
488 let result: McpToolResult
489 try {
490 result = await host.mcpCall(server, tool, {})
491 } catch (error) {
492 return { cloudId: null, error: messageOf(error), siteLines: [], lastCall }
493 }
494 if (result.isError) {
495 const texts = result.content.map((block) => block.text).filter((text): text is string => typeof text === 'string' && text !== '')
496 const error = texts.length > 0 ? texts.join('\n\n') : `${tool} answered isError with no text (server ${server})`
497 return { cloudId: null, error, siteLines: [], lastCall }
498 }
499 const text = result.content.find((block) => block.type === 'text' && typeof block.text === 'string')?.text
500 let parsed: unknown
501 try {
502 parsed = text === undefined ? [] : JSON.parse(text)
503 } catch (error) {
504 return { cloudId: null, error: `jira-ticket-pane: could not parse ${tool}'s answer: ${messageOf(error)}`, siteLines: [], lastCall }
505 }
506 const sites = sitesOf(parsed)
507 if (sites.length === 0) return { cloudId: null, error: 'no Atlassian site is accessible to this session', siteLines: [], lastCall }
508 if (sites.length > 1) {
509 return {
510 cloudId: null,
511 error: 'more than one Atlassian site; pin one with /jira config server=<s> tool=<t> cloud=<id>',
512 siteLines: sites.map((site) => `${site.id} ${site.url}`.trimEnd()),
513 lastCall,
514 }
515 }
516 return { cloudId: sites[0]!.id, error: null, siteLines: [], lastCall: null }
517}
518
519async function fetchFromMcp(host: Host, config: McpConfig | null, priorCloudId: string | null, key: string): Promise<FetchOutcome> {
520 let resolved = config
521 // `tools` stays `null` when `config` pins the server and tool by hand: that path never calls
522 // `tool.list`, so it has nothing to check `ACCESSIBLE_RESOURCES_RE` against and sends no
523 // `cloudId` unless `config.cloudId` or an already-resolved `priorCloudId` gives it one.
524 let tools: ToolInfo[] | null = null
525 if (resolved === null) {
526 tools = await host.toolList()
527 const discovered = discoverTool(tools)
528 if (discovered === null) {
529 return {
530 result: null,
531 error: 'no Jira MCP tool found',
532 toolNames: tools.filter((tool) => tool.mcp).map((tool) => tool.name),
533 lastCall: null,
534 cloudId: priorCloudId,
535 siteLines: [],
536 }
537 }
538 resolved = discovered
539 }
540
541 let cloudId = config?.cloudId ?? priorCloudId ?? null
542 if (cloudId === null && tools !== null) {
543 const accessibleTool = accessibleResourcesToolOf(tools, resolved.server)
544 if (accessibleTool !== null) {
545 const site = await siteResolutionOf(host, accessibleTool.server, accessibleTool.tool)
546 if (site.error !== null) {
547 return { result: null, error: site.error, toolNames: [], lastCall: site.lastCall, cloudId: null, siteLines: site.siteLines }
548 }
549 cloudId = site.cloudId
550 }
551 }
552
553 let lastResult: McpToolResult | null = null
554 let lastCall = ''
555 for (const argName of ARG_NAMES) {
556 // Set right before each call, so a reject inside the try carries the name of the call that
557 // threw, the same name an exhausted loop carries for its last attempt.
558 lastCall = `${resolved.tool} on ${resolved.server} with ${argName} (${cloudId !== null ? 'cloudId set' : 'no cloudId'})`
559 let result: McpToolResult
560 try {
561 result = await host.mcpCall(resolved.server, resolved.tool, {
562 [argName]: key,
563 ...(cloudId !== null ? { cloudId } : {}),
564 responseContentFormat: 'markdown',
565 })
566 } catch (error) {
567 return { result: null, error: messageOf(error), toolNames: [], lastCall, cloudId, siteLines: [] }
568 }
569 if (!result.isError) return { result, error: null, toolNames: [], lastCall: null, cloudId, siteLines: [] }
570 lastResult = result
571 }
572
573 // Every argument name failed: the real Jira MCP tool this session has may need an argument
574 // this file does not know to send, so its answer's own text is the clue kept. A block can
575 // carry no text; a wholly blank answer falls back to a line naming the server and tool, so the
576 // error line always carries words for a person to read.
577 const texts = (lastResult?.content ?? []).map((block) => block.text).filter((text): text is string => typeof text === 'string' && text !== '')
578 const error = texts.length > 0 ? texts.join('\n\n') : `the tool answered isError with no text (server ${resolved.server}, tool ${resolved.tool})`
579 return { result: null, error, toolNames: [], lastCall, cloudId, siteLines: [] }
580}
581
582// The real element types, so the typecheck refuses a prop the engine would refuse. `Text` takes
583// no `key` (giving it one drops the whole tree); `Box` and `Button` do. None of this pane's
584// Buttons take a `hotkey`: pull-request-pane tested that prop in a real terminal, and the
585// hotkey did not fire inside a pane.
586type Ui = Pick<Elements['terminal'], 'Box' | 'Button' | 'Link' | 'Text'>
587
588// The pane's own side padding, named once: the divider is sized to `bodyColumns` (the render
589// input's cells-across-the-body figure) less these two cells, or it ends up wider than the
590// content and wraps one `─` onto a second row (real-terminal feedback, after `paddingLeft` was
591// added and only `paddingRight` had been subtracted).
592const PANE_PADDING_LEFT = 1
593const PANE_PADDING_RIGHT = 1
594
595function dividerOf(ui: Ui, bodyColumns: number): RenderElement {
596 return ui.Text({ dimColor: true, children: '─'.repeat(Math.max(bodyColumns - PANE_PADDING_LEFT - PANE_PADDING_RIGHT, 0)) })
597}
598
599// Every state the pane can be in shows this row: the tabs on the left once an issue has parsed
600// into fields, the refresh and attach buttons on the right once an issue is loaded. Plain
601// buttons keep the row from reading as boxed chrome sitting over the issue itself.
602function topBarOf(ui: Ui, state: State, host: Host, view: IssueView | null): RenderElement {
603 const { Box, Button } = ui
604
605 const tabButtons: RenderElement[] =
606 view === null
607 ? []
608 : [
609 Button({
610 key: 'tabs:issue',
611 label: 'issue',
612 plain: true,
613 ...(state.tab === 'issue' ? {} : { dimColor: true }),
614 onPress: () => {
615 state.tab = 'issue'
616 host.invalidate()
617 },
618 }),
619 Button({
620 key: 'tabs:meta',
621 label: 'meta',
622 plain: true,
623 ...(state.tab === 'meta' ? {} : { dimColor: true }),
624 onPress: () => {
625 state.tab = 'meta'
626 host.invalidate()
627 },
628 }),
629 ]
630
631 const rightButtons: RenderElement[] = []
632 const key = state.issueKey
633 if (key !== null) {
634 const refreshLabel = state.isLoading ? '↻ …' : `↻ ${state.fetchedAt ?? ''}`.trimEnd()
635 rightButtons.push(
636 Button({ key: 'refresh:button', label: refreshLabel, plain: true, dimColor: true, onPress: () => void fetchIssue(state, key).catch(() => undefined) }),
637 )
638
639 const result = state.result
640 if (result !== null) {
641 const isArmed = state.armed !== null && state.armed.key === key
642 rightButtons.push(
643 Button({
644 key: 'arm:button',
645 label: isArmed ? 'attached ✓' : 'attach',
646 plain: true,
647 onPress: () => {
648 if (state.armed !== null && state.armed.key === key) {
649 state.armed = null
650 host.status(undefined)
651 } else {
652 state.armed = { key, text: issueTextOf(result) }
653 host.status(`${key} rides your next prompt (press the button again to drop it)`)
654 }
655 host.invalidate()
656 },
657 }),
658 )
659 }
660 }
661
662 return Box({
663 key: 'topbar',
664 flexDirection: 'row',
665 justifyContent: 'space-between',
666 children: [
667 Box({ key: 'tabs', flexDirection: 'row', columnGap: 1, children: tabButtons }),
668 Box({ key: 'topbar-right', flexDirection: 'row', columnGap: 1, children: rightButtons }),
669 ],
670 })
671}
672
673function errorRowsOf(ui: Ui, state: State): RenderElement[] {
674 if (state.error === null) return []
675 const { Text } = ui
676 const rows: RenderElement[] = [Text({ color: 'red', children: `✗ ${state.error}` })]
677 if (state.lastCall !== null) rows.push(Text({ dimColor: true, children: `called ${state.lastCall}` }))
678 if (state.toolNames.length > 0) {
679 rows.push(Text({ dimColor: true, children: 'connected MCP tools:' }))
680 for (const name of state.toolNames) rows.push(Text({ dimColor: true, children: name }))
681 rows.push(Text({ dimColor: true, children: 'fix the tool with: /jira config server=<name> tool=<name>' }))
682 }
683 for (const line of state.siteLines) rows.push(Text({ dimColor: true, children: line }))
684 return rows
685}
686
687// Common to both tabs: a `<key> <summary>` heading, then the status dot (colored by category),
688// the type, the priority when the issue has one, and the assignee. Reading the state this way
689// needs no tab switch, so a press to see `raw json` is the only reason to leave the issue tab.
690function headerRowsOf(ui: Ui, view: IssueView): RenderElement {
691 const { Box, Text } = ui
692 const statusColor = statusColorOf(view.statusCategory)
693
694 const identityRow = Box({
695 flexDirection: 'row',
696 columnGap: 2,
697 children: [Text({ color: 'cyan', bold: true, children: view.key }), Text({ bold: true, children: view.summary })],
698 })
699
700 const stateRowChildren: RenderElement[] = [
701 Text({ ...(statusColor === undefined ? {} : { color: statusColor }), children: `● ${view.status}` }),
702 Text({ dimColor: true, children: view.type }),
703 ]
704 if (view.priority !== '') stateRowChildren.push(Text({ dimColor: true, children: view.priority }))
705 stateRowChildren.push(Text({ dimColor: true, children: view.assignee !== '' ? view.assignee : 'unassigned' }))
706 const stateRow = Box({ flexDirection: 'row', columnGap: 3, children: stateRowChildren })
707
708 return Box({ flexDirection: 'column', children: [identityRow, stateRow] })
709}
710
711// One `<label> <value>` row per field, a label padded to line the values up, an empty value
712// dropped (assignee excepted: `unassigned` always shows). The url row alone carries a `key`, the
713// keyed Box a `Link`'s `hover` needs to take effect.
714function metaLinesOf(ui: Ui, view: IssueView): RenderElement {
715 const { Box, Link, Text } = ui
716 const rows: { label: string; value: string; isUrl?: true }[] = [
717 { label: 'type', value: view.type },
718 { label: 'status', value: view.status },
719 { label: 'priority', value: view.priority },
720 { label: 'assignee', value: view.assignee !== '' ? view.assignee : 'unassigned' },
721 { label: 'reporter', value: view.reporter },
722 { label: 'labels', value: view.labels.join(', ') },
723 { label: 'created', value: shortDateOf(view.created) },
724 { label: 'updated', value: shortDateOf(view.updated) },
725 { label: 'url', value: view.url, isUrl: true },
726 ]
727
728 const children = rows
729 .filter((row) => row.value !== '')
730 .map((row) => {
731 const valueElement = row.isUrl === true ? Link({ href: row.value, children: [Text({ hover: { color: 'cyan' }, children: row.value })] }) : Text({ children: row.value })
732 return Box({
733 ...(row.isUrl === true ? { key: 'meta:url' } : {}),
734 flexDirection: 'row',
735 children: [Text({ dimColor: true, children: row.label.padEnd(11) }), valueElement],
736 })
737 })
738
739 return Box({ key: 'meta-lines', flexDirection: 'column', children })
740}
741
742// The fold's raw JSON: `structuredContent` when the tool sent one, else the text block
743// `issueViewOf` itself read its fields from. This keeps the fold showing something once fields
744// can draw straight from a text block instead of needing `structuredContent`.
745function structuredJsonOf(result: McpToolResult): unknown {
746 return result.structuredContent !== undefined ? result.structuredContent : issueJsonOf(result)
747}
748
749function structuredRowsOf(ui: Ui, state: State, host: Host): RenderElement[] {
750 const result = state.result
751 if (result === null) return []
752 const structured = structuredJsonOf(result)
753 if (structured === null || structured === undefined) return []
754 const { Box, Button, Text } = ui
755 const isOpen = state.isStructuredOpen
756 const rows: RenderElement[] = [
757 Box({
758 key: 'structured',
759 children: [
760 Button({
761 key: 'structured:button',
762 label: isOpen ? '▼ raw json' : '▶ raw json',
763 plain: true,
764 dimColor: true,
765 onPress: () => {
766 state.isStructuredOpen = !state.isStructuredOpen
767 host.invalidate()
768 },
769 }),
770 ],
771 }),
772 ]
773 if (isOpen) rows.push(Text({ dimColor: true, children: JSON.stringify(structured, null, 2) }))
774 return rows
775}
776
777// A parsed `IssueView` draws the shared header, then its selected tab's body: the description on
778// `issue`, the field list and the raw-json fold on `meta`. Otherwise (no parsed view: an older or
779// a different server's answer) every `text` content block draws as its own row, unchanged from
780// before field-by-field rendering existed, with the fold under it.
781function resultRowsOf(ui: Ui, state: State, host: Host, view: IssueView | null): RenderElement[] {
782 const result = state.result
783 if (result === null) return []
784 const { Box, Text } = ui
785
786 if (view !== null) {
787 const header = headerRowsOf(ui, view)
788 const body: RenderElement =
789 state.tab === 'issue'
790 ? view.description !== ''
791 ? Text({ children: view.description })
792 : Text({ dimColor: true, children: '(no description)' })
793 : Box({ key: 'meta', flexDirection: 'column', rowGap: 1, children: [metaLinesOf(ui, view), ...structuredRowsOf(ui, state, host)] })
794 return [Box({ key: 'result', flexDirection: 'column', rowGap: 1, children: [header, body] })]
795 }
796
797 const blocks = result.content.map((block) => (block.type === 'text' ? Text({ children: block.text ?? '' }) : Text({ dimColor: true, children: `[${block.type} block]` })))
798 return [Box({ key: 'result', flexDirection: 'column', rowGap: 1, children: blocks }), ...structuredRowsOf(ui, state, host)]
799}
800
801function paneOf(ui: Ui, state: State, host: Host, bodyColumns: number): RenderElement {
802 const { Box, Text } = ui
803 const view = state.result !== null ? issueViewOf(state.result) : null
804 const children: RenderElement[] = [topBarOf(ui, state, host, view), dividerOf(ui, bodyColumns)]
805
806 if (state.issueKey === null && state.error === null) children.push(Text({ dimColor: true, children: 'type /jira <KEY> to show an issue' }))
807
808 children.push(...errorRowsOf(ui, state))
809 children.push(...resultRowsOf(ui, state, host, view))
810
811 return Box({ key: 'jira-ticket-pane', flexDirection: 'column', paddingTop: 1, paddingRight: PANE_PADDING_RIGHT, paddingLeft: PANE_PADDING_LEFT, children })
812}
813
814// `config server=<s> tool=<t>` pins the MCP tool, so `fetchIssue` skips `tool.list` and calls it
815// directly; `config clear` drops that pin, back to discovery. `cloud=<id>` is optional and pins
816// the Atlassian cloud site, so `fetchFromMcp` skips `getAccessibleAtlassianResources` too. Values
817// carry no whitespace, so a plain `\S+` token match is enough. Both branches call `storeSet`
818// unawaited: `state.config` already holds the value the rest of this session reads, so a slow or
819// failing write to the store must not hold up the command's reply.
820function handleConfig(state: State, host: Host, rest: string): { text: string } {
821 if (rest === 'clear') {
822 state.config = null
823 void host.storeSet(STORE_KEY, null).catch(() => undefined)
824 return { text: 'jira-ticket-pane config cleared' }
825 }
826
827 let server: string | undefined
828 let tool: string | undefined
829 let cloud: string | undefined
830 for (const token of rest.split(/\s+/).filter((piece) => piece !== '')) {
831 const serverMatch = /^server=(\S+)$/.exec(token)
832 if (serverMatch) server = serverMatch[1]
833 const toolMatch = /^tool=(\S+)$/.exec(token)
834 if (toolMatch) tool = toolMatch[1]
835 const cloudMatch = /^cloud=(\S+)$/.exec(token)
836 if (cloudMatch) cloud = cloudMatch[1]
837 }
838
839 if (server === undefined || tool === undefined) {
840 const usage = 'jira-ticket-pane: usage: /jira config server=<name> tool=<name> [cloud=<id>] (or /jira config clear)'
841 host.status(usage)
842 return { text: usage }
843 }
844
845 const config: McpConfig = { server, tool, ...(cloud !== undefined ? { cloudId: cloud } : {}) }
846 state.config = config
847 void host.storeSet(STORE_KEY, config).catch(() => undefined)
848 return { text: `jira-ticket-pane calls ${tool} on ${server}` }
849}
850
851export function register(on: On) {
852 const state: State = {
853 host: null,
854 isOpen: false,
855 issueKey: null,
856 result: null,
857 error: null,
858 toolNames: [],
859 lastCall: null,
860 config: null,
861 isLoading: false,
862 fetchedAt: null,
863 isStructuredOpen: false,
864 armed: null,
865 fetchSeq: 0,
866 cloudId: null,
867 siteLines: [],
868 tab: 'issue',
869 }
870
871 // A second `prompt.submit` arriving while the first one's `next` is still in flight must not
872 // attach the same armed issue twice.
873 let carrying: Armed | null = null
874
875 on('session.start', async ($, e, next) => {
876 const host = hostOf($)
877 state.host = host
878 await host.register().catch((error: unknown) => host.log(`jira-ticket-pane: /${COMMAND} is not available: ${messageOf(error)}`))
879 state.config = configFromStore(await host.storeGet(STORE_KEY).catch(() => undefined))
880 return next(e)
881 })
882
883 on('command.run', { command: COMMAND }, async ($, e, next) => {
884 const host = state.host
885 if (host === null) return next(e)
886 const args = e.args.trim()
887
888 if (args === '') {
889 if (state.isOpen) {
890 await host.close()
891 state.isOpen = false
892 return { text: 'jira-ticket-pane hidden' }
893 }
894 await host.open()
895 state.isOpen = true
896 host.invalidate()
897 return { text: 'jira-ticket-pane shown' }
898 }
899
900 const configMatch = /^config(?:\s+(.*))?$/.exec(args)
901 if (configMatch !== null) return handleConfig(state, host, (configMatch[1] ?? '').trim())
902
903 if (KEY_RE.test(args)) {
904 if (!state.isOpen) {
905 await host.open()
906 state.isOpen = true
907 }
908 // A new key drops what is armed, so a stale issue's text cannot ride a prompt about a
909 // different one; a refetch of the same key (this branch running twice, or the refresh
910 // button) leaves it be.
911 if (state.issueKey !== args) state.armed = null
912 state.issueKey = args
913 await fetchIssue(state, args)
914 return { text: `jira-ticket-pane shows ${args}` }
915 }
916
917 // A typo should spend nothing on the real MCP server: this branch calls `host.status` alone,
918 // zero calls to `mcp.call`, `tool.list` or `fs.read`.
919 const usage = `jira-ticket-pane: "${args}" is not an issue key (expected a form such as DEMO-1)`
920 host.status(usage)
921 return { text: usage }
922 })
923
924 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
925 if (e.requestId !== PANE_ID || state.host === null) return next(e)
926 if (e.surface !== 'terminal') return next(e)
927 const { Box, Button, Link, Text } = await $.ui.resolve(e)
928 return paneOf({ Box, Button, Link, Text }, state, state.host, e.props.bodyColumns)
929 })
930
931 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
932 const result = await next(e)
933 if (result.deny === undefined) state.isOpen = false
934 return result
935 })
936
937 // The armed issue rides the next prompt as one of its context entries; the person's own prompt
938 // text passes through unchanged. Fit the text to the room the context has left, attach it on
939 // the way down, and disarm only once the prompt actually entered (a drop leaves it armed, so a
940 // refused prompt keeps the one attach the person meant to make, to spend on a later try).
941 on('prompt.submit', async ($, e, next) => {
942 const host = state.host
943 const asked = state.armed
944 if (host === null || asked === null || carrying === asked) return next(e)
945
946 const context = e.context ?? []
947 const room = PROMPT_CONTEXT_MAX_CHARS - context.reduce((sum, block) => sum + block.length, 0)
948 const text = fittedContextTextOf(contextTextOf(asked.key, asked.text), room)
949
950 if (text === undefined) {
951 state.armed = null
952 host.status(`${asked.key} did not fit in the prompt and was dropped`)
953 host.invalidate()
954 return next(e)
955 }
956
957 carrying = asked
958 try {
959 const result = await next({ ...e, context: [...context, text] })
960 if (result.drop === undefined && state.armed === asked) {
961 state.armed = null
962 host.status(undefined)
963 host.invalidate()
964 }
965 return result
966 } finally {
967 carrying = null
968 }
969 })
970}
971