Painel lateral com as queries do Athena da sessão: dados escaneados, custo, alertas de query cara e sem partição, status da sessão AWS, prévia do resultado e…

Mod do Claude Code que abre um painel lateral com as queries do AWS Athena que o Claude rodou na sessão: quanto cada uma escaneou, quanto custou, se leu tabela particionada sem filtro, se a sua sessão AWS ainda vale, e as primeiras linhas do resultado. Funciona no terminal e na aba Code do app desktop.
aws ok · conta … em verde, ou aws expirada · rode aws login em vermelho. Checa a cada 60 segundos e sempre que uma chamada à AWS volta expirada.possível leitura sem partição e sai um toast. A checagem lê o texto do SQL, por isso diz "possível".ver prévia mostra as 5 primeiras linhas, direto do Athena, sem custo de scan.abrir csv, mostrar no finder, copiar sql, copiar id, copiar caminho do csv. O botão atualizar (r) relê tudo; queries rodando já se atualizam sozinhas a cada 5 segundos.O painel abre sozinho na primeira query da sessão. Para reabrir, digite /queries.
Qualquer ferramenta cuja saída traga um QueryExecutionId entra no painel:
QueryExecutionId: <id> (como a skill athena-query), e opcionalmente CSV_PATH: <caminho> e ROWS: <n> para o CSV local e a contagem de linhas;aws athena start-query-execution e get-query-execution no terminal;awslabs.aws-dataprocessing-mcp-server).PATH, com uma credencial que possa chamar athena:GetQueryExecution, athena:GetQueryResults, glue:GetTable e sts:GetCallerIdentity. O mod usa o profile e a região padrão do seu ambiente.open do macOS para os botões de abrir CSV. Em outros sistemas esses botões mostram um aviso de erro.Rode no terminal:
claude plugin install query-pane --marketplace iagodemacedo/query-pane
Instala no escopo de usuário, então o painel vale para toda sessão nova, no terminal e na aba Code do app desktop. Abra uma sessão nova depois de instalar.
Dentro de uma sessão no terminal também dá para digitar /plugin install query-pane --marketplace iagodemacedo/query-pane, responder y para adicionar o marketplace e apertar Enter para manter o escopo de usuário. O /plugin não existe na aba Code do app desktop: instale uma vez pelo terminal e o desktop passa a usar.
O limite do alerta de query cara fica no campo alertGiB (padrão 10), no menu de configuração do plugin.
A estimativa usa o preço on-demand do Athena: US$ 5 por TB escaneado, mínimo de 10 MB por query. Se a sua conta tem outro preço ou usa capacidade provisionada, o valor não se aplica.
A prévia mostra dados reais do resultado no painel, inclusive dados pessoais se a query trouxer. Ela fica só na sua tela e não entra no contexto do Claude.
claude plugin update query-pane
claude plugin uninstall query-pane
git clone https://github.com/iagodemacedo/query-pane && cd query-pane
claude --plugin-dir .
Salvar um arquivo recarrega o mod na sessão. Antes de um commit, rode claude plugin validate . e claude plugin test ..
MIT
hooks/register.tsx 653 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AwsStatus, Query } from '../types'
5
6const PANE = 'query-pane'
7const TITLE = 'Athena'
8const queries = atom({ plugin: 'query-pane', key: 'queries' } as const, [])
9
10// Covers the athena-query skill ("QueryExecutionId: <id>" on stderr), the
11// AWS CLI and the Athena MCP (JSON "QueryExecutionId": "<id>").
12const ID = /QueryExecutionId["']?\s*[:=]\s*["']?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi
13const CSV = /CSV(?:_PATH)?:\s*(.+?\.csv)\s*$/m
14const ROWS = /^ROWS:\s*([\d.,]+)/m
15
16// A batch-get or a listing can name dozens of ids; only the first few are real work of this call.
17const MAX_IDS_PER_CALL = 5
18const DONE = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED'])
19
20// Athena on-demand: US$ 5 per TB scanned, 10 MB minimum per query.
21const usd = (bytes: number) => (Math.max(bytes, 10e6) / 1e12) * 5
22
23const bytes = (n?: number) => {
24 if (n === undefined) return '?'
25 const nf = (x: number, d: number) => x.toLocaleString('pt-BR', { maximumFractionDigits: d })
26 if (n < 1024) return `${n} B`
27 if (n < 1024 ** 2) return `${nf(n / 1024, 0)} KiB`
28 if (n < 1024 ** 3) return `${nf(n / 1024 ** 2, 1)} MiB`
29 return `${nf(n / 1024 ** 3, 2)} GiB`
30}
31
32const money = (v: number) => (v > 0 && v < 0.01 ? 'menos de us$ 0,01' : `us$ ${v.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)
33
34const short = (id: string) => `${id.slice(0, 8)}…${id.slice(-4)}`
35
36// SubmissionDateTime already carries the local offset, so its clock reads as local time.
37const hhmm = (q: Query) => q.submittedAt?.slice(11, 16) ?? '--:--'
38
39// Same palette and track as context-bar, so the two read as one family.
40const PALETTE = ['#6B9BD1', '#5EC4C9', '#A78BFA', '#86C06C', '#E8C56A', '#F4A6C0', '#E0704F']
41const TRACK_HEX = '#8B93A6'
42const OK_HEX = '#86C06C'
43const RUNNING_HEX = '#E8C56A'
44const FAILED_HEX = '#E0704F'
45const STATE: Record<string, { label: string; hex: string }> = {
46 SUCCEEDED: { label: 'ok', hex: OK_HEX },
47 RUNNING: { label: 'rodando', hex: RUNNING_HEX },
48 QUEUED: { label: 'na fila', hex: RUNNING_HEX },
49 FAILED: { label: 'falhou', hex: FAILED_HEX },
50 CANCELLED: { label: 'cancelada', hex: FAILED_HEX },
51}
52
53// Absolute size drives the color (cost), relative size drives the length.
54const sizeHex = (n?: number) => ((n ?? 0) >= 10 * 1024 ** 3 ? FAILED_HEX : (n ?? 0) >= 1024 ** 3 ? RUNNING_HEX : OK_HEX)
55
56const METER_CELLS = 10
57const CELL_MAX = 18
58const meterCells = (percent: number) =>
59 percent > 0 ? Math.min(METER_CELLS, Math.max(1, Math.round((percent / 100) * METER_CELLS))) : 0
60
61function meterSvg(percent: number, fill: string): string {
62 const w = Math.min(100, Math.max(0, percent))
63 return (
64 `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="12" viewBox="0 0 100 6" preserveAspectRatio="none">` +
65 `<defs><clipPath id="m"><rect width="100" height="6" rx="3" ry="3"/></clipPath></defs>` +
66 `<g clip-path="url(#m)"><rect width="100" height="6" fill="${TRACK_HEX}" fill-opacity="0.22"/>` +
67 `<rect width="${w.toFixed(1)}" height="6" fill="${fill}"/></g></svg>`
68 )
69}
70
71function stripSvg(fill: string, opacity: number): string {
72 return (
73 `<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="20" viewBox="0 0 100 10" preserveAspectRatio="none">` +
74 `<rect width="100" height="10" fill="${fill}" fill-opacity="${opacity}"/></svg>`
75 )
76}
77
78// Splits `width` cells by share, every non-empty value at least one cell,
79// leftovers to the largest remainders.
80function allocate(values: number[], width: number): number[] {
81 const total = values.reduce((sum, v) => sum + v, 0)
82 if (total <= 0 || width <= 0) return values.map(() => 0)
83 const raw = values.map(v => (v / total) * width)
84 const cells = raw.map((r, i) => ((values[i] ?? 0) > 0 ? Math.max(1, Math.floor(r)) : 0))
85 let left = width - cells.reduce((a, b) => a + b, 0)
86 const order = raw
87 .map((r, i) => ({ i, rem: r - Math.floor(r) }))
88 .filter(({ i }) => (values[i] ?? 0) > 0)
89 .sort((a, b) => b.rem - a.rem)
90 for (let k = 0; left > 0 && order.length > 0; k++) {
91 const slot = order[k % order.length]
92 if (slot) cells[slot.i] = (cells[slot.i] ?? 0) + 1
93 left -= 1
94 }
95 while (left < 0) {
96 const widest = cells.indexOf(Math.max(...cells))
97 cells[widest] = (cells[widest] ?? 1) - 1
98 left += 1
99 }
100 return cells
101}
102
103const secs = (ms?: number) =>
104 ms === undefined ? '?' : `${(ms / 1000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })}s`
105
106const oneLine = (sql?: string) => (sql ?? '').replace(/--[^\n]*/g, ' ').replace(/\s+/g, ' ').trim()
107
108export const parseResult = (text: string) => {
109 const ids = [...new Set([...text.matchAll(ID)].map(m => (m[1] ?? "").toLowerCase()).filter(Boolean))].slice(0, MAX_IDS_PER_CALL)
110 const csv = text.match(CSV)?.[1]
111 const rowsText = text.match(ROWS)?.[1]
112 const rows = rowsText === undefined ? undefined : Number(rowsText.replace(/[.,]/g, ''))
113
114 return { ids, csv, rows }
115}
116
117const aws = atom({ plugin: 'query-pane', key: 'aws' } as const, { state: 'unknown' })
118const expanded = atom({ plugin: 'query-pane', key: 'expanded' } as const, [])
119
120const EXPIRED = /expired|aws login|Unable to locate credentials|ExpiredToken|InvalidClientTokenId/i
121const AWS_EVERY_MS = 60_000
122const POLL_EVERY_MS = 5_000
123const PREVIEW_ROWS = 5
124
125type Run = { exitCode: number; stdout: string; stderr: string }
126
127const awsRun = async ($: EngineInterface, args: string[]): Promise<Run> => {
128 try {
129 return await $.process.run(['aws', ...args, '--output', 'json'], { timeoutMs: 15000 })
130 } catch (err) {
131 return { exitCode: 1, stdout: '', stderr: String(err) }
132 }
133}
134
135// Any AWS call that comes back expired flips the header, without waiting for the next check.
136const noteAws = async ($: EngineInterface, run: Run) => {
137 if (run.exitCode !== 0 && EXPIRED.test(run.stderr)) {
138 await update($, aws, () => ({ state: 'expired' as const }))
139 }
140}
141
142const checkAws = async ($: EngineInterface) => {
143 const run = await awsRun($, ['sts', 'get-caller-identity'])
144 const next = (() => {
145 if (run.exitCode === 0) {
146 try {
147 return { state: 'ok' as const, account: String(JSON.parse(run.stdout).Account ?? '') }
148 } catch {
149 return { state: 'ok' as const }
150 }
151 }
152 if (EXPIRED.test(run.stderr)) return { state: 'expired' as const }
153 return { state: 'unknown' as const, detail: run.stderr.trim().split('\n').pop()?.slice(0, 80) }
154 })()
155 await update($, aws, () => next)
156}
157
158const describe = async ($: EngineInterface, id: string): Promise<Partial<Query>> => {
159 const run = await awsRun($, ['athena', 'get-query-execution', '--query-execution-id', id])
160 await noteAws($, run)
161 if (run.exitCode !== 0) {
162 return { error: EXPIRED.test(run.stderr) ? 'sessão aws expirada: rode aws login e aperte atualizar' : run.stderr.trim().slice(0, 160) }
163 }
164 try {
165 const qe = JSON.parse(run.stdout).QueryExecution
166
167 return {
168 state: qe.Status?.State ?? 'UNKNOWN',
169 sql: qe.Query,
170 database: qe.QueryExecutionContext?.Database,
171 scannedBytes: qe.Statistics?.DataScannedInBytes,
172 engineMs: qe.Statistics?.EngineExecutionTimeInMillis,
173 outputLocation: qe.ResultConfiguration?.OutputLocation,
174 submittedAt: qe.Status?.SubmissionDateTime,
175 error: qe.Status?.StateChangeReason ?? '',
176 }
177 } catch {
178 return { error: 'resposta da aws ilegível' }
179 }
180}
181
182// --- Partition check -------------------------------------------------------
183// A table partitioned in Glue (tracking.* by year/month/day), or a view that
184// passes those columns through from one, should be filtered on them; one that
185// is not reads every partition. The check is textual, so it says "possível".
186
187const stripSql = (sql: string) =>
188 sql.replace(/--[^\n]*/g, ' ').replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/'(?:[^']|'')*'/g, "''").toLowerCase()
189
190const NOT_TABLES = new Set(['select', 'unnest', 'values', 'lateral', 'table'])
191
192const unquote = (s: string) => s.replace(/["`]/g, '')
193
194// Tables after FROM / JOIN, CTE names left out, `db.table` or bare `table`.
195export const tablesOf = (sql: string): string[] => {
196 const text = stripSql(sql)
197 const ctes = new Set([...text.matchAll(/(?:\bwith|,)\s+("?[\w$]+"?)\s+as\s*\(/g)].map(m => unquote(m[1] ?? '')))
198 // Glue's own view text opens its FROM with "(" before a join: allow it.
199 const found = [...text.matchAll(/\b(?:from|join)\s+\(*\s*((?:"?[\w$-]+"?\.)?"?[\w$-]+"?)/g)]
200 .map(m => unquote(m[1] ?? ''))
201 .filter(t => t !== '' && !ctes.has(t) && !NOT_TABLES.has(t))
202 return [...new Set(found)]
203}
204
205// Whether any of `keys` shows up after a WHERE or a JOIN's ON in the query.
206export const filtersOn = (sql: string, keys: string[]): boolean => {
207 const text = stripSql(sql)
208 const tails = [...text.matchAll(/\b(?:where|on)\b/g)].map(m => text.slice(m.index ?? 0))
209 return keys.some(k => tails.some(tail => new RegExp(`\\b${k}\\b`).test(tail)))
210}
211
212const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
213const fromBase64 = (s: string) => {
214 let bits = 0
215 let acc = 0
216 const out: number[] = []
217 for (const ch of s.replace(/[^A-Za-z0-9+/]/g, '')) {
218 acc = (acc << 6) | B64.indexOf(ch)
219 bits += 6
220 if (bits >= 8) {
221 bits -= 8
222 out.push((acc >> bits) & 0xff)
223 }
224 }
225 return decodeURIComponent(out.map(b => `%${b.toString(16).padStart(2, '0')}`).join(''))
226}
227
228type TableInfo = { keys: string[]; columns: string[]; viewSql?: string }
229const tableCache = new Map<string, Promise<TableInfo | null>>()
230
231const tableInfo = ($: EngineInterface, db: string, name: string) => {
232 const key = `${db}.${name}`
233 let hit = tableCache.get(key)
234 if (hit === undefined) {
235 hit = (async () => {
236 const run = await awsRun($, ['glue', 'get-table', '--database-name', db, '--name', name])
237 await noteAws($, run)
238 if (run.exitCode !== 0) return null
239 try {
240 const t = JSON.parse(run.stdout).Table
241 const view = /Presto View: (\S+) \*\//.exec(t.ViewOriginalText ?? '')?.[1]
242 return {
243 keys: (t.PartitionKeys ?? []).map((k: { Name: string }) => k.Name.toLowerCase()),
244 columns: (t.StorageDescriptor?.Columns ?? []).map((c: { Name: string }) => c.Name.toLowerCase()),
245 viewSql: view === undefined ? undefined : JSON.parse(fromBase64(view)).originalSql,
246 }
247 } catch {
248 return null
249 }
250 })()
251 tableCache.set(key, hit)
252 }
253 return hit
254}
255
256// The partition columns a reader of `db.name` should filter on: its own, or for
257// a view, those of the partitioned tables under it that the view exposes.
258const requiredKeys = async ($: EngineInterface, db: string, name: string): Promise<string[]> => {
259 const info = await tableInfo($, db, name)
260 if (info === null) return []
261 if (info.keys.length > 0) return info.keys
262 if (info.viewSql === undefined) return []
263 const keys = new Set<string>()
264 for (const base of tablesOf(info.viewSql)) {
265 const [bdb, bname] = base.includes('.') ? base.split('.') : [db, base]
266 const b = await tableInfo($, bdb ?? db, bname ?? base)
267 for (const k of b?.keys ?? []) if (info.columns.includes(k)) keys.add(k)
268 }
269 return [...keys]
270}
271
272const analyze = async ($: EngineInterface, id: string, alertGiB: number) => {
273 const q = (await read($, queries)).find(one => one.id === id)
274 if (q === undefined) return
275
276 if (!q.isAnalyzed && q.sql !== undefined) {
277 const warnings: string[] = []
278 for (const t of tablesOf(q.sql)) {
279 const [db, name] = t.includes('.') ? t.split('.') : [q.database, t]
280 if (db === undefined || name === undefined) continue
281 const keys = await requiredKeys($, db, name)
282 if (keys.length > 0 && !filtersOn(q.sql, keys)) warnings.push(`${db}.${name} sem filtro em ${keys.join(', ')}`)
283 }
284 await upsert($, id, { isAnalyzed: true, partitionWarnings: warnings })
285 if (warnings.length > 0) {
286 $.ui.toast(`possível query sem filtro de partição: ${warnings[0]}`)
287 }
288 }
289
290 const scanned = q.scannedBytes ?? 0
291 if (!q.isCostAlerted && DONE.has(q.state) && scanned >= alertGiB * 1024 ** 3) {
292 await upsert($, id, { isCostAlerted: true })
293 $.ui.toast(`query cara: ${bytes(scanned)} escaneados, ≈ ${money(usd(scanned))}`)
294 }
295}
296
297const loadPreview = async ($: EngineInterface, id: string) => {
298 const run = await awsRun($, ['athena', 'get-query-results', '--query-execution-id', id, '--max-results', String(PREVIEW_ROWS + 1)])
299 await noteAws($, run)
300 if (run.exitCode !== 0) {
301 await upsert($, id, { previewError: EXPIRED.test(run.stderr) ? 'sessão aws expirada' : run.stderr.trim().slice(0, 120) })
302 return
303 }
304 try {
305 const rs = JSON.parse(run.stdout).ResultSet
306 const columns: string[] = (rs.ResultSetMetadata?.ColumnInfo ?? []).map((c: { Name: string }) => c.Name)
307 const rows: string[][] = (rs.Rows ?? []).map((r: { Data?: { VarCharValue?: string }[] }) =>
308 (r.Data ?? []).map(d => d.VarCharValue ?? 'null'),
309 )
310 // The first row of a SELECT's results repeats the header.
311 const body = rows.length > 0 && rows[0]?.join('\u0000') === columns.join('\u0000') ? rows.slice(1) : rows
312 await upsert($, id, { columns, preview: body.slice(0, PREVIEW_ROWS), previewError: undefined })
313 } catch {
314 await upsert($, id, { previewError: 'resposta da aws ilegível' })
315 }
316}
317
318const toggle = async ($: EngineInterface, id: string) => {
319 const isOpen = (await read($, expanded)).includes(id)
320 await update($, expanded, ids => (isOpen ? ids.filter(x => x !== id) : [...ids, id]))
321 const q = (await read($, queries)).find(one => one.id === id)
322 if (!isOpen && q !== undefined && q.preview === undefined && q.state === 'SUCCEEDED') {
323 await loadPreview($, id)
324 }
325}
326
327// The path comes from a tool's output, so `open` only ever gets an existing local
328// .csv file: never a URL, an app bundle or anything a crafted output could name.
329export const isOpenableCsv = (path: string) => path.startsWith('/') && /\.csv$/i.test(path) && !path.includes('\0')
330
331const openFile = async ($: EngineInterface, path: string, reveal: boolean) => {
332 const stat = isOpenableCsv(path) ? await $.fs.stat(path).catch(() => undefined) : undefined
333 if (stat?.kind !== 'file') {
334 $.ui.toast(`não abri ${path}: não é um arquivo .csv local`)
335 return
336 }
337 const run = await $.process.run(reveal ? ['open', '-R', '--', path] : ['open', '--', path]).catch(() => ({ exitCode: 1 }))
338 if (run.exitCode !== 0) $.ui.toast(`não consegui abrir ${path}`)
339}
340
341const upsert = ($: EngineInterface, id: string, patch: Partial<Query>) =>
342 update($, queries, list => {
343 const found = list.some(q => q.id === id)
344 const next = found
345 ? list.map(q => (q.id === id ? { ...q, ...patch, rows: patch.rows ?? q.rows, csv: patch.csv ?? q.csv } : q))
346 : [...list, { id, tool: '', state: 'RUNNING', ...patch }]
347
348 return next.slice(-100)
349 })
350
351const refresh = async ($: EngineInterface, alertGiB: number) => {
352 const pending = (await read($, queries)).filter(q => !DONE.has(q.state))
353 await Promise.all(
354 pending.map(async q => {
355 await upsert($, q.id, await describe($, q.id))
356 await analyze($, q.id, alertGiB)
357 }),
358 )
359}
360
361export const register: Register = (on, options) => {
362 const alertGiB = typeof options.alertGiB === 'number' && options.alertGiB > 0 ? options.alertGiB : 10
363 let isPolling = false
364
365 on('session.start', async ($, e, next) => {
366 await $.command.register({
367 name: 'queries',
368 description: 'Abre o painel com as queries do Athena desta sessão',
369 })
370 $.clock.after(0, () => void checkAws($))
371 $.clock.every(AWS_EVERY_MS, () => void checkAws($))
372 // Running queries settle on their own: their final size, and any alert, land without a press.
373 $.clock.every(POLL_EVERY_MS, () => {
374 if (isPolling) return
375 isPolling = true
376 void refresh($, alertGiB).finally(() => {
377 isPolling = false
378 })
379 })
380
381 return next(e)
382 })
383
384 on('command.run', { command: 'queries' }, async $ => {
385 await $.ui.open({ id: PANE, title: TITLE })
386 const n = (await read($, queries)).length
387
388 return { text: n === 0 ? 'Painel aberto. Nenhuma query do Athena nesta sessão ainda.' : `Painel aberto com ${n} queries.` }
389 })
390
391 on('tool.call', async ($, e, next) => {
392 const ran = await next(e)
393 const text = ran.text
394 if (text === undefined || !/QueryExecutionId/i.test(text)) {
395 return ran
396 }
397
398 const { ids, csv, rows } = parseResult(text)
399 // CSV and row count only make sense when the call ran one query.
400 const single = ids.length === 1
401 for (const id of ids) {
402 await upsert($, id, { tool: e.tool, ...(single ? { csv, rows } : {}) })
403 await upsert($, id, await describe($, id))
404 }
405 if (ids.length > 0) {
406 // Glue lookups run after the tool answers, so the model is not kept waiting.
407 $.clock.after(0, () => {
408 void (async () => {
409 for (const id of ids) await analyze($, id, alertGiB)
410 })()
411 })
412 // No surface to seat a pane (headless run): the data still lands in state for /queries.
413 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
414 }
415
416 return ran
417 }).catch(($, e, next) => next(e))
418
419 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
420 const { Box, Text, Button } = $.ui.resolve(e)
421 // Svg is the desktop's own element; the terminal draws the same shapes in block characters.
422 const Svg = e.surface === 'desktop' ? $.ui.resolve(e).Svg : undefined
423 const list = (await read($, queries)).slice().reverse()
424 const status = await read($, aws)
425 const open = await read($, expanded)
426 // Card border (2) and padding (2) come off the pane's width.
427 const inner = Math.max(24, e.props.bodyColumns - 4)
428 const awsLine = (s: AwsStatus) =>
429 s.state === 'ok' ? (
430 <Text>
431 <Text color={OK_HEX}>● </Text>
432 <Text dimColor>aws ok{s.account ? ` · conta ${s.account}` : ''}</Text>
433 </Text>
434 ) : s.state === 'expired' ? (
435 <Text>
436 <Text color={FAILED_HEX}>● </Text>
437 <Text color={FAILED_HEX}>aws expirada</Text>
438 <Text dimColor> · rode aws login no terminal</Text>
439 </Text>
440 ) : (
441 <Text>
442 <Text dimColor>● aws ?{s.detail ? ` · ${s.detail}` : ''}</Text>
443 </Text>
444 )
445
446 if (list.length === 0) {
447 return (
448 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
449 <Box justifyContent="space-between">
450 <Text bold>athena</Text>
451 {awsLine(status)}
452 </Box>
453 <Text dimColor>nenhuma query nesta sessão ainda</Text>
454 <Box marginTop={1}>
455 <Text dimColor>aparece aqui toda query da skill athena-query, do aws cli ou do mcp do athena</Text>
456 </Box>
457 </Box>
458 )
459 }
460
461 const scanned = list.reduce((sum, q) => sum + (q.scannedBytes ?? 0), 0)
462 const cost = list.reduce((sum, q) => sum + (q.scannedBytes === undefined ? 0 : usd(q.scannedBytes)), 0)
463 const biggest = Math.max(1, ...list.map(q => q.scannedBytes ?? 0))
464 const colorOf = (q: Query) => PALETTE[(list.length - 1 - list.indexOf(q)) % PALETTE.length] ?? TRACK_HEX
465
466 // The session bar: one segment per query, sized by bytes scanned, in the
467 // query's own color, its label on hover.
468 const cells = allocate(list.map(q => q.scannedBytes ?? 0), inner)
469 let start = 0
470 const bar = (
471 <Box>
472 {scanned === 0 ? (
473 Svg !== undefined ? (
474 <Svg source={stripSvg(TRACK_HEX, 0.22)} alt="nada escaneado" height={10} />
475 ) : (
476 <Text dimColor>{'░'.repeat(inner)}</Text>
477 )
478 ) : (
479 list.map((q, i) => {
480 const n = cells[i] ?? 0
481 const at = start
482 start += n
483 if (n <= 0) return null
484 const label = `${hhmm(q)} · ${bytes(q.scannedBytes)} · ${oneLine(q.sql).slice(0, 40)}`
485 const left = Math.max(-at, Math.min(0, inner - (at + label.length)))
486 return (
487 <Box key={`seg-${q.id}`} width={n}>
488 {Svg !== undefined ? (
489 <Svg source={stripSvg(colorOf(q), 1)} alt={label} height={10} />
490 ) : (
491 <Text color={colorOf(q)}>{'█'.repeat(n)}</Text>
492 )}
493 <Box position="absolute" top={1} left={left} display="none" hover={{ display: 'flex' }}>
494 <Text>{label}</Text>
495 </Box>
496 </Box>
497 )
498 })
499 )}
500 </Box>
501 )
502
503 const summary = (
504 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
505 <Box justifyContent="space-between">
506 <Text bold>athena</Text>
507 <Text>
508 <Text bold>{bytes(scanned)}</Text>
509 <Text dimColor>
510 {' '}escaneados · {list.length} {list.length === 1 ? 'query' : 'queries'} · ≈ {money(cost)}
511 </Text>
512 </Text>
513 </Box>
514 {bar}
515 <Box marginTop={1} justifyContent="space-between" alignItems="center">
516 {awsLine(status)}
517 <Button key="refresh" label="atualizar" hotkey="r" onPress={() => Promise.all([refresh($, alertGiB), checkAws($)])} />
518 </Box>
519 <Text dimColor>us$ 5 por tb, mínimo de 10 mb · alerta a partir de {alertGiB} gib</Text>
520 </Box>
521 )
522
523 // The first rows of the result, each column as wide as its widest cell up to
524 // a cap, the columns that do not fit counted rather than squeezed.
525 const previewTable = (q: Query) => {
526 if (q.previewError !== undefined) {
527 return (
528 <Box marginTop={1}>
529 <Text color={FAILED_HEX}>prévia indisponível: {q.previewError}</Text>
530 </Box>
531 )
532 }
533 if (q.preview === undefined || q.columns === undefined) {
534 return (
535 <Box marginTop={1}>
536 <Text dimColor>carregando prévia…</Text>
537 </Box>
538 )
539 }
540 const cols = q.columns
541 const widths = cols.map((c, i) => Math.min(CELL_MAX, Math.max(c.length, ...(q.preview ?? []).map(r => (r[i] ?? '').length)) + 2))
542 let room = inner - 2
543 const shown = widths.filter(w => (room -= w) >= 0).length || 1
544 const cell = (v: string, w: number) => (v.length > w - 2 ? `${v.slice(0, w - 3)}…` : v)
545 const row = (values: string[], key: string, isHead: boolean) => (
546 <Box key={key}>
547 {values.slice(0, shown).map((v, i) => (
548 <Box width={widths[i] ?? CELL_MAX}>
549 <Text bold={isHead} dimColor={isHead} wrap="truncate-end">
550 {cell(v, widths[i] ?? CELL_MAX)}
551 </Text>
552 </Box>
553 ))}
554 </Box>
555 )
556 return (
557 <Box flexDirection="column" marginTop={1} borderStyle="single" borderDimColor paddingX={1}>
558 {row(cols, `head-${q.id}`, true)}
559 {q.preview.length === 0 ? <Text dimColor>sem linhas</Text> : q.preview.map((r, i) => row(r, `row-${q.id}-${i}`, false))}
560 <Text dimColor>
561 {cols.length} {cols.length === 1 ? 'coluna' : 'colunas'}
562 {shown < cols.length ? ` · ${cols.length - shown} fora da tela` : ''} · primeiras {q.preview.length} linhas
563 </Text>
564 </Box>
565 )
566 }
567
568 const card = (q: Query) => {
569 const look = STATE[q.state] ?? { label: q.state.toLowerCase(), hex: RUNNING_HEX }
570 const percent = ((q.scannedBytes ?? 0) / biggest) * 100
571 const meter = Svg !== undefined ? (
572 <Svg source={meterSvg(percent, sizeHex(q.scannedBytes))} alt={`${bytes(q.scannedBytes)} escaneados`} width={96} height={8} />
573 ) : (
574 <Text>
575 <Text color={sizeHex(q.scannedBytes)}>{'█'.repeat(meterCells(percent))}</Text>
576 <Text dimColor>{'░'.repeat(METER_CELLS - meterCells(percent))}</Text>
577 </Text>
578 )
579 const meta = [secs(q.engineMs), q.rows === undefined ? null : `${q.rows.toLocaleString('pt-BR')} linhas`, short(q.id)]
580 .filter(Boolean)
581 .join(' · ')
582 const hasError = q.error !== undefined && q.error !== ''
583 const isExpensive = (q.scannedBytes ?? 0) >= alertGiB * 1024 ** 3
584 const isOpen = open.includes(q.id)
585 const canPreview = q.state === 'SUCCEEDED'
586
587 return (
588 <Box key={`q-${q.id}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginTop={1}>
589 <Box justifyContent="space-between" alignItems="center">
590 <Text>
591 <Text color={colorOf(q)}>● </Text>
592 <Text bold>{hhmm(q)}</Text>
593 <Text color={look.hex}> {look.label}</Text>
594 {isExpensive && <Text color={FAILED_HEX} bold> query cara</Text>}
595 </Text>
596 <Box alignItems="center" columnGap={1}>
597 {meter}
598 <Box width={10} justifyContent="flex-end">
599 <Text bold>{bytes(q.scannedBytes)}</Text>
600 </Box>
601 </Box>
602 </Box>
603 <Box marginTop={1}>
604 <Text wrap="truncate-end">{oneLine(q.sql) || 'sql ainda não lido'}</Text>
605 </Box>
606 <Text dimColor wrap="truncate-end">{meta}</Text>
607 {q.csv !== undefined && (
608 <Text dimColor wrap="truncate-start">
609 csv {q.csv}
610 </Text>
611 )}
612 {hasError && (
613 <Text color={FAILED_HEX} wrap="truncate-end">
614 {q.error}
615 </Text>
616 )}
617 {(q.partitionWarnings ?? []).map(w => (
618 <Text color={RUNNING_HEX} wrap="truncate-end">
619 ⚠ possível leitura sem partição: {w}
620 </Text>
621 ))}
622 {isOpen && previewTable(q)}
623 <Box marginTop={1} columnGap={2} flexWrap="wrap">
624 {canPreview && (
625 <Button key={`prev-${q.id}`} label={isOpen ? 'esconder prévia' : 'ver prévia'} onPress={() => toggle($, q.id)} />
626 )}
627 {q.csv !== undefined && (
628 <Button key={`open-${q.id}`} label="abrir csv" onPress={() => openFile($, q.csv ?? '', false)} />
629 )}
630 {q.csv !== undefined && (
631 <Button key={`finder-${q.id}`} label="mostrar no finder" onPress={() => openFile($, q.csv ?? '', true)} />
632 )}
633 {q.sql !== undefined && (
634 <Button key={`sql-${q.id}`} label="copiar sql" onPress={() => $.ui.copy({ text: q.sql ?? '', surface: e.surface })} />
635 )}
636 <Button key={`id-${q.id}`} label="copiar id" onPress={() => $.ui.copy({ text: q.id, surface: e.surface })} />
637 {q.csv !== undefined && (
638 <Button key={`csv-${q.id}`} label="copiar caminho do csv" onPress={() => $.ui.copy({ text: q.csv ?? '', surface: e.surface })} />
639 )}
640 </Box>
641 </Box>
642 )
643 }
644
645 return (
646 <Box flexDirection="column">
647 {summary}
648 {list.map(card)}
649 </Box>
650 )
651 })
652}
653types/index.d.ts 34 lines1export type Query = {
2 id: string
3 tool: string
4 state: string
5 sql?: string
6 database?: string
7 scannedBytes?: number
8 engineMs?: number
9 rows?: number
10 csv?: string
11 outputLocation?: string
12 submittedAt?: string
13 error?: string
14 // One line per table read without a filter on its partition columns.
15 partitionWarnings?: string[]
16 isAnalyzed?: boolean
17 isCostAlerted?: boolean
18 columns?: string[]
19 preview?: string[][]
20 previewError?: string
21}
22
23export type AwsStatus = {
24 state: 'ok' | 'expired' | 'unknown'
25 account?: string
26 detail?: string
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'query-pane': { queries: Query[]; aws: AwsStatus; expanded: string[] }
32 }
33}
34