Sencha Claude Code Mod

Marketplace of several Claude Code Plugins
# set the marketplace
claude plugin marketplace add mgusmano/gusmano-marketplace
# install the plugin
claude plugin install sencha@gusmano-marketplace
claude plugin uninstall extjs-sencha@gusmano-marketplace
# update the plugin
claude plugin update sencha@gusmano-marketplace
Now you can run "/extjs" inside claude code to open the Sencha Ext JS Pane
# set the marketplace
claude plugin marketplace add mgusmano/gusmano-marketplace
# install the plugin
claude plugin install sencha@gusmano-marketplace
# update the plugin
claude plugin update sencha@gusmano-marketplace
Now you can run "/sencha" inside claude code to open the Sencha Pane
claude plugin update sencha-extjs@gusmano-marketplace
claude plugin install skins@gusmano-marketplace
claude plugin install context-weather@gusmano-marketplacehooks/register.tsx 478 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface, RenderSurface, PaneOpenArgs, Args, Frozen, Next } from 'claude-code'
3import { versionString, doError, renderMessage, clearAll, appendLines, startProgress, progressFinished, relativeTo, PANE, LOGO_SVG, LOGO_PNG } from './helpers'
4import { commandSencha } from './commands/commandSencha'
5import { commandCallit } from './commands/commandCallit'
6import { commandAsk } from './commands/commandAsk'
7import type { ButtonProps } from './components/button'
8import type { ExpanderProps } from './components/expander'
9import { HoverHint } from './components/hoverhint'
10import { Header } from './components/header'
11import { HELP_PANE, HELP_MARKDOWN } from './helpers/help'
12import {
13 sections, expanderProps, expandAll, collapseAll, toggleSection, addItem, addSection,
14 addItemToLatestSection, setSectionStatus, runTests,
15} from './helpers/sections'
16
17const spaces = 0
18
19export type Fill = number | null
20const percent = atom({ plugin: 'sencha', key: 'percent' } as const, null)
21const tokens = atom({ plugin: 'sencha', key: 'tokens' } as const, null)
22const window = atom({ plugin: 'sencha', key: 'window' } as const, null)
23const costAtom = atom({ plugin: 'sencha', key: 'cost' } as const, null)
24
25// Reads the live window's fill into the atom; the write redraws the band.
26// Never throws: a failed read must not hold up the prompt it gates.
27const refresh = async ($: EngineInterface) => {
28 try {
29 //const { version, base, builtAt } = await $.session.version()
30 //const all = await $.session.version()
31 //$.ui.log(`version ${JSON.stringify(all)}`)
32 //const usage = await $.session.usage()
33 //$.ui.log(`usage ${JSON.stringify(usage)}`)
34 const { context, cost } = await $.session.usage()
35 //$.ui.log(`context ${JSON.stringify(context)}`)
36 let fill: Fill;
37
38 fill = context.tokens ?? null
39 await update($, tokens, () => fill)
40
41 fill = context.window ?? null
42 await update($, window, () => fill)
43
44 fill = context.percent ?? null
45 await update($, percent, () => fill)
46
47 fill = cost?.usd ?? null
48 await update($, costAtom, () => fill)
49
50 } catch {
51 // keep the last reading
52 }
53}
54
55export const register: Register = on => {
56 let startedAt = 0
57 let sessionCwd = ''
58 let hoverHints: Partial<Record<'above' | 'pane', string>> = {}
59
60 on('session.start', async ($, e, next) => {
61 //$.ui.log(`session.start ${e.cwd}`)
62 await refresh($)
63 sessionCwd = e.cwd
64 await $.command.register({ name: 'sencha', description: 'open the Sencha side pane' })
65 await $.command.register({ name: 'callit', description: 'call a tool' })
66 await $.command.register({ name: 'ask', description: 'ask example' })
67
68 await addSection($, 'session.start', `cwd: ${e.cwd}`)
69 await addItemToLatestSection($, `at: ${new Date().toLocaleString()}`, '#c4b5fd', true)
70 await addItemToLatestSection($, `cwd: ${e.cwd}`, '#c4b5fd', true)
71 await addItemToLatestSection($, `sessionid: ${await $.session.id()}`, '#c4b5fd', true)
72 return next(e)
73 }).catch(($, e, next) => { doError($, e, next); return next(e) });
74
75 on('ui.close', async ($, e, next) => {
76 const message = JSON.stringify(e, null, spaces)
77 await addSection($, 'ui.close', message)
78
79 if (e.id !== PANE) return next(e)
80 //isPaneOpen = false
81 return next(e)
82 }).catch(($, e, next) => { doError($, e, next); return next(e) });
83
84 on('ui.press', async ($, e, next) => {
85 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
86 if (e.element === 'close') await $.ui.close({ id: PANE }).catch(() => undefined)
87 return next(e)
88 }).catch(($, e, next) => { doError($, e, next); return next(e) });
89
90 on('session.end', async ($, e, next) => {
91 if (e.reason === 'clear') {
92 $.ui.invalidate('ui.render')
93 return next(e)
94 }
95 return next(e)
96 }).catch(($, e, next) => { doError($, e, next); return next(e) });
97
98 on('command.run', { command: 'sencha' }, async $ => commandSencha($)).catch(($, e, next) => { doError($, e, next); return next(e) });
99 on('command.run', { command: 'callit' }, async $ => commandCallit($)).catch(($, e, next) => { doError($, e, next); return next(e) });
100 on('command.run', { command: 'ask' }, async ($, e, next) => commandAsk($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
101
102 on('prompt.submit', async ($, e, next) => {
103 await refresh($)
104 await addSection($, 'prompt.submit', e.text)
105 return next(e)
106 }).catch(($, e, next) => { doError($, e, next); return next(e) });
107
108 on('turn.start', async ($, e, next) => {
109 let hasCommandMessage = false
110 let isExtjsPrompt = false
111 let isReExtPrompt = false
112 const begin = e.text.indexOf('<command-message>')
113 const end = e.text.indexOf('</command-message>')
114 hasCommandMessage = begin >= 0 && end > begin
115 const inner = hasCommandMessage ? e.text.slice(begin + 17, end).trim() : ''
116 isExtjsPrompt = inner.startsWith('extjs')
117 isReExtPrompt = inner.startsWith('reext')
118 const message = `hasCommandMessage: ${String(hasCommandMessage)} · isExtjsProject: ${String(isExtjsPrompt)} · isReExtPrompt: ${String(isReExtPrompt)} · commandMessage: ${inner.slice(0, 60)}`
119 await addSection($, 'turn.start', message)
120 startedAt = await $.clock.now()
121 return next(e)
122 }).catch(($, e, next) => { doError($, e, next); return next(e) });
123
124 on('turn.step', async function* (_, e, next) {
125 yield* next(e);
126 }).catch(async function* ($, e, next) { doError($, e, next); yield* next(e); });
127
128 on('turn.complete', async ($, e, next) => {
129 await refresh($)
130 const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
131 const dur = `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
132 const message = `time: ${dur}`;
133 await addSection($, 'turn.complete', message)
134 const result = await next(e)
135 return result;
136 }).catch(($, e, next) => { doError($, e, next); return next(e) });
137
138 on('tool.call', async ($, e, next) => {
139 const isProgress = 'progress' in e ? e.progress : undefined;
140 let stop = false;
141 let out: any;
142 let detail: string | undefined
143 if (e.tool === 'Bash') detail = (e as { description?: string }).description
144 if (e.tool === 'Read') detail = relativeTo(sessionCwd, e.file_path)
145 if (e.tool === 'Edit') detail = relativeTo(sessionCwd, (e as { file_path?: string }).file_path)
146 if (e.tool === 'Write') detail = relativeTo(sessionCwd, (e as { file_path?: string }).file_path)
147 const sectionId = await addSection($, 'tool.call', e.tool, detail, { status: 'runs' })
148 for (const [key, value] of Object.entries(e)) {
149 if (key === 'tool' || key === 'tool_use_id' || value === undefined) continue
150 const text = typeof value === 'string' ? value : JSON.stringify(value)
151 await addItemToLatestSection($, `${key}: ${text}`, '#c4b5fd', true)
152 }
153 let theResult;
154 if (isProgress) {
155 // The tool's own result can arrive before its last progress lines. Keep polling until the
156 // progress says it is done, so every line lands in this section before the next tool starts.
157 const testGlobalGetTool = `mcp__plugin_sencha_extjs-tools__test-global-get`;
158 const testGlobalGetName = 'test-global-get';
159 const readProgress = async () => {
160 const testGlobalGetResult: any = await $.tool.call({ tool: testGlobalGetTool, name: testGlobalGetName })
161 return JSON.parse(testGlobalGetResult.result[0].text).data as { name?: string }[]
162 }
163 // Count the server's list before this tool starts, so its leftover lines and "polling done" are skipped
164 startProgress((await readProgress()).length)
165 out = next(e).then(toolResult => { theResult = toolResult })
166 let polledDone = false
167 let pollsAfterResult = 0
168 while (!polledDone) {
169 //$.ui.log(`in polling while loop for ${e.tool}`)
170 const data = await readProgress()
171 await appendLines($, data)
172 if (progressFinished(data)) polledDone = true
173 // safety: if the progress never says done, stop a few polls after the tool's own result
174 if (theResult !== undefined && ++pollsAfterResult >= 3) stop = true
175 if (stop) break
176 await $.clock.sleep(500)
177 }
178 await out
179 }
180 else {
181 out = await next(e)
182 theResult = out;
183 }
184 const failed = theResult != null && (theResult.isError === true || theResult.deny !== undefined)
185 if (failed === true) {
186 await addItemToLatestSection($, theResult.result)
187 }
188 setSectionStatus($, sectionId, failed ? 'fail' : 'done')
189 return theResult;
190 }).catch((_, e, next) => next(e))
191
192 // on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
193 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
194 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
195 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
196 //on('ui.render', { component: 'Pane' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
197 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
198 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
199 on('ui.render', { component: 'Spinner' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
200 // on('ui.render', { component: 'ToolGroup'}, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
201 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
202 //on('ui.render', { component: 'ToolResult' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
203 //on('ui.render', { component: 'ToolUse' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
204 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
205 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => renderMessage($, e, next)).catch(($, e, next) => { doError($, e, next); return next(e) });
206
207 // Any button that reports hover: its hint shows in the status bar and in the spot for its place.
208 on('ui.message', async ($, e, next) => {
209 const data = e.data as { over?: boolean; hint?: string; place?: 'above' | 'pane' } | undefined
210 if (data?.over === undefined || data.place === undefined) return next(e)
211 const hint = data.over ? data.hint : undefined
212 hoverHints = { ...hoverHints, [data.place]: hint }
213 $.ui.invalidate('ui.render')
214 return next(e)
215 }).catch(($, e, next) => { doError($, e, next); return next(e) });
216
217 // expander messages
218 on('ui.message', { element: 'expander' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
219 const data = e.data as {
220 toggle?: { section: string; open: boolean }
221 }
222 if (data?.toggle) {
223 toggleSection(data.toggle.section, data.toggle.open)
224 return { props: expanderProps() }
225 }
226 return next(e)
227 }).catch(($, e, next) => { doError($, e, next); return next(e) });
228
229 // test messages
230 on('ui.message', { element: 'toggle' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
231 if ((e.data as { press?: boolean }).press === true) {
232 const all = sections().length > 0 && sections().every(s => s.expanded)
233 if (all) collapseAll($)
234 else expandAll($)
235 }
236 return next(e)
237 }).catch(($, e, next) => { doError($, e, next); return next(e) });
238
239 on('ui.message', { element: 'add-section' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
240 if ((e.data as { press?: boolean }).press === true) {
241 await addSection($)
242 }
243 return next(e)
244 }).catch(($, e, next) => { doError($, e, next); return next(e) });
245
246 on('ui.message', { element: 'add-item-latest' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
247 if ((e.data as { press?: boolean }).press === true) {
248 await addItemToLatestSection($)
249 }
250 return next(e)
251 }).catch(($, e, next) => { doError($, e, next); return next(e) });
252
253 on('ui.message', { element: 'test' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
254 if ((e.data as { press?: boolean }).press === true) {
255 void runTests($).catch(err => doError($, err))
256 }
257 return next(e)
258 }).catch(($, e, next) => { doError($, e, next); return next(e) });
259
260 // AbovePrompt messages
261 on('ui.message', { element: 'extjs-upgrade' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
262 if ((e.data as { press?: boolean }).press === true) {
263 await commandSencha($).catch(err => doError($, err))
264 await $.command.run({ command: 'extjs-upgrade', args: '' }).catch(err => doError($, err))
265 }
266 return next(e)
267 }).catch(($, e, next) => { doError($, e, next); return next(e) });
268
269 on('ui.message', { element: 'extjs-dependency-report' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
270 if ((e.data as { press?: boolean }).press === true) {
271 await commandSencha($).catch(err => doError($, err))
272 await $.command.run({ command: 'extjs-dependency-report', args: '' }).catch(err => doError($, err))
273 }
274 return next(e)
275 }).catch(($, e, next) => { doError($, e, next); return next(e) });
276
277 on('ui.message', { element: 'sencha' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
278 if ((e.data as { press?: boolean }).press === true) {
279 await commandSencha($).catch(err => doError($, err))
280 }
281 return next(e)
282 }).catch(($, e, next) => { doError($, e, next); return next(e) });
283
284 on('ui.message', { element: 'delete-v8' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
285 if ((e.data as { press?: boolean }).press === true) {
286 const answer = await $.ui.ask('Are you sure you want to delete V8?', ['Delete', 'Cancel']).catch(() => undefined)
287 if (answer !== 'Delete') return next(e)
288 await $.process.run(['rm', '-rf', 'v8'])
289 .then(({ exitCode, stderr }) => {
290 if (exitCode === 0) $.ui.toast('v8 folder deleted')
291 else $.ui.toast(`delete v8 failed: ${stderr.trim()}`)
292 })
293 .catch(err => doError($, err))
294 }
295 return next(e)
296 }).catch(($, e, next) => { doError($, e, next); return next(e) });
297
298 on('ui.message', { element: 'reext-migrate' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
299 if ((e.data as { press?: boolean }).press === true) {
300 await commandSencha($).catch(err => doError($, err))
301 await $.command.run({ command: 'reext-migrate', args: '' }).catch(err => doError($, err))
302 }
303 return next(e)
304 }).catch(($, e, next) => { doError($, e, next); return next(e) });
305
306 on('ui.message', { element: 'help' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
307 if ((e.data as { press?: boolean }).press === true) {
308 await $.ui.open({ id: HELP_PANE, title: 'About Sencha', focus: true, closeOnEscape: true }).catch(err => doError($, err))
309 }
310 return next(e)
311 }).catch(($, e, next) => { doError($, e, next); return next(e) });
312
313 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
314 const tokensVal = await read($, tokens)
315 const windowVal = await read($, window)
316 const percentVal = await read($, percent)
317 const costVal = await read($, costAtom)
318 const costValSmall = Math.round((costVal ?? 0) * 100) / 100
319
320 if (e.surface === 'desktop' || e.surface === 'terminal') {
321 const ui = $.ui.resolve(e)
322 const { Box, Text, Client } = ui
323 const beneath = await next(e)
324 //$.ui.log(`beneath ${JSON.stringify(beneath)}`)
325 let marginBottom = 0;
326 if (beneath.type !== 'engine') {
327 marginBottom = 1
328 }
329 const { Svg, Image } = ui as { Svg?: any; Image?: any }
330 const header = e.surface === 'desktop'
331 ? <Header Box={Box} Text={Text} Svg={Svg} logo={await $.fs.read(`${$.plugin.root}/${LOGO_SVG}`)} version={await versionString($)} />
332 : <Header Box={Box} Text={Text} Image={Image} logo={{ png: (await $.fs.read(`${$.plugin.root}/${LOGO_PNG}`, { as: 'bytes' })).base64 }} version={await versionString($)} />
333
334 return (
335 <Box flexDirection="column" marginLeft={0} marginTop={0} marginBottom={marginBottom} >
336 <Box flexDirection="row">
337 {header}
338 <Box marginLeft={2} flexGrow={1}><HoverHint Box={Box} Text={Text} hint={hoverHints.above} /></Box>
339 <Client key="help" module="./components/button.tsx" props={{ id: 'help', label: 'about', dim: false, surface: e.surface, place: 'above', hint: 'describe the Sencha Claude Code Mod' } satisfies ButtonProps} />
340 </Box>
341 <Box flexDirection="row" marginTop={1} marginRight={0} marginBottom={0} marginLeft={0}>
342 <Client key="extjs-dependency-report" module="./components/button.tsx" props={{ id: 'extjs-dependency-report', label: '/extjs-dependency-report', dim: false, surface: e.surface, place: 'above', hint: `run the 'extjs-dependency-report' Skill` } satisfies ButtonProps} />
343 <Client key="extjs-upgrade" module="./components/button.tsx" props={{ id: 'extjs-upgrade', label: '/extjs-upgrade', dim: false, surface: e.surface, place: 'above', hint: `run the 'extjs-upgrade' Skill` } satisfies ButtonProps} />
344 <Client key="reext-migrate" module="./components/button.tsx" props={{ id: 'reext-migrate', label: '/reext-migrate', dim: false, surface: e.surface, place: 'above', hint: `run the 'reext-migrate' Skill` } satisfies ButtonProps} />
345 </Box>
346 <Box flexDirection="row" marginTop={1} marginRight={0} marginBottom={0} marginLeft={0}>
347 <Client key="sencha" module="./components/button.tsx" props={{ id: 'sencha', label: 'show side pane', dim: false, surface: e.surface, place: 'above', hint: 'open the Sencha side pane' } satisfies ButtonProps} />
348 <Client key="delete-v8" module="./components/button.tsx" props={{ id: 'delete-v8', label: 'delete v8 folder', dim: false, surface: e.surface, place: 'above', hint: 'run rm -rf v8 in the session folder' } satisfies ButtonProps} />
349 </Box>
350 {/* <Box borderStyle='single'>
351 <Box flexGrow={1}></Box>
352 <Text color="#fb923c">{tokensVal}/{windowVal} {percentVal}% ${costValSmall.toFixed(2)}</Text>
353 </Box> */}
354 {beneath}
355 </Box>
356 )
357 }
358
359 if (e.surface === 'vscode') {
360 const { Text } = $.ui.resolve(e)
361 return (<Text>vscode</Text>)
362 }
363
364 if (e.surface === 'mobile') {
365 const { Text } = $.ui.resolve(e)
366 return (<Text>mobile</Text>)
367 }
368
369 return next(e)
370 }).catch(($, e, next) => { doError($, e, next); return next(e) });
371
372 // Pane messages
373 on('ui.message', { element: 'clear' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
374 if ((e.data as { press?: boolean }).press === true) {
375 void clearAll($).catch(err => doError($, err))
376 }
377 return next(e)
378 }).catch(($, e, next) => { doError($, e, next); return next(e) });
379
380 on('ui.message', { element: 'close' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
381 if ((e.data as { press?: boolean }).press === true) {
382 await $.ui.close({ id: PANE }).catch(() => undefined)
383 }
384 return next(e)
385 }).catch(($, e, next) => { doError($, e, next); return next(e) });
386
387 on('ui.message', { element: 'bottom' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
388 if ((e.data as { press?: boolean }).press === true) {
389 await $.ui.scroll({ in: PANE, to: 'end' })
390 }
391 return next(e)
392 }).catch(($, e, next) => { doError($, e, next); return next(e) });
393
394 on('ui.message', { element: 'top' }, async ($: EngineInterface, e: Frozen<Args<'ui.message'>>, next: Next<'ui.message'>) => {
395 if ((e.data as { press?: boolean }).press === true) {
396 await $.ui.scroll({ in: PANE, to: 'start' })
397 }
398 return next(e)
399 }).catch(($, e, next) => { doError($, e, next); return next(e) });
400
401 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
402 const { Box, Text } = $.ui.resolve(e)
403 try {
404 const allExpanded = sections().length > 0 && sections().every(s => s.expanded)
405 const top = (Client: any, surface: RenderSurface) => (
406 <Box flexDirection="row" marginTop={1}>
407 <Client key="bottom" module="./components/button.tsx" props={{ id: 'bottom', label: 'go to bottom', dim: false, surface, place: 'pane', hint: 'go to the bottom of the panel' } satisfies ButtonProps} />
408 <Client key="clear" module="./components/button.tsx" props={{ id: 'clear', label: 'clear', dim: false, surface, place: 'pane', hint: 'Clear All Steps' } satisfies ButtonProps} />
409 <Box flexGrow={1} />
410 <Client key="close" module="./components/button.tsx" props={{ id: 'close', label: 'close', dim: false, surface, place: 'pane', hint: 'Close' } satisfies ButtonProps} />
411 </Box>
412 )
413 const test = (Client: any, surface: RenderSurface) => (
414 <Box flexDirection="row" marginTop={1}>
415 <Client key="test" module="./components/button.tsx" props={{ id: 'test', label: 'Test', dim: false, surface, place: 'pane', hint: 'Add 10 test sections' } satisfies ButtonProps} />
416 <Client key="add-section" module="./components/button.tsx" props={{ id: 'add-section', label: 'Add Section', dim: false, surface, place: 'pane', hint: 'Add Section' } satisfies ButtonProps} />
417 <Client key="add-item-latest" module="./components/button.tsx" props={{ id: 'add-item-latest', label: 'Add Item to Latest Section', dim: false, surface, place: 'pane', hint: 'Add Item' } satisfies ButtonProps} />
418 <Client key="toggle" module="./components/button.tsx" props={{ id: 'toggle', label: allExpanded ? 'Collapse All' : 'Expand All', dim: false, surface, place: 'pane', hint: 'Toggle' } satisfies ButtonProps} />
419 </Box>
420 )
421 const bottom = (Client: any, surface: RenderSurface) => (
422 <Box flexDirection="row" marginTop={1}>
423 <Client key="top" module="./components/button.tsx" props={{ id: 'top', label: 'go to top', dim: false, surface, place: 'pane', hint: 'go to the top of the panel' } satisfies ButtonProps} />
424 </Box>
425 )
426
427 if (e.surface === 'desktop' || e.surface === 'terminal') {
428 const ui = $.ui.resolve(e)
429 const { Box, Text, Client } = ui
430 const dropped: string[] = []
431 const expander = expanderProps(dropped)
432 if (dropped.length > 0) $.ui.log(`expander props: dropped ${dropped.join(', ')}`)
433 const { Svg, Image } = ui as { Svg?: any; Image?: any }
434 const header = e.surface === 'desktop'
435 ? <Header Box={Box} Text={Text} Svg={Svg} logo={await $.fs.read(`${$.plugin.root}/${LOGO_SVG}`)} version={await versionString($)} />
436 : <Header Box={Box} Text={Text} Image={Image} logo={{ png: (await $.fs.read(`${$.plugin.root}/${LOGO_PNG}`, { as: 'bytes' })).base64 }} version={await versionString($)} />
437 return (
438 <Box flexDirection="column">
439 <Box flexDirection="row">
440 {header}
441 <Box marginLeft={2}><HoverHint Box={Box} Text={Text} hint={hoverHints.pane} /></Box>
442 </Box>
443 {top(Client, e.surface)}
444 {test(Client, e.surface)}
445 <Box marginTop={1}><Text color="yellow" italic>STEPS</Text></Box>
446 {dropped.length > 0 ? <Box marginTop={1}><Text color="error">{`expander props: dropped ${dropped.join(', ')}`}</Text></Box> : null}
447 <Client key="expander" module="./components/expander.tsx" props={expander satisfies ExpanderProps} />
448 {bottom(Client, e.surface)}
449 </Box>
450 )
451 }
452
453 if (e.surface === 'vscode') {
454 return (<Text>vscode</Text>)
455 }
456
457 if (e.surface === 'mobile') {
458 return (<Text>mobile</Text>)
459 }
460
461 return next(e)
462 }
463 catch (error) {
464 return (<Text>register Error</Text>)
465 }
466 }).catch(($, e, next) => { doError($, e, next); return next(e) });
467
468 on('ui.render', { component: 'Pane', requestId: HELP_PANE }, async ($, e, next) => {
469 const { Box, Markdown } = $.ui.resolve(e)
470 return (
471 <Box flexDirection="column">
472 <Markdown text={HELP_MARKDOWN} />
473 </Box>
474 )
475 }).catch(($, e, next) => { doError($, e, next); return next(e) })
476
477}
478hooks/helpers/index.tsx 180 lines1import type { EngineInterface, Hook, RenderInput, ToolCallInput } from 'claude-code'
2import { addItemToLatestSection, resetSections } from './sections'
3export const LOGO_SVG = 'assets/rapid.svg'
4export const LOGO_PNG = 'assets/rapid.png'
5
6//$.ui.status(`status: Started At ${startedAt}`)
7//$.ui.toast(`toast: Started At ${startedAt}`)
8//$.ui.log(`log: Started At ${startedAt}`)
9
10// on('ui.open', async ($, e, next) => {
11// $.ui.log(`ui.open: ${await versionString($)} At ${new Date().toLocaleString()}`)
12// return next(e)
13// }).catch(($, e, next) => { doError($, e); return next(e) });
14
15// on('ui.log', ($, e, next) => {
16// // const { Box, Text } = $.ui.resolve(e)
17// // documented rewrite: keep the line off screen (debug log only)
18
19// // return (
20// // <Box>
21// // <Text>Hello there</Text>
22// // </Box>
23// // )
24
25// return next({ ...e, to: "debug" })
26// })
27
28export async function versionString($: EngineInterface): Promise<string> {
29 try {
30 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }
31 return typeof manifest.version === 'string' ? `v${manifest.version}` : 'v?'
32 } catch {
33 return 'v?'
34 }
35}
36
37export function doError($: any, e: any, next?: { error?: { kind?: string; message?: string } }) {
38 const f = next?.error
39 const why = f ? ` (${f.kind}: ${f.message ?? ''})` : ''
40 // JSON.stringify drops an Error's message, so show name and message for those
41 const detail = e instanceof Error || typeof e?.message === 'string' ? `${e.name}: ${e.message}` : JSON.stringify(e)
42 $.ui.log(`doError error${why}: ${detail}`)
43}
44
45function dataFor($: Parameters<Hook<'ui.render'>>[0], e: { component: string; props: any }) {
46 if (e.component === 'zzzUserMessage') {
47 $.ui.log(`renderMessage: ${JSON.stringify(e, null, 2)}`)
48 }
49 switch (e.component) {
50 case 'ToolResult': return { label: 'Result', text: `${e.props.tool}\n${e.props.output}`, showNext: false, marginTop: 0 }
51 case 'AbovePrompt': return { label: 'Above', text: e.props.bodyColumns, showNext: true, marginTop: 0 }
52 case 'PromptHint': return { label: 'Hint', text: e.props.hint, showNext: false, marginTop: 0 }
53 case 'UserMessage': return { label: 'Prompt', text: e.props.text?.trimEnd(), showNext: false, marginTop: 0 }
54 case 'ToolUse': return { label: 'Tool Use', text: e.props.output, showNext: false, marginTop: 0 }
55 case 'ToolGroup': return { label: 'Tool Group', text: e.props.text, showNext: true, marginTop: 0 }
56 case 'AssistantMessage': return { label: 'Message', text: e.props.text?.trimEnd(), showNext: true, marginTop: 0 }
57 case 'CommandOutput': return { label: 'Output', text: e.props.text, showNext: false, marginTop: 0 }
58 case 'Spinner': return { label: 'Working', text: e.props.word, showNext: true, marginTop: 0 }
59 case 'SessionMode': return { label: 'Mode', text: '', showNext: true, marginTop: 0 }
60 case 'InfoNotice': return { label: 'Notice', text: e.props.text, showNext: true, marginTop: 0 }
61 case 'TurnDuration': return { label: 'Time', text: e.props.durationMs + 'ms', showNext: true, marginTop: 0 }
62 default: return { label: e.component, text: e.props.text, showNext: true, marginTop: 0 }
63 }
64}
65
66export async function renderMessage($: Parameters<Hook<'ui.render'>>[0], e: RenderInput<any>, next: any) {
67 const { label, text, showNext, marginTop } = dataFor($, e)
68 const beneath = await next(e)
69 if (e.surface !== 'terminal') {
70 const { Box, Text, Svg } = $.ui.resolve(e)
71 return (
72 <Box flexDirection="row" alignItems="flex-start">
73 <Box marginTop={0.5} flexShrink={0}>
74 <Svg source={await $.fs.read(`${$.plugin.root}/${LOGO_SVG}`)} width={15} height={12} alt="Sencha" />
75 </Box>
76 <Box marginLeft={0.5} alignItems="flex-start">
77 <Box flexDirection="row" alignItems="flex-start">
78 <Box width={8} flexShrink={0} marginTop={0.04}>
79 <Text color="cyan" bold italic>{label}:</Text>
80 </Box>
81 {!showNext && <Text color="rgb(145,186,76)" bold>{text}</Text>}
82 </Box>
83 </Box>
84 {showNext && <Box marginLeft={0.0} marginTop={marginTop}>{beneath}</Box>}
85 </Box>
86 )
87 }
88 // terminal
89 const { Box, Text } = $.ui.resolve(e)
90 let img
91 if (e.surface === 'terminal') {
92 const { Image } = $.ui.resolve(e)
93 img = <Box><Image source={{ png: (await $.fs.read(`${$.plugin.root}/${LOGO_PNG}`, { as: 'bytes' })).base64 }} columns={2} rows={1} alt="S" /></Box>
94 }
95 return (
96 <Box flexDirection="row" alignItems="flex-start" >
97 <Box marginTop={1} width={12} flexShrink={0} >
98 {img} <Text color="cyan" bold={true} italic={true}>{label}: </Text>
99 </Box>
100 {!showNext && <Box marginTop={1}><Text color="rgb(145,186,76)">{text}</Text></Box>}
101 {showNext && beneath}
102 </Box>
103 )
104}
105
106// A path shown relative to the session's working directory when it is inside it; otherwise as given.
107export function relativeTo(cwd: string, path: string | undefined): string | undefined {
108 if (!path || !cwd) return path
109 const prefix = cwd.endsWith('/') ? cwd : `${cwd}/`
110 return path.startsWith(prefix) ? path.slice(prefix.length) : path
111}
112
113// The one argument that says what a built-in call is about: the file, folder,
114// command or query the tool acts on, for the pane's TOOL CALLS rows.
115export function subjectOf(e: ToolCallInput): string | undefined {
116 switch (e.tool) {
117 case 'Bash':
118 return e.command
119 case 'Read':
120 case 'Edit':
121 case 'Write':
122 return e.file_path
123 case 'NotebookEdit':
124 return e.notebook_path
125 case 'ToolSearch':
126 case 'WebSearch':
127 return e.query
128 case 'WebFetch':
129 return e.url
130 default:
131 return undefined
132 }
133}
134
135export const PANE = 'sencha'
136
137let linesSeen = 0
138
139export async function clearAll($: any) {
140 linesSeen = 0
141 resetSections($)
142}
143
144// The server keeps one progress list for every tool. Only initialize and generate clear it, so a
145// build or watch starts with the last run's lines and its "polling done" still in the list.
146// listStart is where this tool's own lines begin in that list.
147let listStart = 0
148
149// Called before a progress tool starts, with the list length the server has right now.
150export function startProgress(length: number) {
151 linesSeen = length
152 listStart = length
153}
154
155// True once this tool's lines are in the list and the last one says polling is done.
156export function progressFinished(items: { name?: string }[]): boolean {
157 const start = items.length < listStart ? 0 : listStart
158 return items.length > start && items[items.length - 1]?.name === 'polling done'
159}
160
161export async function appendLines($: EngineInterface, items: { name?: string }[]): Promise<number> {
162 //$.ui.log('appendLines')
163 if (items.length < linesSeen) {
164 // the server cleared its list for a new run, so this tool's lines start at the top
165 linesSeen = 0
166 listStart = 0
167 }
168 // blank lines are kept so the output keeps its spacing; only the lone '.' lines are dropped
169 const fresh = items.slice(linesSeen).flatMap(item => item.name === undefined || item.name.trim() === '.' ? [] : [{ name: item.name }])
170 linesSeen = items.length
171 if (fresh.length > 0) {
172 for (const item of fresh) {
173 // $.ui.log(`item.name ${item.name}`)
174 await addItemToLatestSection($, item.name)
175 }
176 await $.ui.scroll({ in: PANE, to: 'end' })
177 }
178 return fresh.length
179}
180hooks/commands/commandSencha.ts 19 lines1import { addSection } from '../helpers/sections'
2import { PANE } from '../helpers'
3
4export async function commandSencha($: any): Promise<{ text: string }> {
5 const message = `command 'sencha' executed.`
6 await addSection($, 'command.run', message)
7 // No surface attached (bare -p run): ui.open may never settle, so check first
8 if ((await $.session.surfaces()).length === 0) {
9 return { text: 'Sencha pane could not open: no surface is attached' }
10 }
11 try {
12 const opened = await $.ui.open({ id: PANE, title: 'Sencha', focus: true, columns: 320 })
13 if (!opened.isPlaced) return { text: `Sencha pane waiting: ${opened.reason}` }
14 } catch {
15 return { text: 'Sencha pane could not open: no surface is attached' }
16 }
17 return { text: 'Sencha pane opened' }
18}
19hooks/commands/commandCallit.ts 52 lines1import type { EngineInterface } from 'claude-code'
2
3export async function commandCallit($: EngineInterface): Promise<{ text: string }> {
4 //const tool = `mcp__plugin_sencha_extjs-tools__extjs-initialize-with-sencha-cmd`
5
6 // const args: any = {
7 // tool: `mcp__plugin_sencha_extjs-tools__extjs-initialize-with-sencha-cmd`,
8 // progress: true,
9 // rootPath: '/Users/demo/upgrade/run/MyWorkspaceRun'
10 // }
11
12
13 // const args: any = {
14 // tool: `mcp__plugin_sencha_extjs-tools__extjs-prettier-all`,
15 // progress: false,
16 // folderPath: '/Users/demo/upgrade/run/MyWorkspaceRun/v8/MyWorkspace'
17 // }
18
19 const args: any = {
20 tool: `mcp__plugin_sencha_extjs-tools__sencha-app-build`,
21 progress: true,
22 cwd: '/Users/demo/upgrade/run/MyWorkspaceRun/MyWorkspace/apps/demos/login'
23 }
24
25 let theResult
26 $.tool.call(args).then(async toolResult => {
27 //$.ui.log(`toolResult ${JSON.stringify(toolResult)}`)
28 theResult = toolResult;
29 //$.ui.log(`tool.call:isError ${JSON.stringify(toolResult)}`)
30 //$.ui.log(`tool.call:isError ${JSON.stringify(toolResult.isError)}`)
31 if (toolResult.isError === true) {
32 //await addItemToLatestSection($, toolResult.text)
33 //stop = true
34 }
35 if (toolResult.deny) {
36 //stop = true;
37 }
38 })
39
40
41 //let out = $.tool.call(args)
42 // $.ui.log(`out ${JSON.stringify(out)}`)
43 // $.tool.call(args).then(async extjsInitializeWithSenchaCmdResult => {
44 // //out = next(e).then(async extjsInitializeWithSenchaCmdResult => {
45 // if (extjsInitializeWithSenchaCmdResult.deny) {
46 // //message = JSON.stringify(extjsInitializeWithSenchaCmdResult)
47 // //stop = true;
48 // }
49 // })
50 return { text: 'running' }
51}
52hooks/commands/commandAsk.ts 15 lines1import type { EngineInterface, Hook } from 'claude-code'
2
3export async function commandAsk($: EngineInterface, e: Parameters<Hook<'command.run'>>[1], next: Parameters<Hook<'command.run'>>[2]) {
4 const tool = `mcp__plugin_sencha_extjs-tools__test-global-set`; const name = 'test-global-set';
5 const value = 'hello'
6 const { decision } = await $.tool.check({ tool, input: { name, value } })
7 //$.ui.log(`decision ${decision}`)
8 if (decision !== 'allow') {
9 const answer = await $.ui.ask(`SET Allow ${name} with value '${value}'?`, ['Yes', 'No'])
10 if (answer !== 'Yes') return { text: 'ask cancelled' }
11 }
12 const out = await next(e)
13 return { ...out, text: JSON.stringify({ text: 'polling done' }) }
14}
15hooks/components/button.tsx 29 lines1import type { ClientModule, RenderSurface } from 'claude-code'
2
3// place: where the button is drawn, so its hover hint goes to the right spot
4// hint: the text the button shows while the pointer is over it, if any
5export type ButtonProps = { id: string; label: string; dim: boolean; surface: RenderSurface; place: 'above' | 'pane'; hint?: string }
6
7// A header button that also tells the hooks module when the pointer is over it, which a Button
8// alone doesn't, so the header can say what the button does. A Client only gets the terminal
9// element table, so the chrome is a bordered Box around the Button; the terminal draws the label
10// as `[ label ]` itself, so the border would double up there.
11const HeaderButton: ClientModule<ButtonProps, { over: boolean }> = (props, surface) => {
12 const { Box, Button } = surface.elements
13 surface.onPointer(e => {
14 const over = e.type !== 'leave'
15 if (over === Boolean(surface.state?.over)) return
16 surface.setState({ over })
17 surface.post({ over, place: props.place, ...(over && props.hint !== undefined ? { hint: props.hint } : {}) })
18 })
19 const button = <Button key={props.id} dimColor={props.dim} label={props.label} onPress={() => surface.post({ press: true })} />
20 if (props.surface === 'terminal') return <Box marginRight={1}>{button}</Box>
21 return (
22 <Box marginRight={1} borderStyle="single" paddingX={1} paddingY={0}>
23 {button}
24 </Box>
25 )
26}
27
28export default HeaderButton
29hooks/components/expander.tsx 224 lines1import type { ClientModule, RenderElement } from 'claude-code'
2import { CHEVRON_CLOSED, CHEVRON_OPEN, CIRCLE_FILLED, CIRCLE_NERD } from './icons'
3
4export type ExpanderItem = { text: string; color?: string; dim?: boolean; italic?: boolean }
5export type ExpanderStatus = 'runs' | 'done' | 'fail'
6export type ExpanderSection = {
7 id: string
8 title: string
9 message?: string
10 status?: ExpanderStatus
11 subject?: string
12 detail?: string
13 items?: (string | ExpanderItem)[]
14 expanded?: boolean
15}
16export type ExpanderProps = {
17 sections: ExpanderSection[]
18 titleColor?: string
19 itemColor?: string
20 hoverBg?: string
21 unicode?: boolean
22 max?: number
23}
24type Row = { text: string; color?: string; dim?: boolean; italic?: boolean }
25type Local = {
26 hover: { sec: string; row: number } | null
27 drag: string | false
28 scrolls: Record<string, number>
29 focus: string | null
30}
31
32const toRows = (items?: (string | ExpanderItem)[]): Row[] =>
33 (items ?? []).map(i => (typeof i === 'string' ? { text: i } : i))
34
35// Presentational: sections, rows and expanded flags arrive as props and every
36// change leaves as an event for the host to own. Only view state (hover,
37// scroll, drag) lives here.
38const Expander: ClientModule<ExpanderProps, Local> = (props, surface) => {
39 const { Box, Text } = surface.elements
40 try {
41 let state = surface.state
42 if (state === undefined) {
43 state = { hover: null, drag: false, scrolls: {}, focus: props.sections[0]?.id ?? null }
44 surface.setState(state)
45 }
46 const cur = () => surface.state ?? state
47 const caretOpen = props.unicode ? '▼' : CHEVRON_OPEN
48 const caretClosed = props.unicode ? '▶' : CHEVRON_CLOSED
49 const cap = Math.max(1, Math.min(props.max ?? Infinity, 500))
50
51 const sectionOf = (id: string) => props.sections.find(s => s.id === id)
52 const focusId = (): string | null => {
53 const s = cur()
54 return s.focus !== null && sectionOf(s.focus) ? s.focus : props.sections[0]?.id ?? null
55 }
56 const toggle = (id: string) => surface.post({ toggle: { section: id, open: !sectionOf(id)?.expanded } })
57 const addRow = (id: string) => surface.post({ addRow: { section: id } })
58 const addSection = () => surface.post({ addSection: true })
59 const press = (id: string, index: number) => surface.post({ press: { section: id, index } })
60
61 // one entry per drawn row: { section id, row within it }
62 const hits = (): { sec: string; row: number }[] => {
63 const list: { sec: string; row: number }[] = []
64 props.sections.forEach(s => {
65 list.push({ sec: s.id, row: 0 })
66 if (s.expanded) toRows(s.items).forEach((_, ii) => list.push({ sec: s.id, row: 1 + ii }))
67 })
68 return list
69 }
70
71 surface.onPointer(e => {
72 const s = cur()
73 if (e.type === 'leave') {
74 if (s.hover !== null || s.drag !== false) surface.setState({ ...s, hover: null, drag: false })
75 return
76 }
77 const rows = hits()
78 // an active scrollbar drag owns the pointer until it comes up
79 if (s.drag !== false) {
80 const sec = sectionOf(s.drag)
81 if (!sec) {
82 surface.setState({ ...s, drag: false })
83 return
84 }
85 if (e.type === 'up') {
86 surface.setState({ ...s, drag: false })
87 return
88 }
89 if (e.type === 'down' || e.type === 'move') {
90 const items = toRows(sec.items)
91 const maxScroll = Math.max(0, items.length - cap)
92 const start = rows.findIndex(r => r.sec === sec.id && r.row === 1)
93 const span = Math.max(1, Math.min(cap, items.length) - 1)
94 const rel = Math.max(0, Math.min(span, e.y - start))
95 const to = maxScroll === 0 ? 0 : Math.round((rel / span) * maxScroll)
96 surface.setState({ ...s, scrolls: { ...s.scrolls, [sec.id]: to }, drag: sec.id, hover: null })
97 return
98 }
99 }
100 const hit = rows[e.y]
101 if (e.type === 'move' && !e.button) {
102 const h = hit ? { sec: hit.sec, row: hit.row } : null
103 if (s.hover === null || h === null || s.hover.sec !== h.sec || s.hover.row !== h.row) {
104 surface.setState({ ...s, hover: h })
105 }
106 return
107 }
108 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !hit) return
109 // grab a section's scrollbar in its rightmost column
110 if (e.x >= surface.columns - 1 && hit.row >= 1) {
111 const sec = sectionOf(hit.sec)
112 if (sec && toRows(sec.items).length > cap) {
113 surface.setState({ ...s, drag: hit.sec, hover: null })
114 return
115 }
116 }
117 if (hit.row === 0) {
118 const sec = sectionOf(hit.sec)
119 // a section with nothing under it has no expander; leave it alone
120 if (sec && toRows(sec.items).length > 0) toggle(hit.sec)
121 return
122 }
123 if (hit.row >= 1) {
124 const sec = sectionOf(hit.sec)
125 if (sec) press(sec.id, (s.scrolls[sec.id] ?? 0) + (hit.row - 1))
126 }
127 })
128
129 surface.onKey(e => {
130 const id = focusId()
131 const sec = sectionOf(id ?? '')
132 if (!sec) return
133 const s = cur()
134 if (e.key === 'return' || e.key === ' ') {
135 if (toRows(sec.items).length > 0) toggle(sec.id)
136 } else if (e.key === 'a') {
137 addRow(sec.id)
138 } else if (e.key === 'n') {
139 addSection()
140 } else {
141 const items = toRows(sec.items)
142 const maxScroll = Math.max(0, items.length - cap)
143 const from = s.scrolls[sec.id] ?? 0
144 if (sec.expanded && (e.key === 'down' || e.key === 'j')) {
145 surface.setState({ ...s, scrolls: { ...s.scrolls, [sec.id]: Math.min(maxScroll, from + 1) }, focus: sec.id })
146 } else if (sec.expanded && (e.key === 'up' || e.key === 'k')) {
147 surface.setState({ ...s, scrolls: { ...s.scrolls, [sec.id]: Math.max(0, from - 1) }, focus: sec.id })
148 }
149 }
150 })
151
152 const hoverOn = (sec: string, row: number) =>
153 state.hover !== null && state.hover.sec === sec && state.hover.row === row
154
155 const sectionBlock = (sec: ExpanderSection): RenderElement => {
156 try {
157 const items = toRows(sec.items)
158 const hasItems = items.length > 0
159 const glyph = hasItems ? (sec.expanded ? caretOpen : caretClosed) : (props.unicode ? CIRCLE_FILLED : CIRCLE_NERD)
160 const scroll = state.scrolls[sec.id] ?? 0
161 const maxScroll = Math.max(0, items.length - cap)
162 const at = Math.max(0, Math.min(scroll, maxScroll))
163 const clipped = Boolean(sec.expanded) && items.length > cap
164 const shown = sec.expanded ? items.slice(at, at + cap) : []
165 const barSize = clipped ? Math.max(1, Math.round((cap * cap) / items.length)) : 0
166 const barPos = clipped && maxScroll > 0 ? Math.round((at * (cap - barSize)) / maxScroll) : 0
167 const titleColor =
168 sec.status === 'runs' ? 'warning'
169 : sec.status === 'done' ? 'success'
170 : sec.status === 'fail' ? 'error'
171 : props.titleColor
172 const right = sec.subject
173 return (
174 <Box key={sec.id} flexDirection="column">
175 <Box
176 flexDirection="row"
177 height={1}
178 overflow="hidden"
179 backgroundColor={hoverOn(sec.id, 0) ? props.hoverBg : undefined}
180 >
181 <Box width={17} flexShrink={0} flexDirection="row">
182 {/* fixed 3-column gutter so the title starts at column 3 whatever the glyph's width */}
183 <Box width={3} flexShrink={0}>
184 <Text color={titleColor}>{` ${glyph}`}</Text>
185 </Box>
186 <Text color={titleColor} wrap="truncate">{sec.title}</Text>
187 </Box>
188 {sec.message ? <Box flexShrink={1} overflow="hidden"><Text color="white" wrap="truncate">{`${sec.message}`}</Text></Box> : null}
189 {sec.detail ? <Box flexShrink={1} overflow="hidden"><Text color="#c4b5fd" italic wrap="truncate">{` ${sec.detail}`}</Text></Box> : null}
190 {right ? <Text color={titleColor} dimColor wrap="truncate">{right}</Text> : null}
191 <Box flexGrow={1} />
192 </Box>
193 {shown.map((it, i) => (
194 <Box
195 key={`${sec.id}-${i}`}
196 flexDirection="row"
197 height={1}
198 overflow="hidden"
199 backgroundColor={hoverOn(sec.id, 1 + i) ? props.hoverBg : undefined}
200 >
201 <Box marginLeft={3} flexShrink={1} overflow="hidden">
202 <Text color={it.color ?? props.itemColor} dimColor={it.dim} italic={it.italic} wrap="truncate">{it.text}</Text>
203 </Box>
204 <Box flexGrow={1} />
205 {clipped ? <Text>{i >= barPos && i < barPos + barSize ? '┃' : '│'}</Text> : null}
206 </Box>
207 ))}
208 </Box>
209 )
210 }
211 catch (error: any) {
212 return (<Text>ERROR: {error.toString()}</Text>)
213 }
214 }
215
216 return <Box flexDirection="column">{props.sections.map(sectionBlock)}</Box>
217 }
218 catch (error) {
219 return (<Text>Error</Text>)
220 }
221}
222
223export default Expander
224hooks/components/hoverhint.tsx 10 lines1// The line that shows the hint of the button under the pointer, in the spot a place gives it. Blank
2// when no button has a hint, so the line keeps its height and nothing below it moves.
3export function HoverHint({ Box, Text, hint }: { Box: any; Text: any; hint?: string }) {
4 return (
5 <Box>
6 <Text color="#fb923c" size="caption">{hint ? hint : ' '}</Text>
7 </Box>
8 )
9}
10hooks/components/header.tsx 16 lines1// The title row: logo, a title, the version and the time. Each caller reads its own logo and version.
2// The logo is always `logo`: SVG markup (a string, drawn by `Svg`) or a PNG (`{ png }`, drawn by `Image`).
3// The desktop has `Svg` only and the terminal has `Image` only, so a caller passes both elements it has.
4export function Header({ Box, Text, Svg, Image, logo, version }: { Box: any; Text: any; Svg?: any; Image?: any; logo: string | { png: string }; version: string }) {
5 const mark = typeof logo === 'string'
6 ? (Svg ? <Svg source={logo} width={15} height={15} alt="Sencha" /> : null)
7 : (Image ? <Image source={logo} columns={2} rows={1} alt="Sencha" /> : null)
8 return (
9 <Box flexDirection="row">
10 {mark}
11 <Box marginLeft={1}><Text color="yellow" italic>Sencha AI Upgrade</Text></Box>
12 <Box marginLeft={1}><Text color="cyan" italic>{version}</Text></Box>
13 </Box>
14 )
15}
16hooks/helpers/help.ts 43 lines1// The about dialog: a pane opened from the header's about button, showing this description.
2export const HELP_PANE = 'sencha-help'
3
4export const HELP_MARKDOWN = `# Sencha AI Upgrade
5
6Sencha is a Claude Code mod for Ext JS work. It adds a header above the prompt and a side pane that lists each step as it runs.
7
8## Above the prompt
9- **/extjs-upgrade**: runs the \`extjs-upgrade\` Skill. It finds the Ext JS project, checks the Sencha Cmd use and the Ext JS framework version, and then performs the upgrade. It opens the side pane.
10- **/extjs-dependency-report**: runs the \`extjs-dependency-report\` Skill. It builds a dependency and AST report for the Ext JS application and writes a summary Markdown file. It opens the side pane.
11- **/reext-migrate** (ReExt Migrate): runs the \`reext-migrate\` Skill. It finds an Ext JS 8 application and migrates it to a Sencha ReExt app.
12- **show side pane** (Sencha in the terminal): opens the side pane and focuses it.
13- **delete v8 folder** (Delete V8 folder in the terminal): sends Claude the prompt "If there is a V8 folder, then delete it." Claude deletes the folder if it finds one. The button doesn't ask first.
14- **about** (About in the terminal): opens this page.
15
16## Side pane
17- **go to bottom** and **go to top**: scroll to the newest or oldest step.
18- **clear**: removes every step.
19- **close**: closes the side pane.
20- **Test**: adds 10 sections of random test items. It is for checking the pane.
21- **Add Section** and **Add Item to Latest Section**: add a blank section or item by hand. They are for checking the pane.
22- **Expand All** / **Collapse All**: opens or closes every section.
23
24Each step is a section in the list. The list fills in as things happen:
25- a session start, with the time, working folder and session ID;
26- each prompt you send;
27- each command you run;
28- each tool call, with its inputs, and a status of running, done, or failed. A failed call shows its error;
29- the time each turn took.
30
31Click a section's header to open or close it.
32
33## Skills
34- \`extjs-upgrade\`: upgrades an Ext JS project.
35- \`extjs-dependency-report\`: writes a dependency and AST report for an Ext JS project.
36- \`reext-discover\`: discovers an Ext JS 8 application and migrates it to a Sencha ReExt app. No button runs it yet.
37
38## Commands
39- \`/sencha\`: opens the side pane.
40- \`/callit\`: runs a Sencha app build on a sample project path. It is for development.
41- \`/ask\`: asks for permission to set a test value, then checks the result. It is for development.
42`
43hooks/helpers/sections.ts 134 lines1import type { ExpanderProps, ExpanderSection } from '../components/expander'
2
3let list: ExpanderSection[] = []
4
5// latest set of sections, for the UI and anything else that reads section data
6export function sections(): ExpanderSection[] {
7 return list
8}
9
10const cleanData = (value: any, path: string, dropped: string[]): any => {
11 if (Array.isArray(value)) return value.map((v, i) => cleanData(v, `${path}[${i}]`, dropped))
12 if (value !== null && typeof value === 'object') {
13 const out: any = {}
14 for (const [k, v] of Object.entries(value)) {
15 if (v === undefined) { dropped.push(`${path}.${k}`); continue }
16 out[k] = cleanData(v, `${path}.${k}`, dropped)
17 }
18 return out
19 }
20 return value
21}
22
23export function expanderProps(dropped: string[] = []): ExpanderProps {
24 return {
25 sections: cleanData(list, 'sections', dropped),
26 titleColor: 'cyan',
27 itemColor: 'yellow',
28 hoverBg: '#14532d',
29 unicode: true,
30 }
31}
32
33export function expandAll($: any) {
34 try {
35 list = list.map(s => ({ ...s, expanded: true }))
36 $.ui.invalidate('ui.render')
37 }
38 catch (error) {
39 $.ui.log(`error here...`)
40 }
41}
42
43export function collapseAll($: any) {
44 list = list.map(s => ({ ...s, expanded: false }))
45 $.ui.invalidate('ui.render')
46}
47
48// title: the first column. name: the white column after it. value: the italic purple column after that.
49export async function addSection($: any, title?: string, name?: string, value?: string, step?: Pick<ExpanderSection, 'status' | 'subject'>) {
50 if (name === undefined) { name = '' }
51 const n = list.length + 1
52 const id = `section-${n}`
53 const section: ExpanderSection = { id, title: title ?? `section ${n}`, message: name, items: [], expanded: true }
54 if (value !== undefined) section.detail = value
55 if (step?.status !== undefined) section.status = step.status
56 if (step?.subject !== undefined) section.subject = step.subject
57 list = [
58 ...list.map(s => ({ ...s, expanded: false })),
59 section,
60 ]
61 //await $.clock.sleep(5000)
62 $.ui.invalidate('ui.render')
63
64 return id
65}
66
67export function setSectionStatus($: any, id: string, status: ExpanderSection['status']) {
68 list = list.map(s => (s.id === id ? { ...s, status } : s))
69 $.ui.invalidate('ui.render')
70}
71
72export function toggleSection(id: string, open: boolean) {
73 // Accordion: opening a section collapses whichever section was open before,
74 // so at most one section is expanded at a time. Collapsing only affects the
75 // target. Collapsing every other expanded section is equivalent to tracking
76 // the previous id, and cannot drift out of sync with list/addSection/etc.
77 list = list.map(s => {
78 if (s.id === id) return { ...s, expanded: open }
79 if (open && s.expanded) return { ...s, expanded: false }
80 return s
81 })
82}
83
84export async function addItem($: any, id: string, title?: string, color?: string, italic?: boolean) {
85 list = list.map(s => {
86 if (s.id !== id) return s
87 const n = (s.items?.length ?? 0) + 1
88 const text = title ?? `item ${n}`
89 // only the style fields that are set, so the props stay free of undefined values
90 const item = color || italic ? { text, ...(color ? { color } : {}), ...(italic ? { italic } : {}) } : text
91 return { ...s, items: [...(s.items ?? []), item] }
92 })
93 //await $.clock.sleep(5000)
94 $.ui.invalidate('ui.render')
95}
96
97export async function addItemToLatestSection($: any, title?: string, color?: string, italic?: boolean) {
98 const latest = list[list.length - 1]
99 if (latest) await addItem($, latest.id, title, color, italic)
100}
101
102export function resetSections($: any) {
103 list = []
104 $.ui.invalidate('ui.render')
105}
106
107export async function runTests($: any) {
108 const wait = 0;
109 for (let i = 0; i < 10; i++) {
110 await addSection($, `new section ${i}`)
111 await $.clock.sleep(wait)
112 const count = 5 + Math.floor(Math.random() * 6)
113 for (let j = 0; j < count; j++) {
114 await addItemToLatestSection($, `new item ${j}`)
115 await $.clock.sleep(wait)
116 }
117 }
118}
119
120
121
122export async function runTests2($: any) {
123 const wait = 50;
124 for (let i = 0; i < 10; i++) {
125 await addSection($, `new section ${i}`)
126 await $.clock.sleep(wait)
127 const count = 5 + Math.floor(Math.random() * 6)
128 for (let j = 0; j < count; j++) {
129 await addItemToLatestSection($, `new item ${j}`)
130 await $.clock.sleep(wait)
131 }
132 }
133}
134hooks/components/icons.ts 145 lines1import type { FileNode } from './types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5// Nerd Font Material Design set: chevrons and circle share one glyph grid, so they centre on the same line.
6export const CHEVRON_OPEN = cp(0xf0140)
7export const CHEVRON_CLOSED = cp(0xf0142)
8export const CIRCLE_NERD = cp(0xf0765)
9// Unicode fallback for terminals without a Nerd Font: a standard black circle, one cell wide.
10export const CIRCLE_FILLED = cp(0x25cf)
11
12const NAMES: Record<string, number> = {
13 'agents.md': 0xf0354,
14 'changelog.md': 0xf0354,
15 'contributing.md': 0xf0354,
16 'todo.md': 0xf0354,
17 'readme.md': 0xf0354,
18 license: 0xe60a,
19 dockerfile: 0xf0868,
20 makefile: 0xf1064,
21 'cmakelists.txt': 0xf1064,
22 justfile: 0xf05b7,
23 gemfile: 0xf0d2d,
24 rakefile: 0xf0d2d,
25 'cargo.toml': 0xe6b2,
26 'cargo.lock': 0xe6b2,
27 'pyproject.toml': 0xe6b2,
28 'requirements.txt': 0xf160e,
29 'go.mod': 0xf0afa,
30 'go.sum': 0xf07d3,
31 'package.json': 0xf0626,
32 'package-lock.json': 0xf0626,
33 'tsconfig.json': 0xf0626,
34 '.prettierrc': 0xf0626,
35 '.eslintrc': 0xf0626,
36 'pnpm-lock.yaml': 0xe6a8,
37 'yarn.lock': 0xe6a8,
38 '.gitignore': 0xf02a2,
39 '.gitmodules': 0xf0493,
40 '.editorconfig': 0xe652,
41}
42
43const EXTS: Record<string, number> = {
44 lua: 0xe620,
45 py: 0xe606,
46 pyi: 0xe606,
47 js: 0xe60c,
48 mjs: 0xe60c,
49 cjs: 0xe60c,
50 ts: 0xe628,
51 jsx: 0xe625,
52 tsx: 0xe7ba,
53 json: 0xe60b,
54 jsonc: 0xe60b,
55 yaml: 0xe6a8,
56 yml: 0xe6a8,
57 sh: 0xe795,
58 bash: 0xe795,
59 zsh: 0xe795,
60 fish: 0xe795,
61 md: 0xf0354,
62 markdown: 0xf0354,
63 css: 0xe6b8,
64 scss: 0xf031c,
65 html: 0xe736,
66 htm: 0xe736,
67 go: 0xe627,
68 rs: 0xe68b,
69 rb: 0xe791,
70 php: 0xe608,
71 java: 0xe738,
72 cs: 0xf031b,
73 sql: 0xe706,
74 graphql: 0xf20e,
75 gql: 0xf20e,
76 xml: 0xf05c0,
77 toml: 0xe6b2,
78 ini: 0xf0bc2,
79 conf: 0xf0493,
80 pdf: 0xeaeb,
81 svg: 0xf0721,
82 jpg: 0xf0225,
83 jpeg: 0xf0225,
84 png: 0xe60d,
85 gif: 0xf0d78,
86 webp: 0xf021f,
87 mp4: 0xf022b,
88 mkv: 0xf022b,
89 mov: 0xf022b,
90 mp3: 0xf0223,
91 wav: 0xf0223,
92 flac: 0xf0223,
93 zip: 0xf05c4,
94 gz: 0xf05c4,
95 xz: 0xf05c4,
96 tar: 0xf05c4,
97 qml: 0xf375,
98 cpp: 0xe61d,
99 cc: 0xe61d,
100 cxx: 0xe61d,
101 c: 0xe61e,
102 h: 0xf0af5,
103 hpp: 0xf0af5,
104 vue: 0xe6a0,
105 svelte: 0xe697,
106 csv: 0xe64a,
107 txt: 0xf09aa,
108}
109
110export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
111 if (n.kind === 'link') return cp(0xf481)
112 if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
113 const lower = n.name.toLowerCase()
114 const named = NAMES[lower]
115 if (named) return cp(named)
116 const dot = lower.lastIndexOf('.')
117 const ext = dot >= 0 ? lower.slice(dot + 1) : ''
118 return cp(EXTS[ext] ?? 0xf0214)
119}
120
121export const GIT_COLOR: Record<string, string> = {
122 A: '#98c379',
123 '?': '#98c379',
124 R: '#61afef',
125 C: '#61afef',
126 M: '#e5c07b',
127 T: '#e5c07b',
128}
129
130export const LIGHT_GIT_COLOR: Record<string, string> = {
131 A: '#14532d',
132 '?': '#14532d',
133 R: '#1e3a8a',
134 C: '#1e3a8a',
135 M: '#78350f',
136 T: '#78350f',
137}
138
139const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
140
141export function stronger(a: string | undefined, b: string): string {
142 if (!a) return b
143 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
144}
145