Sencha Ext JS Claude Code Mod

hooks/register.tsx 541 lines1import type { Register, ToolCallInput } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3import type { UpgradeRun, UpgradeStep, Message } from '../types'
4
5export type Turn = { seconds: number; tools: number }
6
7const VERSION_STRING = 'v0.0.18'
8
9const PANE = 'sencha-extjs'
10const PREFIX = 'mcp__extjs-tools__'
11const spaces = 0
12const ANSWER_PREFIX = 'Sencha AI Upgrade: '
13const LOGO_SVG = 'assets/extjs-logo.svg'
14const LOGO_PNG = 'assets/extjs-logo.png'
15
16const tail = (p: string, max = 46) => (p.length > max ? '…' + p.slice(-(max - 1)) : p)
17const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
18const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
19
20const status = atom({ plugin: 'sencha-extjs', key: 'status' } as const, [] as Message[])
21const run = atom({ plugin: 'sencha-extjs', key: 'run' } as const, {})
22const steps = atom({ plugin: 'sencha-extjs', key: 'steps' } as const, [])
23const messages = atom({ plugin: 'sencha-extjs', key: 'messages' } as const, [] as Message[])
24//const last = atom({ plugin: 'turn-band', key: 'last' } as const, null)
25//const isHidden = atom({ plugin: 'turn-band', key: 'isHidden' } as const, false)
26
27// The extjs-tools results are a prose line followed by one or more JSON
28// objects, so take the last object that parses rather than the first brace.
29export function lastJson(text: string | undefined): Record<string, unknown> | undefined {
30 if (!text) return undefined
31 const end = text.lastIndexOf('}')
32 if (end < 0) return undefined
33 let found: Record<string, unknown> | undefined
34 let tries = 0
35 for (let i = text.indexOf('{'); i >= 0 && i <= end && tries < 60; i = text.indexOf('{', i + 1)) {
36 tries++
37 try {
38 const value: unknown = JSON.parse(text.slice(i, end + 1))
39 if (value && typeof value === 'object' && !Array.isArray(value)) {
40 found = value as Record<string, unknown>
41 break
42 }
43 } catch {
44 // not an object boundary; try the next brace
45 }
46 }
47 return found
48}
49
50export function absorb(tool: string, text: string | undefined, prev: UpgradeRun): UpgradeRun {
51 if (tool === 'sencha-stop') return { ...prev, serverUrl: undefined }
52 if (tool === 'sencha-web-start' || tool === 'sencha-app-watch') {
53 const url = (text ?? '').match(/https?:\/\/[^\s"']+/)
54 return { ...prev, serverUrl: url ? url[0] : prev.serverUrl }
55 }
56 const d = lastJson(text)
57 if (!d) return prev
58 const lint = (d.content && typeof d.content === 'object' ? d.content : d) as Record<string, unknown>
59 return {
60 ...prev,
61 projectType: str(d.projectType) ?? prev.projectType,
62 toolkit: str(d.toolkit) ?? prev.toolkit,
63 theme: str(d.theme) ?? prev.theme,
64 sourceVersion: str(d.sourceVersion) ?? prev.sourceVersion,
65 sourceRoot: str(d.sourceRoot) ?? str(d.sourceFolderPath) ?? prev.sourceRoot,
66 targetRoot: str(d.targetRoot) ?? prev.targetRoot,
67 lintFolders: num(lint.foldersProcessed) ?? prev.lintFolders,
68 lintFilesFixed: num(lint.filesFixed) ?? prev.lintFilesFixed,
69 lintFoldersFailed: num(lint.foldersFailed) ?? prev.lintFoldersFailed,
70 }
71}
72
73// The one argument that says what a built-in call is about: the file, folder,
74// command or query the tool acts on, for the pane's TOOL CALLS rows.
75export function subjectOf(e: ToolCallInput): string | undefined {
76 switch (e.tool) {
77 case 'Bash':
78 return e.command
79 case 'Read':
80 case 'Edit':
81 case 'Write':
82 return e.file_path
83 case 'NotebookEdit':
84 return e.notebook_path
85 case 'ToolSearch':
86 case 'WebSearch':
87 return e.query
88 case 'WebFetch':
89 return e.url
90 default:
91 return undefined
92 }
93}
94
95let tool_use_id = '';
96
97export const register: Register = on => {
98 let startedAt = 0
99 let tools = 0
100 let prefixedAnswer = false
101 let hasCommandMessage = false
102 let isExtjsPrompt = false
103 let isReExtPrompt = false
104 let taskId: string | undefined
105
106 on('session.start', async ($, e, next) => {
107 $.ui.log(`session.start: ${VERSION_STRING} At ${new Date().toLocaleString()}`)
108 await $.command.register({ name: 'extjs', description: 'Open the Sencha Ext JS side pane' })
109 $.clock.after(500, () => {
110 //void update($, messages, list => [...list, { title: 'session.start:timer', message: '500ms elapsed' }])
111 void $.ui.open({ id: PANE, title: 'Sencha Ext JS' })
112 })
113 await update($, messages, list => [...list, { title: 'session.start', message: `At ${new Date().toLocaleString()}` }]);
114 return next(e)
115 })
116
117
118
119 on('session.end', async ($, e, next) => {
120 await update($, messages, list => [...list, { title: 'session.end', message: JSON.stringify(e, null, spaces) }]);
121 // /clear starts a new conversation in the same process: its cache is a new one
122 if (e.reason === 'clear') {
123 // samples = []
124 // lastKey = ''
125 // toastedFor = 0
126 // observed = .log
127 // ttl = baseTtl
128 // ttlSource = envSource
129 $.ui.invalidate('ui.render')
130 return next(e)
131 }
132 //timer?.cancel()
133 //timer = undefined
134 return next(e)
135 })
136
137 on('command.run', { command: 'extjs' }, async $ => {
138 await update($, messages, list => [...list, { title: 'command.run', message: `command 'extjs' executed.` }]);
139 await $.ui.open({ id: PANE, title: 'Sencha Ext JS' })
140 return { text: 'Sencha Ext JS pane opened' }
141 })
142
143 on('ui.close', async ($, e, next) => {
144 await update($, messages, list => [...list, { title: 'ui.close', message: JSON.stringify(e, null, spaces) }]);
145 if (e.id !== PANE) return next(e)
146 //isPaneOpen = false
147 return next(e)
148 })
149
150 on('ui.press', async ($, e, next) => {
151 //await update($, messages, list => [...list, { title: 'ui.press', message: JSON.stringify(e, null, spaces) }]);
152 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
153 if (e.element === 'close') await $.ui.close({ id: PANE }).catch(() => undefined)
154 return next(e)
155 })
156
157 // on('skill.prompt', async ($, e, next) => {
158 // await update($, messages, l => [...l, { title: 'skill.prompt', message: e.skill }])
159 // return next(e)
160 // })
161
162 on('prompt.submit', async ($, e, next) => {
163 await update($, messages, list => [...list, { title: 'prompt.submit', message: JSON.stringify(e, null, spaces) }]);
164 //$.ui.status(`status: Started At ${startedAt}`)
165 //$.ui.toast(`toast: Started At ${startedAt}`)
166 //$.ui.log(`log: Started At ${startedAt}`)
167 return next(e)
168 })
169
170 on('turn.start', async ($, e, next) => {
171 //await update($, messages, list => [...list, { title: 'turn.start', message: JSON.stringify(e, null, spaces) }]);
172 const begin = e.text.indexOf('<command-message>')
173 const end = e.text.indexOf('</command-message>')
174 hasCommandMessage = begin >= 0 && end > begin
175 const inner = hasCommandMessage ? e.text.slice(begin + 17, end).trim() : ''
176 isExtjsPrompt = inner.startsWith('extjs')
177 isReExtPrompt = inner.startsWith('reext')
178 await update($, messages, list => [...list, { title: 'turn.start', message: `command: ${String(hasCommandMessage)} · extjs: ${String(isExtjsPrompt)} · reext: ${String(isReExtPrompt)} · ${JSON.stringify(inner.slice(0, 60))}` }]);
179 startedAt = await $.clock.now()
180 tools = 0
181 prefixedAnswer = false
182 return next(e)
183 })
184
185 on('turn.step', async function* ($, e, next) {
186 //await update($, messages, list => [...list, { title: 'turn.step', message: JSON.stringify(e, null, spaces) }]);
187 await update($, messages, list => [...list, { title: 'turn.step', message: JSON.stringify({ index: e.index, messageCount: e.messageCount }, null, spaces) }]);
188 // The shown text is the concatenation of the `text` chunks, so rewrite the
189 // first one to prefix the answer. Every step is its own model request, so
190 // anything that happened between steps (a file read, an MCP call) does not
191 // let an earlier prefix stand: this step's first text run earns its own.
192 // Within a step, a tool call (or the thinking and arguments around it)
193 // breaks the answer text, so the text after it is a new run and is
194 // prefixed too. `engine` chunks are the envelope, not a break, and are
195 // ignored so a single streamed text block is never split into two prefixes.
196 // Never re-prefix text that already carries the prefix, so a stacked or
197 // replayed hook cannot write it more than once.
198 prefixedAnswer = false
199 for await (const c of next(e)) {
200 if (c.kind !== 'text' && c.kind !== 'engine') prefixedAnswer = false
201 if (c.kind === 'text' && c.text.length > 0 && !prefixedAnswer && !c.text.startsWith(ANSWER_PREFIX)) {
202 prefixedAnswer = true
203 yield { ...c, text: `${ANSWER_PREFIX}${c.text}` }
204 } else {
205 yield c
206 //yield { ...c, text: `plain: ` }
207 }
208 }
209 })
210
211 on('turn.complete', async ($, e, next) => {
212 await update($, messages, list => [...list, { title: 'turn.complete', message: JSON.stringify(e, null, spaces) }]);
213 const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
214 const dur = `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
215 const turn: Turn = { seconds, tools }
216 //await update($, messages, list => [...list, { title: 'turn.complete', message: JSON.stringify({ usage: e.usage, seconds, tools }, null, spaces) }]);
217 await update($, messages, list => [...list, { title: 'turn.complete', message: `${dur} ${tool_use_id}` }]);
218 const result = await next(e)
219 return result;
220 //return { ...result, text: `That turn took ${seconds}s` }
221 })
222
223 on('tool.call', async ($, e, next) => {
224 tool_use_id = e.tool_use_id;
225
226 await update($, messages, list => [...list, { title: 'tool.call', message: JSON.stringify(e, null, spaces) }]);
227 await update($, messages, list => [...list, { title: 'tool.call', message: e.tool }]);
228 await update($, messages, list => [...list, { title: 'tool.call', message: tool_use_id }]);
229 //if (!e.tool.startsWith(PREFIX)) return next(e)
230 const id = e.tool_use_id
231 const tool = e.tool.startsWith(PREFIX) ? e.tool.slice(PREFIX.length) : e.tool
232 const subject = subjectOf(e)
233 await update($, steps, list => [...list, { id, tool, subject, status: 'runs' as const }].slice(-60))
234 const ran = await next(e)
235 //await update($, messages, list => [...list, { title: 'tool.call:ran', message: JSON.stringify(ran, null, spaces) }]);
236 //await update($, messages, list => [...list, { title: 'tool', message: `tool: ${tool}` }]);
237
238 if (tool === 'job-status') {
239 const result = ran.result as Array<{ text?: string }> | undefined
240 const status: any = result?.[0]?.text
241 const statusJson = JSON.parse(status)
242 //await update($, messages, list => [...list, { title: 'status', message: JSON.stringify(result?.[0]?.text, null, spaces) }]);
243 await update($, messages, list => [...list, { title: 'status', message: `${statusJson.state}: ${statusJson.percent}% - ${statusJson.message}` }]);
244 }
245
246 const blocks = ran.result as Array<{ text?: string }> | undefined
247 const text = blocks?.[0]?.text ?? ''
248 if (tool === 'task-start') {
249 try {
250 taskId = (JSON.parse(text) as { taskId?: string }).taskId ?? taskId
251 } catch {
252 // the start result was not JSON; keep the previous task id
253 }
254 let statusMessage = (JSON.parse(text) as { statusMessage?: string }).statusMessage ?? ''
255 await update($, status, list => [...list, { title: 'task-start', message: statusMessage }])
256 await update($, messages, list => [...list, { title: 'task-start', message: text }])
257 }
258 if (tool === 'task-get' && taskId !== undefined && (e as { taskId?: string }).taskId === taskId) {
259 let statusMessage = (JSON.parse(text) as { statusMessage?: string }).statusMessage ?? ''
260 await update($, status, list => [...list, { title: 'task-get', message: statusMessage }])
261 await update($, messages, list => [...list, { title: 'task-get', message: statusMessage }])
262 }
263
264 const failed = ran.isError === true || ran.deny !== undefined
265 await update($, steps, list =>
266 list.map(one => (one.id === id ? { ...one, status: failed ? ('fail' as const) : ('done' as const) } : one)),
267 )
268 if (!failed) await update($, run, prev => absorb(tool, ran.text, prev))
269 return ran
270 // Bookkeeping only: never let a pane failure change what the tool does.
271 }).catch(($, e, next) => next(e))
272
273 // on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
274 // const ran = await next({ ...e, command: e.command.trim() })
275 // const hasFailed = ran.deny === undefined && ran.isError === true
276 // $.ui.status(hasFailed ? `failed: ${e.command.slice(0, 40)}` : undefined)
277 // return ran
278 // })
279
280 // // The matcher limits the hook to Bash calls, so e.command is the shell command
281 // on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
282 // //if (/git push .*--force/.test(e.command)) {
283 // if (/ls/.test(e.command)) {
284 // // Returning without calling next answers the event, so the command never runs
285 // // return { deny: 'Force pushes are not allowed in this repository. Push to a new branch instead.' }
286 // return { deny: 'ls is not allowed.' }
287 // }
288 // // Every other command goes on to the permission check and then to Bash
289 // return next(e)
290 // })
291
292 // on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
293 // const RISKY = /\brm\s+-rf?\b|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*--force/
294
295 // // Let every other command through without a question
296 // if (!RISKY.test(e.command)) return next(e)
297 // // Start from the safe answer, so a question nobody answers refuses the command
298 // let answer = 'Refuse'
299 // try {
300 // // The tool call waits here until the user picks one of the two labels
301 // answer = await $.ui.ask('Run this command? ' + e.command, ['Run it', 'Refuse'])
302 // } catch {
303 // // The user dismissed the question, or this is a claude -p run with nobody to ask
304 // }
305 // if (answer !== 'Run it') {
306 // // Answer without calling next, so the command doesn't run
307 // return { deny: 'The user declined this command. Ask before trying a different approach.' }
308 // }
309 // return next(e)
310 // })
311
312 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
313 const { Box, Text, Markdown } = $.ui.resolve(e)
314 // The streamed text carries the plain prefix (for ctrl+o and exports);
315 // drawing swaps it for a coloured chip so the live view is not doubled.
316 const text = e.props.text
317 if (!text.startsWith(ANSWER_PREFIX)) return next(e)
318 const body = <Markdown text={'Hello2 ' + text.slice(ANSWER_PREFIX.length)} />
319 // The chip marks a turn that an extjs command-message started: only those
320 // draw it, every other prefixed message shows its content without the label.
321 if (hasCommandMessage && (isExtjsPrompt || isReExtPrompt)) {
322 return (
323 <Box flexDirection="column">
324 <Text color="yellow" bold>hello {ANSWER_PREFIX.trim()}</Text>
325 {body}
326 </Box>
327 )
328 }
329 return body
330 });
331
332 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
333 const { Box, Text } = $.ui.resolve(e)
334 const beneath = await next(e)
335 return (
336 <Box flexDirection="column">
337 <Box flexDirection="row">
338 <Text color="cyan" italic>Sencha Ext JS</Text>
339 <Text dimColor> · </Text>
340 <Text color="yellow" italic>Classic toolkit · Classic theme</Text>
341 </Box>
342 {beneath}
343 </Box>
344 )
345 });
346
347 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
348 if (e.requestId !== tool_use_id) return next(e)
349 await update($, messages, list => [...list, { title: 'ui.render:ToolProgress', message: JSON.stringify(e, null, spaces) }]);
350 const { Box, Text } = $.ui.resolve(e)
351 return Box({
352 flexDirection: 'row',
353 columnGap: 1,
354 children: [
355 Text({ color: 'cyan', children: ['⚡ Custom Progress:'] }),
356 // Call next(e) to retain engine defaults, or return your own custom elements
357 await next(e),
358 ]
359 });
360 })
361
362 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
363 if (e.requestId !== tool_use_id) return next(e)
364 await update($, messages, list => [...list, { title: 'ui.render:ToolUse', message: JSON.stringify(e, null, spaces) }]);
365 const { Box, Text } = $.ui.resolve(e)
366 return Box({
367 flexDirection: 'row',
368 columnGap: 1,
369 children: [
370 Text({ color: 'cyan', children: ['⚡ Custom Tool Use:'] }),
371 // Call next(e) to retain engine defaults, or return your own custom elements
372 await next(e),
373 ]
374 });
375 })
376
377
378 // on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
379 // await update($, messages, list => [...list, { title: 'a', message: JSON.stringify(e, null, spaces) }]);
380 // return next(e)
381 // })
382
383
384 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
385 const { Box, Text, Button } = $.ui.resolve(e)
386 const r: UpgradeRun = await read($, run)
387 const list: UpgradeStep[] = await read($, steps)
388 const feed: Message[] = await read($, messages)
389 const statuses: Message[] = await read($, status)
390 const facts: { k: string; v: string; color?: string }[] = []
391 if (r.projectType) {
392 facts.push({
393 k: 'Project',
394 v: [r.projectType, r.toolkit && `toolkit ${r.toolkit}`, r.theme].filter(Boolean).join(' · '),
395 })
396 }
397 if (r.sourceVersion) facts.push({ k: 'Version', v: `${r.sourceVersion} → v8` })
398 if (r.sourceRoot) facts.push({ k: 'Source', v: tail(r.sourceRoot) })
399 if (r.targetRoot) facts.push({ k: 'Target', v: tail(r.targetRoot) })
400 if (r.lintFolders !== undefined) {
401 const bad = (r.lintFoldersFailed ?? 0) > 0
402 facts.push({
403 k: 'Lint',
404 v: `${r.lintFolders} folders · ${r.lintFilesFixed ?? 0} fixed · ${r.lintFoldersFailed ?? 0} failed`,
405 color: bad ? 'error' : 'success',
406 })
407 }
408 facts.push({
409 k: 'Server',
410 v: r.serverUrl ?? 'stopped',
411 color: r.serverUrl ? 'success' : undefined,
412 })
413
414 let version =
415 <Text color="cyan" italic>{VERSION_STRING}</Text>
416
417 let buttons =
418 <Box flexDirection="row" marginTop={1}>
419 <Box borderStyle='single' marginLeft={0} paddingX={1} paddingY={0}>
420 <Button plain={true} key="clear" label="clear" onPress={async () => {
421 //$.ui.log(`clear: At ${new Date().toLocaleString()}`)
422 await update($, run, () => ({}))
423 await update($, steps, () => [])
424 await update($, messages, () => [])
425 await update($, status, () => [])
426 }}></Button>
427 </Box>
428 <Box borderStyle='single' marginLeft={2} paddingX={1} paddingY={0}>
429 <Button plain={true} key="close" label="close" onPress={() => { }} />
430 </Box>
431 </Box>;
432
433 let body =
434 <Box flexDirection="column">
435 <Box marginTop={2}><Text color="yellow" italic>FACTS</Text></Box>
436 {
437 facts.map(f => (
438 <Text key={`k-${f.k}`} color={f.color}>
439 {`${f.k.padEnd(8)}${f.v}`}
440 </Text>
441 ))
442 }
443
444 <Box marginTop={2}><Text color="yellow" italic>STATUS</Text></Box>
445 {
446 statuses.length === 0 ? (
447 <Text dimColor>No status yet.</Text>
448 ) : (
449 statuses.map((s, i) => (
450 <Text key={`step${i}`}>
451 <Text color="cyan">{s.title}</Text>
452 <Text dimColor> {s.message}</Text>
453 </Text>
454 ))
455 )
456 }
457
458 <Box marginTop={2}><Text color="yellow" italic>TOOL CALLS</Text></Box>
459 {
460 list.length === 0 ? (
461 <Text dimColor>No tool calls yet.</Text>
462 ) : (
463 list.map(s => (
464 <Box key={s.id} flexDirection="row" alignItems="flex-start">
465 <Box width={2} flexShrink={0}>
466 <Text color={s.status === 'fail' ? 'error' : s.status === 'done' ? 'success' : 'warning'}>
467 {s.status === 'runs' ? '•' : s.status === 'done' ? '✓' : '✗'}
468 </Text>
469 </Box>
470 <Text color={s.status === 'fail' ? 'error' : s.status === 'done' ? 'success' : 'warning'}>
471 {s.tool}
472 {s.subject ? <Text dimColor>{` · ${tail(s.subject, 1280)}`}</Text> : null}
473 </Text>
474 </Box>
475 ))
476 )
477 }
478 <Box marginTop={2}><Text color="yellow" italic>STEPS</Text></Box>
479 {
480 feed.length === 0 ? (
481 <Text dimColor>No steps yet.</Text>
482 ) : (
483 feed.map((s, i) => (
484 <Text key={`step${i}`}>
485 <Text color="cyan">{s.title}</Text>
486 <Text dimColor> {s.message}</Text>
487 </Text>
488 ))
489 )
490 }
491 </Box>;
492
493 let bottom =
494 <Box flexDirection="row" marginTop={1}>
495 <Box borderStyle='single' marginLeft={0} paddingX={1} paddingY={0}>
496 <Button plain={true} key="clear" label="clear" onPress={async () => {
497 //$.ui.log(`clear: At ${new Date().toLocaleString()}`)
498 await update($, run, () => ({}))
499 await update($, steps, () => [])
500 await update($, messages, () => [])
501 await update($, status, () => [])
502 }}></Button>
503 </Box>
504 <Box borderStyle='single' marginLeft={2} paddingX={1} paddingY={0}>
505 <Button plain={true} key="close" label="close" onPress={() => { }} />
506 </Box>
507 </Box>;
508
509 if (e.surface !== 'terminal') {
510 const { Box, Text, Svg } = $.ui.resolve(e)
511 return (
512 <Box flexDirection="column">
513 <Box flexDirection="row">
514 <Svg source={await $.fs.read(`${$.plugin.root}/${LOGO_SVG}`)} width={65} height={25} alt="Sencha Ext JS" />
515 <Box marginLeft={2}>{version}</Box>
516 <Box marginLeft={2}><Text color="yellow" italic>{new Date().toLocaleString()}</Text></Box>
517 </Box>
518 {buttons}
519 {body}
520 {bottom}
521 </Box>
522 )
523 }
524
525 // terminal surface
526 const { Image } = $.ui.resolve(e)
527 return (
528 <Box flexDirection="column">
529 <Box flexDirection="row">
530 <Image source={{ png: (await $.fs.read(`${$.plugin.root}/${LOGO_PNG}`, { as: 'bytes' })).base64 }} columns={10} rows={1} alt="Sencha Ext JS" />
531 <Box marginLeft={2}>{version}</Box>
532 <Box marginLeft={2}><Text color="yellow" italic>{new Date().toLocaleString()}</Text></Box>
533 </Box>
534 {buttons}
535 {body}
536 {bottom}
537 </Box>
538 )
539 })
540}
541types/index.d.ts 36 lines1export type UpgradeRun = {
2 projectType?: string
3 toolkit?: string
4 theme?: string
5 sourceVersion?: string
6 sourceRoot?: string
7 targetRoot?: string
8 lintFolders?: number
9 lintFilesFixed?: number
10 lintFoldersFailed?: number
11 serverUrl?: string
12}
13
14export type UpgradeStep = {
15 id: string
16 tool: string
17 subject?: string
18 status: 'runs' | 'done' | 'fail'
19}
20
21export type Message = {
22 title: string
23 message: string
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'sencha-extjs': {
29 status: Message[];
30 run: UpgradeRun;
31 steps: UpgradeStep[];
32 messages: Message[]
33 }
34 }
35}
36