SLOPSHOPPER

previa

Mantém um servidor de prévia no ar para o HTML editado e mostra a URL acima do prompt.

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview could not run: harness timed out after 15s
README

claude-code-mods

Cinco mods para o Claude Code, escritos como plugins de function hooks. Desenham barras e faixas acima do prompt, avisam sobre cache e uso do plano, sobem um servidor de prévia para HTML e lembram o modelo de usar o seu design system.

Funcionam no terminal e na aba Code do app desktop. Textos e comandos estão em português.

Painel de progresso

Pré-requisitos

  • Claude Code recente, com suporte a function hooks.
  • A variável CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 no bloco env do ~/.claude/settings.json. Sem ela nenhum dos mods carrega.
{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

Se o arquivo já tem um bloco env, acrescente só a linha da variável.

Instalação

Num terminal com claude aberto, instale cada mod que quiser:

/plugin install progresso --marketplace Kelvi-Maycon/claude-code-mods
/plugin install barra-uso --marketplace Kelvi-Maycon/claude-code-mods
/plugin install cache-frio --marketplace Kelvi-Maycon/claude-code-mods
/plugin install previa --marketplace Kelvi-Maycon/claude-code-mods
/plugin install ds-primeiro --marketplace Kelvi-Maycon/claude-code-mods

Na primeira vez o Claude Code pergunta se quer adicionar o marketplace: responda y. Depois escolha o escopo do usuário (o primeiro da lista). O mod já roda na sessão atual e nas próximas.

Instalado no escopo do usuário pelo terminal, o mod carrega também na aba Code do app desktop. O comando /plugin install só existe no terminal.

Mods

progresso

Barras de progresso

Barras de progresso acima do prompt. O modelo recebe uma ferramenta (progresso) e uma regra curta no system prompt: em tarefa com vários passos, cria uma barra com o plano e a atualiza conforme avança. Cada barra mostra o passo ativo, a etapa, o percentual e o estado (andamento, esperando você, falhou, concluída).

  • Subagentes e comandos Bash em background aparecem como faixas embaixo da barra, com tipo, modelo, esforço, ferramenta em uso, custo estimado e tempo.
  • Painel lateral "Progresso" com custo, tokens, tempo, agentes rodando, concluídos, com falha e planejados. Clicar num agente abre a conversa dele, e dá para mandar mensagem a ele por ali.
  • Som ao concluir, ao falhar e quando o modelo espera uma decisão sua.
  • Se o turno vai terminar com uma barra aberta, o mod pede ao modelo que a feche antes (uma vez por barra).

Comandos:

  • /progresso: mostra ou esconde as barras. Aceita on, off ou painel (abre o painel lateral).
  • /progresso-demo: demonstração de 30 segundos com todos os estados, faixas, painel e sons.
  • /progresso-limpar: remove todas as barras e faixas.

Ícones por tipo de agente: executor-leve, executor, executor-pesado, executor-design, investigador e leitor têm robôs próprios. Explore usa a lupa do investigador. general-purpose, Plan, claude, fork e qualquer outro nome usam o robô padrão, com o nome do tipo em cinza. Modelo e esforço vêm do frontmatter (name, model, effort) dos arquivos em ~/.claude/agents/.

Um passo cujo título termina com o tipo do agente entre parênteses, como Revisar tudo (executor-pesado), aparece no painel como agente planejado.

Ícones

barra-uso

Uma linha de pílulas acima do campo:

  • 5H e 7D: uso do plano nas janelas de 5 horas e 7 dias, com o tempo até renovar.
  • CTX: contexto usado até a compactação automática, pelo limite que o Claude Code informa (275k quando ele não informa nenhum).
  • CACHE: tempo restante do cache de prompt (TTL de 60 minutos, fixo) e taxa de acerto.
  • DS on/off: só aparece com o ds-primeiro configurado. Clicar alterna o DS nesta sessão.

Comando: /uso, com on ou off.

cache-frio

Quando a sessão ficou parada além do TTL do cache e o contexto passa de 100k tokens, o próximo envio regrava o cache inteiro e custa mais. O mod segura esse envio uma vez, devolve o texto ao campo e avisa. Enviar de novo dentro de 3 minutos passa direto. Comandos de barra (/compact, /clear) nunca são segurados.

Comando: /cache-frio mostra contexto e tempo de cache. /cache-frio ttl <minutos> ajusta o TTL (padrão 60), /cache-frio off e /cache-frio on desligam e ligam o aviso.

previa

Quando o modelo grava ou edita um .html, o mod sobe um python3 -m http.server na pasta do arquivo, a partir da porta 8765, e mostra a URL acima do prompt com botões para abrir, copiar e parar. O modelo recebe uma linha dizendo que o servidor já está no ar, para não abrir outro. Comandos Bash que matariam o servidor (kill, pkill, killall no processo dele) são negados.

Arquivos em /tmp e /var/folders não viram prévia.

Comando: /previa <caminho> serve uma pasta, /previa status lista os servidores, /previa parar encerra.

Requisitos: python3 e lsof no PATH. O botão Abrir usa o comando open do macOS; em Linux ele não faz nada e o botão de copiar continua funcionando.

ds-primeiro

No primeiro pedido de peça visual da sessão (página, landing, dashboard, slide, tela, componente...), anexa ao contexto uma linha pedindo que o modelo carregue a skill do seu design system antes de criar. Antes do envio aparece uma faixa avisando, com o botão "Tirar deste envio". A detecção é por verbos e alvos em português.

Não anexa quando o pedido já cita um design system, uma identidade visual, a sua skill, o rótulo ou um dos termos extras. Depois de compactar ou de /clear, volta a anexar no próximo pedido visual.

Sem configuração o mod não faz nada: não anexa, não mostra faixa e não registra comando. Para ligar, configure as opções do plugin. A tela de opções aparece na instalação, e depois fica em /config:

OpçãoO que éExemplo
skillNome da skill do design system. Vazio desliga o mod.meu-design-system
rotuloNome exibido na faixa, no toast e na linha anexada. Vazio usa o nome da skill.Minha Marca
ignorarTermos extras, separados por vírgula, que dispensam a linha quando citados.marca do cliente, sem ds

As opções ficam salvas no ~/.claude/settings.json, em pluginConfigs.

Comando: /ds mostra o estado. /ds on e /ds off valem para a sessão; /ds sempre on e /ds sempre off mudam o padrão.

Atualizar

claude plugin update <mod>@claude-code-mods

Depois, numa sessão aberta, rode /reload-plugins.

Desinstalar

claude plugin uninstall <mod>@claude-code-mods

Desenvolvimento

Cada plugin fica em plugins/<nome>/, com os hooks em hooks/ e os testes em tests/.

claude plugin validate plugins/progresso
claude plugin test plugins/progresso

O tsconfig.json de cada plugin estende .claude-plugin/types/tsconfig.json, que o Claude Code gera no primeiro carregamento do plugin. Num clone novo o editor e o tsc acusam tipos ausentes até esse primeiro carregamento (ou um claude plugin validate). A pasta gerada fica fora do git.

Os sons do progresso são gerados por plugins/progresso/sounds/gerar.py.

Créditos

A estrutura das barras do progresso foi portada do plan-progress (zycck/claude-mods), de Kirill Serditov, sob licença MIT. O texto da licença está em plugins/progresso/licenses/plan-progress-LICENSE.

Licença

MIT. Veja LICENSE.

Source 2 files
hooks/register.tsx 550 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  EngineInterface,
4  HookStream,
5  ProcessSpawnChunk,
6  ProcessSpawnResult,
7  Register,
8  Timer,
9} from 'claude-code'
10
11import type { Servidor } from '../types'
12
13type Filho = HookStream<ProcessSpawnChunk, ProcessSpawnResult>
14
15const servidores = atom({ plugin: 'previa', key: 'servidores' } as const, [])
16
17const PRIMEIRA_PORTA = 8765
18const MAXIMO = 3
19const FIM: ProcessSpawnResult = { code: null, signal: null }
20
21// Processos que este carregamento do mod abriu, por raiz. Um reload zera o mapa
22// e a engine mata os filhos; o session.start seguinte os levanta de novo.
23const vivos = new Map<string, Filho>()
24const falhas = new Map<string, { vezes: number; erro: string }>()
25const caidos = new Set<string>()
26let fila: Promise<void> = Promise.resolve()
27let gravado = '[]'
28let ultimoHtml: string | undefined
29let batida: Timer | undefined
30let avisado = ''
31let restaurar = false
32
33const pai = (caminho: string) =>
34  caminho.slice(0, caminho.lastIndexOf('/')) || '/'
35
36const nome = (caminho: string) =>
37  caminho.slice(caminho.lastIndexOf('/') + 1) || '/'
38
39const urlDe = (s: Servidor) =>
40  `http://localhost:${s.porta}/${s.arquivo.split('/').map(encodeURIComponent).join('/')}`
41
42// argv em lista: a raiz pode ter espaço e nunca passa por shell.
43const argvDe = (s: Servidor) => [
44  'python3',
45  '-m',
46  'http.server',
47  String(s.porta),
48  '--bind',
49  '127.0.0.1',
50  '--directory',
51  s.raiz,
52]
53
54const comLimite = <T,>(
55  mapa: Record<string, T>,
56  chave: string,
57  valor: T,
58  limite: number,
59) => {
60  const outras = Object.entries(mapa).filter(([k]) => k !== chave)
61
62  return Object.fromEntries([...outras, [chave, valor] as const].slice(-limite))
63}
64
65const pidNa = async ($: EngineInterface, porta: number) => {
66  const { stdout } = await $.process.run([
67    'lsof',
68    '-nP',
69    `-iTCP:${porta}`,
70    '-sTCP:LISTEN',
71    '-t',
72  ])
73
74  return stdout.trim().split('\n')[0] ?? ''
75}
76
77// Quem escuta na porta do servidor, e se é um http.server idêntico ao nosso
78// (outra sessão do usuário servindo a mesma raiz, ou um órfão de sessão anterior).
79const ocupante = async ($: EngineInterface, s: Servidor) => {
80  const pid = await pidNa($, s.porta)
81
82  if (pid === '') {
83    return { pid, igual: false }
84  }
85
86  const { stdout } = await $.process.run(['ps', '-ww', '-p', pid, '-o', 'command='])
87
88  return { pid, igual: stdout.trim().endsWith(argvDe(s).slice(1).join(' ')) }
89}
90
91const portaLivre = async ($: EngineInterface, portas: Record<string, number>) => {
92  const reservadas = new Set(Object.values(portas))
93
94  for (let porta = PRIMEIRA_PORTA; porta < PRIMEIRA_PORTA + 100; porta++) {
95    if (!reservadas.has(porta) && (await pidNa($, porta)) === '') {
96      return porta
97    }
98  }
99
100  return 0
101}
102
103const subir = ($: EngineInterface, s: Servidor) => {
104  const filho = $.process.spawn({ argv: argvDe(s) })
105  vivos.set(s.raiz, filho)
106
107  void (async () => {
108    const inicio = await $.clock.now()
109    let erro = ''
110
111    try {
112      for await (const { stream, text } of filho) {
113        if (stream === 'stderr') {
114          erro = text.trim().split('\n').pop() ?? ''
115        }
116      }
117    } catch (falha) {
118      erro = String(falha)
119    }
120
121    if (vivos.get(s.raiz) !== filho) {
122      return
123    }
124
125    vivos.delete(s.raiz)
126
127    if ((await $.clock.now()) - inicio < 5000) {
128      const vezes = (falhas.get(s.raiz)?.vezes ?? 0) + 1
129      falhas.set(s.raiz, { vezes, erro: erro.slice(0, 80) })
130    } else {
131      falhas.delete(s.raiz)
132      caidos.add(s.raiz)
133    }
134  })()
135}
136
137// Deixa um servidor da lista no ar. Devolve o motivo quando ele deve sair dela.
138const levantar = async ($: EngineInterface, s: Servidor) => {
139  const falha = falhas.get(s.raiz)
140
141  if (falha !== undefined && falha.vezes >= 3) {
142    falhas.delete(s.raiz)
143
144    return falha.erro || 'o servidor não subiu'
145  }
146
147  const pasta = await $.fs.stat(s.raiz).catch(() => undefined)
148
149  if (pasta?.kind !== 'dir') {
150    return 'a pasta não existe mais'
151  }
152
153  const portas = ((await $.store.get('portas')) ?? {}) as Record<string, number>
154  const preferida = s.porta || portas[s.raiz] || 0
155  const dono =
156    preferida > 0 ? await ocupante($, { ...s, porta: preferida }) : undefined
157  const serve = dono !== undefined && (dono.igual || dono.pid === '')
158  const porta = serve ? preferida : await portaLivre($, portas)
159
160  if (porta === 0) {
161    return `sem porta livre a partir de ${PRIMEIRA_PORTA}`
162  }
163
164  const pronto = { ...s, porta }
165
166  if (porta !== s.porta) {
167    await update($, servidores, lista =>
168      lista.map(um => (um.raiz === s.raiz ? { ...um, porta } : um)),
169    )
170  }
171
172  if (portas[s.raiz] !== porta) {
173    await $.store.set('portas', comLimite(portas, s.raiz, porta, 60))
174  }
175
176  // Porta já atendida por um http.server idêntico: usa esse, sem duplicar.
177  if (dono?.igual !== true) {
178    subir($, pronto)
179  }
180
181  if (caidos.delete(s.raiz)) {
182    $.ui.toast(`Prévia de ${nome(s.raiz)} caiu e voltou em ${urlDe(pronto)}`)
183  }
184
185  return undefined
186}
187
188const reconciliar = async ($: EngineInterface) => {
189  const lista = await read($, servidores)
190
191  for (const [raiz, filho] of vivos) {
192    if (!lista.some(s => s.raiz === raiz)) {
193      vivos.delete(raiz)
194      void filho.return(FIM)
195    }
196  }
197
198  for (const s of lista) {
199    if (vivos.has(s.raiz)) {
200      continue
201    }
202
203    const motivo = await levantar($, s)
204
205    if (motivo !== undefined) {
206      await update($, servidores, atual => atual.filter(um => um.raiz !== s.raiz))
207      $.ui.toast(`Prévia de ${nome(s.raiz)} saiu: ${motivo}`)
208    }
209  }
210
211  const final = await read($, servidores)
212  const texto = JSON.stringify(final)
213
214  if (texto !== gravado) {
215    const sessoes = ((await $.store.get('sessoes')) ?? {}) as Record<string, Servidor[]>
216    await $.store.set(
217      'sessoes',
218      comLimite(sessoes, await $.session.id(), final, 20),
219    )
220    gravado = texto
221  }
222}
223
224const rodar = ($: EngineInterface) => {
225  fila = fila
226    .then(() => reconciliar($))
227    .catch(erro => $.ui.log(`previa: ${String(erro)}`, { to: 'debug' }))
228
229  return fila
230}
231
232// O spawn sai de um timer, fora do dispatch do hook que pediu: assim o processo
233// não fica preso ao sinal de um tool.call ou de um comando.
234const acertar = ($: EngineInterface) =>
235  new Promise<void>(feito => {
236    $.clock.after(0, () => void rodar($).then(feito))
237  })
238
239// Raiz a servir: o diretório mais próximo com index.html, sem passar do
240// diretório da sessão; fora dele, sem subir até a pasta do usuário.
241const raizDe = async ($: EngineInterface, arquivo: string) => {
242  const sessao = await $.fs
243    .stat(await $.session.cwd(), { resolve: true })
244    .catch(() => undefined)
245  const cwd = sessao?.realPath
246  const dentro = cwd !== undefined && arquivo.startsWith(`${cwd}/`)
247
248  for (
249    let dir = pai(arquivo);
250    dentro ? dir.length >= cwd.length : dir.split('/').length > 4;
251    dir = pai(dir)
252  ) {
253    if (await $.fs.exists(`${dir}/index.html`)) {
254      return dir
255    }
256  }
257
258  return pai(arquivo)
259}
260
261// Põe a pasta (ou a raiz do arquivo) na frente da lista. Devolve a raiz, ou
262// undefined quando o caminho não existe.
263const servir = async ($: EngineInterface, caminho: string) => {
264  const alvo = await $.fs.stat(caminho, { resolve: true }).catch(() => undefined)
265  const real = alvo?.realPath
266
267  if (alvo === undefined || real === undefined || alvo.kind === 'other') {
268    return undefined
269  }
270
271  const raiz = alvo.kind === 'dir' ? real : await raizDe($, real)
272  const arquivo = alvo.kind === 'dir' ? '' : real.slice(raiz.length + 1)
273  const [primeiro] = await read($, servidores)
274  const mudou = primeiro?.raiz !== raiz || (arquivo !== '' && primeiro.arquivo !== arquivo)
275
276  if (mudou) {
277    await update($, servidores, lista => {
278      const antigo = lista.find(s => s.raiz === raiz)
279      const novo = {
280        raiz,
281        porta: antigo?.porta ?? 0,
282        arquivo: arquivo || antigo?.arquivo || '',
283      }
284
285      return [novo, ...lista.filter(s => s !== antigo)].slice(0, MAXIMO)
286    })
287  }
288
289  return raiz
290}
291
292// Tira da lista um servidor, ou todos. Devolve quantos saíram.
293const desligar = async ($: EngineInterface, raiz?: string) => {
294  const lista = await read($, servidores)
295  const saem = lista.filter(s => raiz === undefined || s.raiz === raiz)
296
297  await update($, servidores, atual =>
298    atual.filter(s => !saem.some(um => um.raiz === s.raiz)),
299  )
300
301  // Servidor idêntico que não é filho deste carregamento (órfão de sessão
302  // anterior): o stream não existe, então o pedido de parar vai por PID.
303  for (const s of saem) {
304    const dono = vivos.has(s.raiz) || s.porta === 0 ? undefined : await ocupante($, s)
305
306    if (dono?.igual === true) {
307      await $.process.run(['kill', dono.pid])
308    }
309  }
310
311  await acertar($)
312
313  return saem.length
314}
315
316// A lista que o store guarda para a sessão em curso, quando a tela está vazia.
317const restaurarSessao = async ($: EngineInterface) => {
318  if ((await read($, servidores)).length > 0) {
319    return
320  }
321
322  const sessoes = ((await $.store.get('sessoes')) ?? {}) as Record<string, Servidor[]>
323  const salvos = sessoes[await $.session.id()] ?? []
324
325  if (salvos.length > 0) {
326    await update($, servidores, () => salvos)
327  }
328}
329
330export const register: Register = on => {
331  on('session.start', async ($, e, next) => {
332    await $.command.register({
333      name: 'previa',
334      description: 'Liga, lista ou para o servidor de prévia',
335      argumentHint: '[caminho | status | parar]',
336      immediate: true,
337    })
338    const iniciada = await next(e)
339
340    restaurar = false
341    await restaurarSessao($)
342
343    batida?.cancel()
344    batida = $.clock.every(15000, () => void rodar($))
345    void acertar($)
346
347    return iniciada
348  })
349
350  // /clear e /resume seguem no mesmo processo com outro id e sem session.start: os
351  // servidores da sessão que acabou param (o store fica com a lista dela) e os da
352  // seguinte voltam no primeiro envio.
353  on('session.end', async ($, e, next) => {
354    for (const filho of vivos.values()) {
355      void filho.return(FIM)
356    }
357
358    vivos.clear()
359    gravado = '[]'
360    avisado = ''
361    restaurar = true
362    await update($, servidores, () => [])
363
364    return next(e)
365  })
366
367  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
368    const feito = await next(e)
369    const gravou = feito.deny === undefined && feito.isError !== true
370
371    // Rascunho em pasta temporária (scratchpad de agente) não vira prévia.
372    const temporario = /^\/(private\/)?(tmp|var\/folders)\//.test(e.file_path)
373
374    if (gravou && !temporario && /\.html$/i.test(e.file_path)) {
375      ultimoHtml = e.file_path
376      await servir($, e.file_path)
377        .then(() => void acertar($))
378        .catch(erro => $.ui.log(`previa: ${String(erro)}`, { to: 'debug' }))
379    }
380
381    return feito
382  })
383
384  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
385    const ativos = (await read($, servidores)).filter(s => s.porta > 0)
386
387    if (ativos.length === 0 || !/\b(pkill|killall|kill)\b/.test(e.command)) {
388      return next(e)
389    }
390
391    // "pkill -f 'python3 render.py'" mira outro processo e passa.
392    const emMassa =
393      /\b(pkill|killall)\b[^|;&\n]*(http\.server|\bpython3?["']?\s*(?:$|[|;&\n]))/i
394    const mata = async (s: Servidor) => {
395      const porPorta = new RegExp(`(lsof\\b[^|;&\\n]*:|kill-port\\s+)${s.porta}\\b`)
396
397      if (porPorta.test(e.command)) {
398        return true
399      }
400
401      const { pid } = await ocupante($, s)
402
403      return pid !== '' && new RegExp(`\\bkill\\b[^|;&\\n]*\\s${pid}\\b`).test(e.command)
404    }
405    let alvo = emMassa.test(e.command) ? ativos[0] : undefined
406
407    for (const s of ativos) {
408      alvo ??= (await mata(s)) ? s : undefined
409    }
410
411    return alvo === undefined
412      ? next(e)
413      : {
414          deny: `previa: esse comando derruba o servidor de prévia do usuário em http://localhost:${alvo.porta}/ (pasta ${alvo.raiz}). Ele não foi aberto pela tarefa. Quem encerra é o usuário, com /previa parar.`,
415        }
416  })
417
418  on('prompt.submit', async ($, e, next) => {
419    if (restaurar) {
420      restaurar = false
421      await restaurarSessao($)
422      // Levanta antes do aviso, para a linha dizer a porta em que o servidor ficou.
423      await acertar($)
424    }
425
426    const ativos = (await read($, servidores)).filter(s => s.porta > 0)
427
428    const onde = ativos
429      .map(s => `http://localhost:${s.porta}/ (pasta ${s.raiz})`)
430      .join(', ')
431
432    // A linha entra uma vez por conjunto de servidores, não a cada pedido. No app desktop o envio do
433    // usuário chega como `sdk`; no terminal, como `composer`.
434    const doUsuario = e.origin.kind === 'composer' || e.origin.kind === 'sdk'
435    if (!doUsuario || onde === avisado || onde === '') {
436      return next(e)
437    }
438
439    avisado = onde
440    const linha = `previa: servidor de prévia do usuário ativo em ${onde}. Não iniciar outro servidor para essa pasta nem encerrar este; ele não foi aberto pela tarefa.`
441
442    return next({ ...e, context: [...(e.context ?? []), linha] })
443  })
444
445  // O resumo da compactação não guarda a linha: o próximo pedido avisa de novo.
446  on('session.compact', ($, e, next) => {
447    avisado = ''
448
449    return next(e)
450  })
451
452  on('command.run', { command: 'previa' }, async ($, e) => {
453    const pedido = e.args.trim().replace(/^(['"])(.*)\1$/, '$2')
454
455    if (pedido === 'parar') {
456      const quantos = await desligar($)
457
458      return {
459        text:
460          quantos === 0
461            ? 'Nenhum servidor ativo.'
462            : `${quantos} ${quantos === 1 ? 'servidor encerrado' : 'servidores encerrados'}.`,
463      }
464    }
465
466    if (pedido === 'status') {
467      const ativos = (await read($, servidores)).filter(s => s.porta > 0)
468
469      return {
470        text:
471          ativos.length === 0
472            ? 'Nenhum servidor ativo.'
473            : ativos.map(s => `${nome(s.raiz)} ${urlDe(s)}`).join(' | '),
474      }
475    }
476
477    const cwd = await $.session.cwd()
478    const home = await $.env.get('HOME')
479    const caminho =
480      pedido === ''
481        ? (ultimoHtml ?? cwd)
482        : pedido.startsWith('~/') && home !== undefined
483          ? `${home}${pedido.slice(1)}`
484          : pedido.startsWith('/')
485            ? pedido
486            : `${cwd}/${pedido}`
487    const raiz = await servir($, caminho)
488
489    if (raiz === undefined) {
490      return { text: `Caminho não encontrado: ${caminho}.` }
491    }
492
493    await acertar($)
494    const servidor = (await read($, servidores)).find(s => s.raiz === raiz)
495
496    return {
497      text:
498        servidor === undefined || servidor.porta === 0
499          ? `O servidor não subiu em ${raiz}.`
500          : `No ar: ${urlDe(servidor)}`,
501    }
502  })
503
504  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
505    const ativos = (await read($, servidores)).filter(s => s.porta > 0)
506
507    if (e.props.hasSurvey || ativos.length === 0) {
508      return next(e)
509    }
510
511    const { Box, Button, Text } = $.ui.resolve(e)
512    // O que os outros mods desenham na faixa continua acima das linhas da prévia.
513    const abaixo = await next(e)
514
515    return (
516      <Box flexDirection="column">
517        {abaixo}
518        {ativos.map(s => {
519          const url = urlDe(s)
520
521          return (
522            <Box gap={2}>
523              <Text dimColor>Prévia</Text>
524              <Text bold>{nome(s.raiz)}</Text>
525              <Text wrap="truncate-middle">{url}</Text>
526              <Box gap={1}>
527                <Button
528                  key={`abrir-${s.porta}`}
529                  label="Abrir"
530                  onPress={() => void $.process.run(['open', url])}
531                />
532                <Button
533                  key={`copiar-${s.porta}`}
534                  label="Copiar"
535                  onPress={press => void $.ui.copy({ text: url, surface: press.surface })}
536                />
537                <Button
538                  key={`parar-${s.porta}`}
539                  label="Parar"
540                  onPress={() => void desligar($, s.raiz)}
541                />
542              </Box>
543            </Box>
544          )
545        })}
546      </Box>
547    )
548  })
549}
550
types/index.d.ts 16 lines
1export type Servidor = {
2  /** Pasta servida, caminho absoluto resolvido. */
3  raiz: string
4  /** Porta em 127.0.0.1; 0 enquanto o servidor ainda não foi levantado. */
5  porta: number
6  /** Último arquivo editado, relativo à raiz; vazio quando a pasta foi ligada sem arquivo. */
7  arquivo: string
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    /** Servidores desta sessão, do mais recente para o mais antigo. */
13    previa: { servidores: Servidor[] }
14  }
15}
16