SLOPSHOPPER

jira-ticket-pane

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

newpanecommandstatusprompt
v0.3.1MITupdated 2026-09-19meganemura/jira-ticket-pane/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jira-ticket-pane
│ ┃ jira-ticket-pane ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ type /jira <KEY> to show an issue ⏺ 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 │ │ › /jira │ ⎿ jira-ticket-pane: jira-ticket-pane shown │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jira-ticket-pane
────────────────────────────────────────────────────── type /jira <KEY> to show an issue
README

jira-ticket-pane

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.

Requirements

  • Claude Code with function hooks enabled: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. This is early access. The API can change between releases.
  • CI pins Claude Code version 2.1.273.
  • An MCP server connected to the session, with a tool that reads Jira issues.
  • A terminal surface. -p has no pane.

Install

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.
  • A top bar sits above the issue: the issue and meta tabs on the left, the ↻ and attach buttons on the right. The tab not open draws dim.
  • The ↻ <HH:MM> button refetches the current issue and shows the time of the last fetch. The plugin polls nothing on a timer.
  • The header, above both tabs, shows the key, the summary, the status, the type, the priority, and the assignee. The status dot takes its color from the issue's status category.
  • The issue tab shows the description.
  • The meta tab shows the fields and, under the raw json fold, the raw JSON.
  • The 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.
  • When the full text does not fit, the plugin keeps whole lines up to that room. It adds a cut note at the end of the attached text. The model reads the note. The pane does not show it.
  • When even the header does not fit, the plugin drops the attach and shows one status line.
  • A key matches [A-Z][A-Z0-9]+-\d+.

/jira config

By 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.

Fixture mode

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.

Development

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.

Limits

  • The plugin reads issues. It does not write to Jira.
  • It fetches one issue at a time.
  • It does not manage authentication. When the MCP server returns an error, the pane shows the server's error text as is.
  • The plugin has run against Atlassian's own MCP server. When the response matches that server's shape, the 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.
Source 1 files
hooks/mod.ts 971 lines
1// 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