Barra lateral estilo Evangelion: cuota, contexto, qué hace el agente, PRs, subagentes y alertas

Un mod de Claude Code (v2.1.287 o más nuevo) que agrega una barra lateral con estética NERV: qué está haciendo el agente, cuánto contexto y cuota te queda, tus PRs, tu máquina, tus otras sesiones y un panel para manejar forge.

La cabecera queda siempre fija: dos cintas de neón animadas, la tríada MAGI (MELCHIOR, BALTHASAR y CASPER), el estado (en espera u operando) y las pestañas. Abajo, también fijo, está el medidor de effort del modelo. El contenido de cada pestaña scrollea por su cuenta, las tarjetas largas tienen su propio scroll, y cualquier tarjeta se pliega con un click en su ▾ (arrancan abiertas y lo que pliegues queda guardado).
gh. Escanea ~/projects, o las carpetas que pongas en ~/.config/nerv/projects.json como { "roots": ["~/projects", "~/otra"] }.Además, el spinner de "pensando" se reemplaza por un escáner animado con verbos EVA ("Sincronizando", "Consultando a MAGI"…) y el SYNC de contexto. Cuando un comando que fallaba pasa, sale un confeti. En los turnos de más de 3 minutos llega una notificación MISSION COMPLETE.
"tui": "fullscreen" en tus settings. Como barra lateral necesita al menos 110 columnas, y para abrirse sola al arrancar, 144. En una ventana más angosta se muestra arriba del prompt.gh para las PRs y el CI.~/.local/bin/jcode-rail --json para HARDWARE.git clone https://github.com/gonzalonicolasr/claude-code-nerv ~/claude-code-nerv
claude --plugin-dir ~/claude-code-nerv
Para cargarlo en todas tus sesiones, agregá a ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/ruta/a/claude-code-nerv" } }
Si tenés varios mods, separalos con :.
| Comando | Qué hace | ||||
|---|---|---|---|---|---|
/nerv o /nerv on | Abre la barra | ||||
/nerv quiet | La esconde y apaga las animaciones (para grabar o compartir pantalla) | ||||
/nerv magi · git · hw · equipo · forge | Va a esa pestaña | ||||
| `/nerv tema eva01\ | eva00\ | eva02\ | eva08\ | mark06` | Cambia los colores de todo el panel |
/nerv prs | Refresca las PRs | ||||
/nerv debug | Muestra el estado interno |
Con la barra enfocada, las teclas 1 a 5 cambian de pestaña. Todo lo que se ve como botón se puede clickear.
| Tema | Colores |
|---|---|
eva01 | violeta y verde ácido (el de fábrica) |
eva00 | azul, blanco y amarillo |
eva02 | rojo, naranja y dorado |
eva08 | rosa y verde |
mark06 | azul marino y plata |
El tema elegido se guarda y también se escribe en ~/.local/state/nerv/theme.json, para que otros mods puedan leerlo.
claude plugin validate .
claude plugin test .
Mientras lo usás con --plugin-dir, Claude Code lo recarga solo cada vez que guardás hooks/register.ts.
hooks/register.ts 2600 lines1const PANE = 'nerv'
2const THEMES = {
3 eva01: { label: 'EVA-01', purple: '#8b5cf6', violet: '#7b3fb8', lime: '#a3e635', amber: '#f59e0b', orange: '#ff7a1a', red: '#ef4444', pink: '#ff4d6d', cyan: '#22d3ee', text: '#ece2fb', muted: '#8a73ad', dim: '#5b4a75', todo: '#f0c987', ink: '#140c22', deep: '#2e2340', chip: '#241a33', chipDim: '#1c1529', tabBg: '#3a2560', trail: '#1c1526', spark: '#4b2a7a' },
4 eva00: { label: 'EVA-00', purple: '#3b82f6', violet: '#60a5fa', lime: '#facc15', amber: '#f59e0b', orange: '#fb923c', red: '#ef4444', pink: '#ff4d6d', cyan: '#7dd3fc', text: '#e5e7eb', muted: '#8b9bb4', dim: '#475569', todo: '#fde68a', ink: '#0b1220', deep: '#1e2a44', chip: '#16213a', chipDim: '#111a2e', tabBg: '#1d3a70', trail: '#101828', spark: '#1e40af' },
5 eva02: { label: 'EVA-02', purple: '#ef4444', violet: '#dc2626', lime: '#fbbf24', amber: '#f59e0b', orange: '#f97316', red: '#ef4444', pink: '#ff4d6d', cyan: '#fdba74', text: '#fde8e1', muted: '#b0857a', dim: '#6b3b33', todo: '#fcd34d', ink: '#1a0a08', deep: '#3a1a14', chip: '#2c1410', chipDim: '#22100c', tabBg: '#5a1a14', trail: '#1f0d0a', spark: '#7f1d1d' },
6 eva08: { label: 'EVA-08', purple: '#ec4899', violet: '#f472b6', lime: '#22c55e', amber: '#f59e0b', orange: '#fb923c', red: '#ef4444', pink: '#ff4d6d', cyan: '#4ade80', text: '#fce7f3', muted: '#b47e9e', dim: '#6b3a5a', todo: '#f9a8d4', ink: '#1a0a14', deep: '#3a1430', chip: '#2c1224', chipDim: '#220e1c', tabBg: '#5c1846', trail: '#1f0b18', spark: '#86198f' },
7 mark06: { label: 'MK.06', purple: '#3b82f6', violet: '#1e3a8a', lime: '#cbd5e1', amber: '#f59e0b', orange: '#94a3b8', red: '#ef4444', pink: '#ff4d6d', cyan: '#93c5fd', text: '#e2e8f0', muted: '#94a3b8', dim: '#475569', todo: '#e2e8f0', ink: '#0a0f1e', deep: '#1e293b', chip: '#172036', chipDim: '#111827', tabBg: '#1e3a8a', trail: '#0f172a', spark: '#1e3a8a' },
8}
9type ThemeId = keyof typeof THEMES
10const THEME_IDS = Object.keys(THEMES) as ThemeId[]
11const int = (h: string) => parseInt(h.slice(1), 16)
12const rgbOf = (t: (typeof THEMES)[ThemeId]) => ({
13 purple: int(t.purple),
14 violet: int(t.violet),
15 lime: int(t.lime),
16 orange: int(t.orange),
17 red: int(t.red),
18 pink: int(t.pink),
19 amber: int(t.amber),
20 cyan: int(t.cyan),
21 deep: int(t.deep),
22 ink: int(t.ink),
23 trail: int(t.trail),
24 spark: int(t.spark),
25 none: 0x01000000,
26})
27let themeId: ThemeId = 'eva01'
28let C = { ...THEMES.eva01 }
29let RGB = rgbOf(THEMES.eva01)
30const applyTheme = (id: string) => {
31 if (!THEME_IDS.includes(id as ThemeId)) return false
32 themeId = id as ThemeId
33 C = { ...THEMES[themeId] }
34 RGB = rgbOf(THEMES[themeId])
35 return true
36}
37const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
38const LONG_TURN_MS = 180000
39const BATTERY_AT = 85
40const TABS = [
41 { id: 'magi', label: 'MAGI', hotkey: '1' },
42 { id: 'git', label: 'GIT', hotkey: '2' },
43 { id: 'hw', label: 'HW', hotkey: '3' },
44 { id: 'crew', label: 'EQUIPO', hotkey: '4' },
45 { id: 'forge', label: 'FORGE', hotkey: '5' },
46 { id: 'nodd', label: 'NODD', hotkey: '6' },
47 { id: 'cortex', label: 'CORTEX', hotkey: '7' },
48 { id: 'yt', label: 'YOUTUBE', hotkey: '8' },
49]
50const PHASES = ['explore', 'plan', 'build', 'veredicto']
51const SPIN = ['◐', '◓', '◑', '◒']
52
53let quiet = false
54let placedOnce = false
55let paneOpen = false
56let tab = 'magi'
57let frame = 0
58let working = false
59let turnStart = 0
60let model = ''
61let project = ''
62let cwdNow = ''
63let branch = ''
64let home = ''
65let paneId = ''
66let sessionId = ''
67let usage: any = undefined
68let account = ''
69let samples: { t: number; pct: number }[] = []
70let activity: { tool: string; label: string; start: number } | undefined
71let lastProgress = 0
72const unverified = new Set<string>()
73let lastErr = ''
74let errCount = 0
75let patternBlue: { tool: string; count: number; text: string } | undefined
76const failedCmds = new Set<string>()
77let confettiUntil = 0
78const tasks = new Map<string, { kind: string; desc: string; start: number; end?: number; status?: string }>()
79const agentToUse = new Map<string, string>()
80let prs: any[] | undefined
81let ws: { days: Record<string, number>; repos: any[]; recent: any[] } | undefined
82let wsAt = 0
83let wsBusy = false
84let prsWide = false
85let prError = ''
86let ghMe = ''
87let commitDays: Record<string, number> = {}
88let commitAuthor = ''
89let gitInfo: any = { ok: false, upstream: '', ahead: 0, behind: 0, dirty: 0, recent: [] }
90const folded = new Set<string>()
91let sessionTasks: { id: string; subject: string; status: string; blockedBy: string[] }[] = []
92let configDir = ''
93let acctMenu = false
94let acctNote = ''
95const emails: Record<string, string> = {}
96const ACCOUNTS = [
97 { id: 'personal', conf: '.claude.json', tag: 'PERSONAL' },
98 { id: 'dev', conf: '.claude-secundaria/.claude.json', tag: 'DEV' },
99]
100let health = { ok: true, when: '', text: '' }
101let recap: { prompt: string; at: number } | undefined
102let hw: any = undefined
103let hwBusy = false
104const cpuHist: number[] = []
105const gpuHist: number[] = []
106let crew: any[] = []
107let crewBusy = false
108let bodyOffset = 0
109let bodyMax = 0
110let bodyTab = ''
111let lastView = 0
112let openDrop = ''
113const forgeAccounts: Record<string, boolean> = {}
114const cardScroll: Record<string, number> = {}
115const cardMax: Record<string, number> = {}
116let cardHits: { key: string; top: number; bottom: number }[] = []
117let lastContent = 0
118let ramTotal = ''
119let effort: string | number | undefined
120let effortPick: string | undefined
121let settingsEffort = ''
122let effortFrom = 0
123let effortAt = 0
124let forge: any = undefined
125let nodd: any = undefined
126let cortex: { stats?: any; recent?: any[]; err?: string; at?: number } = {}
127let blue = { ars: 1555, live: false, at: 0 }
128type YtItem = { id: string; title: string; ch: string; d: number }
129let yt: { results: YtItem[]; queue: YtItem[]; query: string; busy: boolean; err: string; now?: { pos?: number; dur?: number; pause?: boolean; idx: number; vol?: number; title?: string } } = { results: [], queue: [], query: '', busy: false, err: '' }
130let runDir = '/tmp'
131let ytDraft = ''
132let ytRecent: string[] = []
133const ytSock = () => `${runDir}/nerv-yt.sock`
134const ytState = () => `${home}/.local/state/nerv/yt.json`
135const clockOf = (sec: number) => mmss((sec || 0) * 1000)
136let cortexKey = ''
137let cortexBusy = false
138const CORTEX_URL = 'https://cortexmem.com/api/cortex/mcp'
139const CORTEX_TYPES: Record<string, string> = { bug_fix: 'bugs', discovery: 'hallazgos', config: 'config', architecture: 'arquitectura', decision: 'decisiones', lesson: 'lecciones', pattern: 'patrones', preference: 'preferencias', session_summary: 'sesiones', context: 'contexto' }
140let forgeMissing = false
141let forgeDraft = ''
142const forgeGroup: Record<string, string> = {}
143
144const now = () => Date.now()
145const base = (p: string) => (p || '').split('/').filter(Boolean).at(-1) || p
146const clip = (s: string, n: number) => {
147 s = String(s ?? '')
148 return s.length > n ? s.slice(0, Math.max(0, n - 1)) + '…' : s
149}
150const took = (ms: number) => {
151 const sec = Math.max(0, Math.round(ms / 1000))
152 if (sec < 60) return `${sec} s`
153 const min = Math.floor(sec / 60)
154 return min < 60 ? `${min} min ${sec % 60} s` : `${Math.floor(min / 60)} h ${min % 60} min`
155}
156
157const shortPath = (p: unknown) => {
158 const s = String(p || '')
159 if (cwdNow && s.startsWith(cwdNow + '/')) return s.slice(cwdNow.length + 1)
160 return home && s.startsWith(home + '/') ? '~' + s.slice(home.length) : s
161}
162
163const oneLine = (v: unknown) => String(v ?? '').replace(/\s+/g, ' ').trim()
164
165const toolHead = (tool: string, input: any) => {
166 const i = input && typeof input === 'object' ? input : {}
167 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
168 if (mcp) return { name: `${mcp[1]} · ${mcp[2]}`, arg: oneLine(Object.values(i).find((v) => typeof v === 'string') || '') }
169 if (tool === 'Bash') return { name: oneLine(i.description) || 'Bash', arg: oneLine(i.command) }
170 if (['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool)) return { name: tool, arg: shortPath(i.file_path ?? i.notebook_path) }
171 if (tool === 'Grep' || tool === 'Glob') return { name: tool === 'Grep' ? 'Buscar' : 'Glob', arg: oneLine(i.pattern) + (i.path ? ` en ${shortPath(i.path)}` : '') }
172 if (tool === 'Agent' || tool === 'Task') return { name: `Agente${i.subagent_type ? ' ' + i.subagent_type : ''}`, arg: oneLine(i.description) }
173 if (tool === 'WebFetch' || tool === 'WebSearch') return { name: tool, arg: oneLine(i.url ?? i.query) }
174 const first = Object.values(i).find((v) => typeof v === 'string')
175 return { name: tool, arg: oneLine(first || '') }
176}
177
178const toolGlyph = (p: { isRunning?: boolean; isErrored?: boolean; isInterrupted?: boolean }) =>
179 p.isRunning ? { g: SPIN[frame % 4], c: C.lime } : p.isErrored ? { g: '✖', c: C.red } : p.isInterrupted ? { g: '◌', c: C.dim } : { g: '◇', c: C.purple }
180
181const GROUP_WORDS: Record<string, [string, string]> = {
182 Read: ['lectura', 'lecturas'],
183 Bash: ['comando', 'comandos'],
184 Grep: ['búsqueda', 'búsquedas'],
185 Glob: ['búsqueda', 'búsquedas'],
186 LS: ['listado', 'listados'],
187 WebFetch: ['página', 'páginas'],
188 WebSearch: ['búsqueda web', 'búsquedas web'],
189 ToolSearch: ['tool cargada', 'tools cargadas'],
190}
191
192const groupSummary = (calls: readonly any[]) => {
193 const counts = new Map<string, number>()
194 for (const c of calls) {
195 const w = GROUP_WORDS[c.tool]
196 const key = w ? w.join('|') : c.tool
197 counts.set(key, (counts.get(key) || 0) + 1)
198 }
199 return [...counts].map(([k, n]) => (k.includes('|') ? `${n} ${n === 1 ? k.split('|')[0] : k.split('|')[1]}` : `${n} ${k}`)).join(' · ')
200}
201
202const mmss = (ms: number) => {
203 const s = Math.max(0, Math.floor(ms / 1000))
204 const m = Math.floor(s / 60)
205 return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}:${String(s % 60).padStart(2, '0')}`
206}
207const escHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
208export const missionNotify = (root: string, project: string, took: string, unverified: number, c: { orange: string; amber: string; lime: string }) => {
209 const check = unverified
210 ? `<font color="${c.amber}"><b>⚠ ${unverified} sin verificar</b></font>`
211 : `<font color="${c.lime}">✓ verificado</font>`
212 const body = `<b>${escHtml(project)}</b> · <font color="${c.orange}">⏱ ${took}</font> · ${check}`
213 return ['notify-send', '-a', 'NERV', '-i', `${root}/assets/mission-complete.png`, 'MISSION COMPLETE', body]
214}
215const ago = (ms: number) => {
216 const m = Math.floor(ms / 60000)
217 return m < 60 ? `hace ${m} min` : m < 1440 ? `hace ${Math.floor(m / 60)} h` : `hace ${Math.floor(m / 1440)} d`
218}
219const heat = (p: number) => (p >= 90 ? C.red : p >= 70 ? C.amber : C.lime)
220const energyBar = (p: number, w: number, hue: string, t = now()) => {
221 const filled = Math.max(p > 0 ? 1 : 0, Math.min(w, Math.round((p / 100) * w)))
222 const base = int(hue)
223 const step = Math.floor(t / 70)
224 const glint = step % (filled + 4)
225 const cells: { ch: number; color: number }[] = []
226 for (let i = 0; i < w; i++) {
227 if (i < filled) {
228 let c = mix(int(C.ink), base, 0.45 + (0.55 * (i + 1)) / filled)
229 if (i === glint) c = mix(base, 0xffffff, 0.6)
230 else if (i === glint - 1 || i === glint + 1) c = mix(base, 0xffffff, 0.25)
231 if (i === filled - 1 && step % 3 === 0) c = mix(c, 0xffffff, 0.35)
232 if (p >= 90 && Math.floor(t / 250) % 2 === 0) c = mix(c, int(C.red), 0.4)
233 cells.push({ ch: 0x2501, color: c })
234 } else cells.push((step + i * 5) % 29 === 0 ? { ch: 0xb7, color: mix(int(C.dim), base, 0.45) } : { ch: 0x2508, color: int(C.dim) })
235 }
236 return cells
237}
238
239const gaugeCells = (p: number, w: number, hue: string) => {
240 const bar = energyBar(p, w, hue)
241 return glyphs(w, 1, (x) => [bar[x].ch, bar[x].color])
242}
243
244let gauges: { key: string; p: number; w: number; hue: string }[] = []
245
246const dots = (p: number, w: number) => {
247 const f = Math.max(0, Math.min(w, Math.round((p / 100) * w)))
248 return '●'.repeat(f) + '·'.repeat(w - f)
249}
250const blink = () => Math.floor(frame / 4) % 2 === 0
251const tag = (text: string, name: string) => (text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`)) || [])[1]
252
253const label = (e: any) => {
254 if (e.tool === 'Bash') return e.description || e.command || 'Bash'
255 if (EDIT_TOOLS.includes(e.tool) || e.tool === 'Read') return base(e.file_path || e.notebook_path || '')
256 if (e.tool === 'Grep' || e.tool === 'Glob') return e.pattern || ''
257 if (e.tool === 'Agent' || e.tool === 'Task') return e.description || ''
258 if (e.tool === 'WebFetch') return (e.url || '').replace(/^https?:\/\//, '').split('/')[0]
259 if (e.tool === 'WebSearch') return e.query || ''
260 return ''
261}
262
263const pct = (k: string) => usage?.rateLimits?.find((l: any) => l.kind === k)?.percentUsed
264
265const take = (u: any) => {
266 if (!u) return
267 usage = u
268 const p = pct('five_hour')
269 if (typeof p === 'number') {
270 samples.push({ t: now(), pct: p })
271 samples = samples.filter((s) => now() - s.t < 3600000)
272 }
273}
274
275const remaining = () => {
276 const p = pct('five_hour')
277 if (typeof p !== 'number' || samples.length < 2) return undefined
278 const first = samples[0]
279 const rate = (p - first.pct) / ((now() - first.t) / 60000)
280 if (rate <= 0) return undefined
281 return ((100 - p) / rate) * 60000
282}
283
284const EFFORTS = [
285 { id: 'low', name: 'LOW', get hue() { return RGB.cyan } },
286 { id: 'medium', name: 'MEDIUM', get hue() { return RGB.lime } },
287 { id: 'high', name: 'HIGH', get hue() { return RGB.amber } },
288 { id: 'xhigh', name: 'XHIGH', get hue() { return RGB.orange } },
289 { id: 'max', name: 'MAX', get hue() { return RGB.red } },
290]
291
292const effortLevel = (v = effort) => {
293 if (typeof v === 'number') return Math.max(1, Math.min(5, Math.round(Math.log2(Math.max(1024, v) / 1024)) + 1))
294 return EFFORTS.findIndex((x) => x.id === v) + 1
295}
296
297const effortName = () => (typeof effort === 'number' ? `${Math.round(effort / 1000)}K` : EFFORTS[effortLevel() - 1]?.name || '–')
298
299const hex = (n: number) => '#' + n.toString(16).padStart(6, '0')
300
301const setEffort = (v: string | number | undefined) => {
302 if (v === undefined || v === effort) return
303 effortFrom = effortShown()
304 effortAt = now()
305 effort = v
306}
307
308const effortShown = () => {
309 const k = Math.min(1, (now() - effortAt) / 1100)
310 return effortFrom + (effortLevel() - effortFrom) * (1 - (1 - k) ** 3)
311}
312
313const battery = () => (usage?.context?.percent ?? 0) >= BATTERY_AT || (pct('five_hour') ?? 0) >= BATTERY_AT
314
315async function ghEnv($: any, cwd: string) {
316 const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd, timeoutMs: 5000 }).catch(() => undefined)
317 const url = r?.stdout?.trim() || ''
318 if (!url) return undefined
319 if (!/github\.com[:/]gonzalonicolasr\//.test(url)) return {}
320 const t = await $.process.run(['gh', 'auth', 'token', '--user', 'gonzalonicolasr'], { timeoutMs: 5000 }).catch(() => undefined)
321 return t?.exitCode === 0 ? { GH_TOKEN: t.stdout.trim() } : {}
322}
323
324const WS_SCAN = [
325 'while IFS= read -r r; do',
326 ' [ -e "$r/.git" ] || continue',
327 ' e=$(git -C "$r" config user.email 2>/dev/null)',
328 ' [ -n "$e" ] || continue',
329 ' n=${r%/}; n=${n##*/}',
330 ' git -C "$r" log --all --since=26.weeks --format="D %H %ad" --date=short --author="$e" 2>/dev/null',
331 ' git -C "$r" log --all -3 --since=26.weeks --format="C%x1f$n%x1f%ct%x1f%h%x1f%s%x1f$r" --author="$e" 2>/dev/null',
332 "done | awk '/^D /{ if (!seen[$2]++) c[$3]++; next } { print } END { for (d in c) printf \"D\\x1f%d\\x1f%s\\n\", c[d], d }'",
333].join('\n')
334
335const WS_STATE = 'while IFS= read -r r; do printf "%s\\x1f%s\\x1f%s\\n" "$r" "$(git -C "$r" rev-parse --abbrev-ref HEAD 2>/dev/null)" "$(git -C "$r" status --porcelain 2>/dev/null | wc -l)"; done'
336
337async function refreshWorkspace($: any) {
338 if (wsBusy || !home) return
339 wsBusy = true
340 try {
341 let roots = [`${home}/projects`]
342 try {
343 const cfg = JSON.parse(String((await $.fs.read(`${home}/.config/nerv/projects.json`).catch(() => '')) || '{}'))
344 if (Array.isArray(cfg.roots) && cfg.roots.length) roots = cfg.roots.map((r: string) => String(r).replace(/^~(?=\/|$)/, home))
345 } catch {}
346 const dirs: string[] = []
347 for (const root of roots) for (const d of ((await $.fs.list(root).catch(() => [])) as any[])) if (d.kind === 'dir') dirs.push(`${root}/${d.name}`)
348 const r = await $.process.run(['bash', '-c', WS_SCAN], { stdin: dirs.join('\n') + '\n', timeoutMs: 60000 }).catch(() => undefined)
349 if (r?.exitCode !== 0) return
350 const days: Record<string, number> = {}
351 const commits: any[] = []
352 const seen = new Set<string>()
353 for (const line of String(r.stdout).split('\n')) {
354 const f = line.split('\x1f')
355 if (f[0] === 'D') days[f[2]] = (days[f[2]] || 0) + Number(f[1])
356 else if (f[0] === 'C' && !seen.has(f[3])) {
357 seen.add(f[3])
358 commits.push({ repo: f[1], ct: Number(f[2]), h: f[3], s: f[4], path: f[5] })
359 }
360 }
361 commits.sort((a, b) => b.ct - a.ct)
362 const last = new Map<string, any>()
363 for (const c of commits) if (!last.has(c.repo)) last.set(c.repo, c)
364 const top = [...last.values()].slice(0, 6)
365 const st = await $.process.run(['bash', '-c', WS_STATE], { stdin: top.map((c) => c.path).join('\n') + '\n', timeoutMs: 30000 }).catch(() => undefined)
366 const info = new Map<string, string[]>()
367 for (const line of String(st?.stdout || '').split('\n')) if (line) info.set(line.split('\x1f')[0], line.split('\x1f'))
368 ws = {
369 days,
370 repos: top.map((c) => ({ name: c.repo, ct: c.ct, branch: info.get(c.path)?.[1] || '?', dirty: Number(info.get(c.path)?.[2] || 0) })),
371 recent: commits.slice(0, 8),
372 }
373 wsAt = now()
374 if (!gitInfo.ok) commitDays = ws.days
375 $.ui.invalidate('ui.render')
376 } finally {
377 wsBusy = false
378 }
379}
380
381async function refreshWidePRs($: any) {
382 const who = await $.process.run(['bash', '-c', 'gh auth status 2>&1 | grep -o "account [^ ]*" | cut -d" " -f2 | sort -u'], { timeoutMs: 10000 }).catch(() => undefined)
383 const users = String(who?.stdout || '').split('\n').filter(Boolean)
384 const all: any[] = []
385 for (const u of users) {
386 const t = await $.process.run(['gh', 'auth', 'token', '--user', u], { timeoutMs: 5000 }).catch(() => undefined)
387 if (t?.exitCode !== 0) continue
388 const r = await $.process
389 .run(['gh', 'search', 'prs', '--author', '@me', '--state', 'open', '--limit', '15', '--json', 'repository,number,title,url,isDraft'], { env: { GH_TOKEN: t.stdout.trim() }, timeoutMs: 20000 })
390 .catch(() => undefined)
391 if (r?.exitCode !== 0) continue
392 try {
393 for (const p of JSON.parse(r.stdout)) all.push({ ...p, repo: p.repository?.name || '' })
394 } catch {}
395 }
396 prs = all
397 prsWide = true
398 prError = users.length ? '' : 'sin cuentas de gh'
399}
400
401async function refreshPRs($: any) {
402 const cwd = await $.session.cwd().catch(() => '')
403 if (!cwd) return
404 const env = await ghEnv($, cwd)
405 if (!env) return refreshWidePRs($)
406 prsWide = false
407 if (!ghMe) {
408 const me = await $.process.run(['gh', 'api', 'user', '-q', '.login'], { cwd, env, timeoutMs: 10000 }).catch(() => undefined)
409 ghMe = me?.exitCode === 0 ? me.stdout.trim() : ''
410 }
411 const r = await $.process
412 .run(['gh', 'pr', 'list', '--state', 'open', '--limit', '20', '--json', 'number,title,reviewDecision,isDraft,statusCheckRollup,url,author'], { cwd, env, timeoutMs: 20000 })
413 .catch((err: any) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
414 if (r.exitCode !== 0) prError = clip((r.stderr || 'gh falló').split('\n')[0], 60)
415 else
416 try {
417 prs = JSON.parse(r.stdout)
418 prError = ''
419 } catch {
420 prError = 'respuesta de gh ilegible'
421 }
422}
423
424const acctNow = () => (!configDir || configDir === `${home}/.claude` ? 'personal' : configDir === `${home}/.claude-secundaria` ? 'dev' : '')
425const acctName = (id: string) => (emails[id] || id).split('@')[0]
426
427async function loadEmails($: any) {
428 for (const a of ACCOUNTS) {
429 try {
430 emails[a.id] = String(JSON.parse((await $.fs.read(`${home}/${a.conf}`).catch(() => '')) || '{}')?.oauthAccount?.emailAddress || '')
431 } catch {}
432 }
433}
434
435async function switchAccount($: any, id: string) {
436 acctNote = working ? 'esperá a que termine el turno' : !paneId || !sessionId ? 'sólo se puede dentro de herdr' : 'cambiando de cuenta…'
437 $.ui.invalidate('ui.render')
438 if (acctNote !== 'cambiando de cuenta…') return
439 const r = await $.process.run([`${home}/.local/bin/claude-cuenta`, id, sessionId, paneId], { timeoutMs: 10000 }).catch((err: any) => ({ exitCode: 1, stderr: String(err) }))
440 if (r?.exitCode !== 0) {
441 acctNote = clip(String(r?.stderr || 'no se pudo cambiar').trim(), 60)
442 $.ui.invalidate('ui.render')
443 return
444 }
445 await $.clock.sleep(20000).catch(() => undefined)
446 const log = String((await $.fs.read(`${home}/.local/state/claude-cuenta/${sessionId}.log`).catch(() => '')) || '').trim().split('\n').pop()
447 acctNote = clip(`no se pudo cambiar${log ? ': ' + log : ''}`, 70)
448 $.ui.invalidate('ui.render')
449}
450
451async function refreshTasks($: any) {
452 if (!home || !sessionId) return
453 const list = (await $.env.get('CLAUDE_CODE_TASK_LIST_ID').catch(() => '')) || sessionId
454 const dir = `${configDir || home + '/.claude'}/tasks/${list}`
455 const files = ((await $.fs.list(dir).catch(() => [])) as any[]).filter((f) => /^\d+\.json$/.test(f.name))
456 const out: typeof sessionTasks = []
457 for (const f of files) {
458 try {
459 const x = JSON.parse((await $.fs.read(`${dir}/${f.name}`).catch(() => '')) || '{}')
460 if (x?.subject && x.status !== 'deleted') out.push({ id: String(x.id ?? f.name.slice(0, -5)), subject: String(x.subject), status: String(x.status || 'pending'), blockedBy: (x.blockedBy || []).map(String) })
461 } catch {}
462 }
463 sessionTasks = out.sort((a, b) => Number(a.id) - Number(b.id))
464}
465
466async function refreshLocal($: any) {
467 await refreshTasks($)
468 const cwd = await $.session.cwd().catch(() => '')
469 cwdNow = cwd
470 project = base(cwd)
471 model = await $.session.model().catch(() => model)
472 const b = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: 5000 }).catch(() => undefined)
473 branch = b?.exitCode === 0 ? b.stdout.trim() : ''
474 const em = await $.process.run(['git', 'config', 'user.email'], { cwd, timeoutMs: 5000 }).catch(() => undefined)
475 const email = em?.exitCode === 0 ? em.stdout.trim() : ''
476 const lg = await $.process
477 .run(['git', 'log', '--all', '--since=26.weeks', '--format=%ad', '--date=short', ...(email ? ['--author=' + email] : [])], { cwd, timeoutMs: 10000 })
478 .catch(() => undefined)
479 const days: Record<string, number> = {}
480 if (lg?.exitCode === 0) for (const d of lg.stdout.split('\n')) if (d) days[d] = (days[d] || 0) + 1
481 commitDays = days
482 commitAuthor = email
483 const stb = await $.process.run(['git', 'status', '--porcelain=2', '--branch'], { cwd, timeoutMs: 8000 }).catch(() => undefined)
484 const sl = stb?.exitCode === 0 ? stb.stdout.split('\n') : []
485 const ab = (sl.find((l: string) => l.startsWith('# branch.ab')) || '').match(/\+(\d+) -(\d+)/)
486 const up = (sl.find((l: string) => l.startsWith('# branch.upstream')) || '').replace('# branch.upstream ', '')
487 const dirty = sl.filter((l: string) => l && !l.startsWith('#')).length
488 const rl = await $.process.run(['git', 'log', '-8', '--format=%h%x1f%s%x1f%ar%x1f%an'], { cwd, timeoutMs: 8000 }).catch(() => undefined)
489 gitInfo = {
490 ok: stb?.exitCode === 0,
491 upstream: up,
492 ahead: ab ? Number(ab[1]) : 0,
493 behind: ab ? Number(ab[2]) : 0,
494 dirty,
495 recent: rl?.exitCode === 0 ? rl.stdout.split('\n').filter(Boolean).map((l: string) => l.split('\x1f')) : [],
496 }
497 if (!gitInfo.ok) {
498 if (ws) commitDays = ws.days
499 if (now() - wsAt > 300000) void refreshWorkspace($)
500 }
501 const st: any = await $.settings.read().catch(() => undefined)
502 const se = String(st?.effortLevel || '')
503 if (se && se !== settingsEffort) {
504 settingsEffort = se
505 if (!effortPick) setEffort(se)
506 }
507 if (home) {
508 const log = await $.fs.read(`${home}/.local/state/claude-healthcheck.log`).catch(() => '')
509 const lines = (log || '').trim().split('\n')
510 const last = lines.filter((l: string) => /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d /.test(l)).at(-1) || ''
511 const when = last.slice(11, 16)
512 if (/ ok$/.test(last)) health = { ok: true, when, text: 'servicios ok' }
513 else if (last) {
514 const i = lines.lastIndexOf(last)
515 const falla = lines.slice(i + 1).find((l: string) => l.startsWith('- ')) || 'algo falló'
516 health = { ok: false, when, text: falla.replace(/^- /, '') }
517 }
518 }
519}
520
521async function refreshHw($: any) {
522 if (hwBusy || !home) return
523 hwBusy = true
524 try {
525 const env: Record<string, string> = ramTotal ? { JCODE_RAIL_RAM_TOTAL: ramTotal } : {}
526 const r = await $.process.run([`${home}/.local/bin/jcode-rail`, '--json'], { env, timeoutMs: 8000 }).catch(() => undefined)
527 if (r?.exitCode === 0) {
528 hw = JSON.parse(r.stdout)
529 if (typeof hw?.cpu?.pct === 'number') cpuHist.push(hw.cpu.pct)
530 if (typeof hw?.gpu?.util === 'number') gpuHist.push(hw.gpu.util)
531 while (cpuHist.length > 200) cpuHist.shift()
532 while (gpuHist.length > 200) gpuHist.shift()
533 }
534 } catch {
535 } finally {
536 hwBusy = false
537 }
538}
539
540async function refreshCrew($: any) {
541 if (crewBusy) return
542 crewBusy = true
543 try {
544 const a = await herdrJson($, ['agent', 'list'])
545 const w = await herdrJson($, ['workspace', 'list'])
546 const t = await herdrJson($, ['tab', 'list'])
547 if (!a) {
548 crew = []
549 return
550 }
551 const spaces = Object.fromEntries((w?.workspaces || []).map((x: any) => [x.workspace_id, x.label]))
552 const tabs = Object.fromEntries((t?.tabs || []).map((x: any) => [x.tab_id, x.label || '']))
553 const rank: any = { blocked: 0, done: 1, working: 2, idle: 3 }
554 crew = a.agents
555 .map((x: any) => {
556 const tb = tabs[x.tab_id] || ''
557 return {
558 status: x.agent_status,
559 agent: x.agent,
560 where: tb && !/^\d+$/.test(tb) ? `${spaces[x.workspace_id] || ''} › ${tb}` : spaces[x.workspace_id] || '',
561 title: (x.terminal_title_stripped || '').replace(/^(π|pi)\s*-\s*\S+$/, ''),
562 me: x.pane_id === paneId,
563 }
564 })
565 .sort((x: any, y: any) => (rank[x.status] ?? 9) - (rank[y.status] ?? 9) || x.where.localeCompare(y.where))
566 } catch {
567 } finally {
568 crewBusy = false
569 }
570}
571
572async function herdrJson($: any, args: string[]) {
573 const r = await $.process.run(['herdr', ...args], { timeoutMs: 5000 }).catch(() => undefined)
574 if (r?.exitCode !== 0) return undefined
575 try {
576 return JSON.parse(r.stdout).result
577 } catch {
578 return undefined
579 }
580}
581
582async function loadRecap($: any) {
583 const all = ((await $.store.get('sessions').catch(() => undefined)) || {}) as Record<string, any>
584 const cut = now() - 7 * 86400000
585 for (const k of Object.keys(all)) if (all[k].beat < cut) delete all[k]
586 const prev = Object.entries(all)
587 .filter(([id, s]: any) => id !== sessionId && s.cwd === cwdNow && now() - s.beat > 180000 && s.prompt)
588 .sort(([, a]: any, [, b]: any) => b.beat - a.beat)[0]
589 if (prev) {
590 recap = { prompt: (prev[1] as any).prompt, at: (prev[1] as any).beat }
591 delete all[prev[0]]
592 }
593 all[sessionId] = { cwd: cwdNow, beat: now(), prompt: '' }
594 await $.store.set('sessions', all).catch(() => undefined)
595}
596
597async function beat($: any, prompt?: string) {
598 if (!sessionId) return
599 const all = ((await $.store.get('sessions').catch(() => undefined)) || {}) as Record<string, any>
600 const mine = all[sessionId] || { cwd: cwdNow, prompt: '' }
601 all[sessionId] = { ...mine, beat: now(), ...(prompt ? { prompt: clip(prompt.replace(/\s+/g, ' '), 140) } : {}) }
602 await $.store.set('sessions', all).catch(() => undefined)
603}
604
605async function clean($: any) {
606 const all = ((await $.store.get('sessions').catch(() => undefined)) || {}) as Record<string, any>
607 delete all[sessionId]
608 await $.store.set('sessions', all).catch(() => undefined)
609}
610
611const cells = (cols: number, rows: number, paint: (x: number, y: number) => number) => {
612 const words = new Uint32Array(cols * rows * 3)
613 for (let r = 0; r < rows; r++)
614 for (let c = 0; c < cols; c++) {
615 const top = paint(c, r * 2)
616 const bot = paint(c, r * 2 + 1)
617 const i = (r * cols + c) * 3
618 if (top === RGB.none && bot === RGB.none) {
619 words[i] = 0x20
620 words[i + 1] = RGB.none
621 words[i + 2] = RGB.none
622 } else if (top === RGB.none) {
623 words[i] = 0x2584
624 words[i + 1] = bot
625 words[i + 2] = RGB.none
626 } else {
627 words[i] = 0x2580
628 words[i + 1] = top
629 words[i + 2] = bot
630 }
631 }
632 return (new Uint8Array(words.buffer) as any).toBase64()
633}
634
635const TRAIL = [0x28ff, 0x28f7, 0x28f6, 0x2876, 0x2836, 0x2816, 0x2806, 0x2804]
636
637const glyphs = (cols: number, rows: number, paint: (x: number, y: number) => [number, number] | undefined) => {
638 const words = new Uint32Array(cols * rows * 3)
639 for (let y = 0; y < rows; y++)
640 for (let x = 0; x < cols; x++) {
641 const i = (y * cols + x) * 3
642 const g = paint(x, y)
643 words[i] = g ? g[0] : 0x20
644 words[i + 1] = g ? g[1] : RGB.none
645 words[i + 2] = RGB.none
646 }
647 return (new Uint8Array(words.buffer) as any).toBase64()
648}
649
650const hash = (x: number, y: number, k: number) => {
651 const v = Math.sin(x * 127.1 + y * 311.7 + k * 74.7) * 43758.5453
652 return v - Math.floor(v)
653}
654
655const ramp = (stops: number[], v: number) => {
656 const p = Math.max(0, Math.min(0.9999, v)) * (stops.length - 1)
657 const i = Math.floor(p)
658 return mix(stops[i], stops[i + 1], p - i)
659}
660
661
662const WAVES = {
663 get eva() { return [RGB.lime, RGB.purple] },
664 get alert() { return [RGB.purple, RGB.lime] },
665 get blue() { return [RGB.amber, RGB.orange] },
666}
667
668const softWave = (cols: number, rows: number) => {
669 const h = rows * 2
670 const t = frame * (working ? 0.2 : 0.06)
671 const [hi, lo] = battery() ? WAVES.alert : patternBlue ? WAVES.blue : WAVES.eva
672 const party = now() < confettiUntil
673 const amp = (h - 1) / 2
674 const glintA = (frame * (working ? 0.9 : 0.35)) % (cols + 16) - 8
675 const glintB = cols - ((frame * (working ? 0.7 : 0.25)) % (cols + 16) - 8)
676 const ink = RGB.ink
677 return cells(cols, rows, (x, y) => {
678 if (party && hash(x, y, frame) > 0.9) return [RGB.lime, RGB.pink, RGB.cyan, RGB.amber][Math.floor(hash(y, x, frame) * 4)]
679 const w1 = amp + Math.sin(x * 0.2 + t) * amp * 0.85
680 const w2 = amp + Math.sin(x * 0.13 - t * 0.7 + 1.3) * amp * 0.65
681 const i1 = Math.exp(-((y - w1) ** 2) / 0.45)
682 const i2 = Math.exp(-((y - w2) ** 2) / 0.45)
683 const g1 = Math.exp(-(((x - glintA) / 2.5) ** 2))
684 const g2 = Math.exp(-(((x - glintB) / 2.5) ** 2))
685 const top = Math.max(i1, i2)
686 if (top < 0.12) return RGB.none
687 const front = i1 >= i2
688 const hue = front ? hi : lo
689 const glint = front ? g1 : g2
690 const base = mix(ink, hue, Math.min(1, 0.35 + top * (working ? 0.75 : 0.6)))
691 return mix(base, 0xffffff, glint * top * 0.75)
692 })
693}
694
695const dayKey = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
696
697const heatColor = (n: number) => (n <= 0 ? 0x3a2d52 : n === 1 ? RGB.violet : n <= 3 ? RGB.purple : n <= 6 ? RGB.lime : n <= 10 ? RGB.orange : RGB.red)
698
699const commitStats = (weeks: number) => {
700 const today = new Date()
701 let total = 0
702 for (let i = 0; i < weeks * 7; i++) total += commitDays[dayKey(new Date(today.getTime() - i * 86400000))] || 0
703 let streak = 0
704 for (let i = commitDays[dayKey(today)] ? 0 : 1; commitDays[dayKey(new Date(today.getTime() - i * 86400000))]; i++) streak++
705 return { total, streak, today: commitDays[dayKey(today)] || 0 }
706}
707
708const heatCells = (cols: number, weeks: number) => {
709 const today = new Date()
710 const dow = today.getDay()
711 const pulse = 0.5 + 0.5 * Math.sin(frame * 0.35)
712 return glyphs(cols, 7, (x, y) => {
713 if (x % 2 === 1) return undefined
714 const w = weeks - 1 - x / 2
715 const back = w * 7 + (dow - y)
716 if (w < 0 || back < 0) return undefined
717 const n = commitDays[dayKey(new Date(today.getTime() - back * 86400000))] || 0
718 const c = heatColor(n)
719 return [0x25a0, back === 0 ? mix(n ? c : RGB.purple, 0xffffff, 0.25 + 0.45 * pulse) : c]
720 })
721}
722
723const sparkCells = (cols: number, rows: number) => {
724 const h = rows * 2
725 const level = (hist: number[], x: number) => {
726 const v = hist[hist.length - cols + x]
727 return typeof v === 'number' ? h - 1 - Math.round((v / 100) * (h - 1)) : undefined
728 }
729 return cells(cols, rows, (x, y) => {
730 const g = level(gpuHist, x)
731 const c = level(cpuHist, x)
732 if (c !== undefined && y === c) return RGB.lime
733 if (g !== undefined && y >= g) return y === g ? RGB.purple : RGB.deep
734 return RGB.none
735 })
736}
737
738const SCAN_W = 18
739const VERBS: Record<string, string[]> = {
740 thinking: ['Sincronizando', 'Consultando a MAGI', 'Analizando patrón', 'Calculando'],
741 requesting: ['Conectando cable umbilical', 'Enlazando con MAGI'],
742 responding: ['Transmitiendo', 'Redactando informe'],
743 'tool-input': ['Preparando despliegue', 'Armando la orden'],
744 'tool-use': ['Desplegando', 'Ejecutando operación'],
745}
746const SCAN_COLOR: Record<string, number> = {
747 get thinking() { return RGB.purple },
748 get requesting() { return RGB.amber },
749 get responding() { return RGB.cyan },
750 get 'tool-input'() { return RGB.lime },
751 get 'tool-use'() { return RGB.lime },
752}
753
754const mix = (a: number, b: number, k: number) => {
755 const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - k) + ((b >> s) & 255) * k)
756 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
757}
758
759const scanCells = (mode: string) => {
760 const span = SCAN_W - 1
761 const pos = frame % (span * 2)
762 const head = pos <= span ? pos : span * 2 - pos
763 const dir = pos <= span ? 1 : -1
764 const hue = SCAN_COLOR[mode] || RGB.purple
765 return glyphs(SCAN_W, 1, (x) => {
766 const behind = (head - x) * dir
767 if (x === head) return [0x28ff, 0xffffff]
768 if (behind > 0 && behind < TRAIL.length) return [TRAIL[behind], mix(hue, RGB.trail, behind / TRAIL.length)]
769 if (hash(x, 0, Math.floor(frame / 2)) > 0.8) return [0x2802, RGB.spark]
770 return undefined
771 })
772}
773
774const effortGeom = (cols: number) => {
775 const segW = Math.max(3, Math.floor((cols - 4 - 4) / 5))
776 return { segW, step: segW + 1 }
777}
778
779const effortCells = (cols: number) => {
780 const lvl = effortLevel()
781 const shown = effortShown()
782 const { segW, step } = effortGeom(cols)
783 const charging = now() - effortAt < 1100
784 return cells(cols, 2, (x, py) => {
785 const sx = x - (3 - py)
786 if (sx < 0) return RGB.none
787 const seg = Math.floor(sx / step)
788 const inner = sx % step
789 if (seg > 4 || inner >= segW) return RGB.none
790 const hue = EFFORTS[seg].hue
791 const fillK = Math.max(0, Math.min(1, shown - seg))
792 const lit = inner < fillK * segW
793 const edge = py === 0 || py === 3 || inner === 0 || inner === segW - 1
794 if (!lit) return edge ? mix(hue, RGB.ink, 0.72) : RGB.none
795 if (lvl === 5 && seg === 4) return (sx + py + Math.floor(frame / 2)) % 4 < 2 ? RGB.red : 0x3a0a10
796 let col = mix(mix(hue, RGB.ink, 0.25), hue, inner / Math.max(1, segW - 1))
797 if (py === 0) col = mix(col, 0xffffff, 0.35)
798 if (py === 3) col = mix(col, RGB.ink, 0.35)
799 if (seg === lvl - 1) {
800 const pulse = 0.5 + 0.5 * Math.sin(frame * (working ? 0.5 : 0.22))
801 const sweep = (frame * (working ? 0.8 : 0.35)) % (segW + 6) - 3
802 col = mix(col, 0xffffff, 0.15 * pulse + 0.55 * Math.exp(-(((inner - sweep) / 1.6) ** 2)))
803 }
804 if (charging && Math.abs(inner - fillK * segW) < 1.5) col = mix(col, 0xffffff, 0.7)
805 return col
806 })
807}
808
809function draw($: any, e: any) {
810 const { Box, Text, Raster, Button, Link, Input } = $.ui.resolve(e)
811 const dock = e.props.placement === 'dock' && (e.props.scroll?.bodyRows || 0) > 0
812 for (const k of Object.keys(cardMax)) delete cardMax[k]
813 const cols = Math.max(20, (e.props.bodyColumns || 40) - (dock ? 1 : 0))
814 const compact = !dock
815 const w = cols - 4
816 const t = (children: any[], color = C.text, extra: any = {}) => Text({ wrap: 'truncate', color, children, ...extra })
817 const span = (s: string, color: string, extra: any = {}) => Text({ color, children: [s], ...extra })
818 const foldBtn = (key: string) =>
819 Button({
820 key: 'fold-' + key,
821 label: folded.has(key) ? '▸' : '▾',
822 plain: true,
823 dimColor: true,
824 onPress: async () => {
825 if (folded.has(key)) folded.delete(key)
826 else folded.add(key)
827 $.ui.invalidate('ui.render')
828 await $.store.set('folded', [...folded]).catch(() => undefined)
829 await savePrefs($, { folded: [...folded] })
830 },
831 })
832 const titleRow = (key: string, parts: any[], color: string) =>
833 Box({ key: 'title-' + key, flexDirection: 'row', justifyContent: 'space-between', children: [t(parts, color), foldBtn(key)] })
834 const card = (key: string, title: string, color: string, rows: any[]) =>
835 Box({ key, flexDirection: 'column', borderStyle: 'round', borderColor: color, paddingX: 1, children: [titleRow(key, [span(title, color, { bold: true })], color), ...(folded.has(key) ? [] : rows)] })
836 const kv = (left: any[], right: any[]) => Box({ flexDirection: 'row', justifyContent: 'space-between', children: [t(left), t(right)] })
837 const kvBtn = (left: any[], btn: any) => Box({ flexDirection: 'row', justifyContent: 'space-between', children: [t(left), btn] })
838 const scard = (key: string, title: string, color: string, rows: any[], max: number) => {
839 const content = measure(rows, w)
840 if (!dock || content <= max || folded.has(key)) return card(key, title, color, rows)
841 const cmax = content - max
842 const off = Math.max(0, Math.min(cardScroll[key] || 0, cmax))
843 cardScroll[key] = off
844 cardMax[key] = cmax
845 const thumb = Math.max(1, Math.round((max * max) / content))
846 const at = Math.round(((max - thumb) * off) / cmax)
847 const rail: any[] = []
848 for (let i = 0; i < max; i++) rail.push(Text({ color: i >= at && i < at + thumb ? color : C.dim, children: [i >= at && i < at + thumb ? '┃' : '│'] }))
849 return Box({
850 key,
851 flexDirection: 'column',
852 borderStyle: 'round',
853 borderColor: color,
854 paddingLeft: 1,
855 children: [
856 Box({ key: 'title-' + key, flexDirection: 'row', justifyContent: 'space-between', paddingRight: 1, children: [t([span(title, color, { bold: true }), span(` ↕ ${off + 1}-${off + max}/${content}`, C.dim)], color), foldBtn(key)] }),
857 Box({
858 key: key + '-win',
859 flexDirection: 'row',
860 height: max,
861 overflow: 'hidden',
862 children: [
863 Box({ key: key + '-clip', flexDirection: 'column', flexGrow: 1, overflow: 'hidden', children: [Box({ key: key + '-in', flexDirection: 'column', flexShrink: 0, marginTop: -off, children: rows })] }),
864 Box({ key: key + '-rail', flexDirection: 'column', width: 1, children: rail }),
865 ],
866 }),
867 ],
868 })
869 }
870 const chip = (key: string, label: string, active: boolean, bg: string, onPress: () => void) =>
871 Box({ key: 'box-' + key, paddingX: 1, backgroundColor: active ? bg : C.chipDim, children: [Button({ key, label, plain: true, dimColor: !active, onPress })] })
872
873 const out: any[] = []
874 const alert = battery()
875 const ink = C.ink
876 const magi = ['メルキオール', 'バルタザール', 'カスパー']
877 const vote = (i: number) => {
878 if (alert || (patternBlue && i === 2)) return { word: '否決', fg: blink() ? C.lime : C.purple, bg: C.chipDim, on: true }
879 if (working && Math.floor(frame / 6) % 3 === i) return { word: '審議', fg: C.lime, bg: C.chipDim, on: true }
880 if (working) return { word: '待機', fg: C.dim, bg: C.chipDim, on: false }
881 return { word: '承認', fg: C.muted, bg: C.chipDim, on: false }
882 }
883 out.push(
884 Box({
885 flexDirection: 'row',
886 justifyContent: 'space-between',
887 marginRight: 2,
888 children: [
889 t([
890 span(' NERV ', ink, { bold: true, backgroundColor: alert && blink() ? C.lime : C.purple }),
891 span(' ネルフ', C.orange, { bold: true }),
892 span(' ⟋ MAGI', C.dim),
893 ]),
894 t([
895 ...(compact ? [0, 1, 2].map((i) => span(` ${'メバカ'[i]}·${vote(i).word} `, vote(i).fg, { bold: vote(i).on })) : []),
896 span(compact ? ' ' : ''),
897 working
898 ? span(` ${SPIN[frame % 4]} OPERANDO `, ink, { bold: true, backgroundColor: C.lime })
899 : span(' ◎ EN ESPERA ', C.muted, { backgroundColor: C.chip }),
900 ]),
901 ],
902 }),
903 )
904 if (!compact && e.surface === 'terminal' && Raster) out.push(Raster({ key: 'wave', columns: cols, rows: 3, cells: softWave(cols, 3) }))
905 if (!compact)
906 out.push(
907 Box({
908 flexDirection: 'row',
909 columnGap: 1,
910 children: magi.map((name, i) => {
911 const v = vote(i)
912 const cw = Math.floor((cols - 2) / 3)
913 return Box({
914 key: 'magi-' + i,
915 width: cw,
916 flexDirection: 'column',
917 alignItems: 'center',
918 backgroundColor: v.bg,
919 children: [t([span(name, v.on ? v.fg : C.muted, { bold: v.on })], v.fg), t([span(v.on ? `◆ ${v.word}` : v.word, v.fg, { bold: v.on })], v.fg)],
920 })
921 }),
922 }),
923 )
924 const tabLabel = (x: (typeof TABS)[number]) => (tab === x.id ? `◆ ${x.label}` : x.label)
925 const oneRow = TABS.reduce((n, x) => n + tabLabel(x).length + 4, 0) <= cols
926 const perRow = compact && oneRow ? TABS.length : 3
927 const cellW = perRow === TABS.length ? Math.max(...TABS.map((x) => tabLabel(x).length + 3)) + 1 : Math.floor((cols - (perRow - 1)) / perRow)
928 const tabButton = (x: (typeof TABS)[number]) =>
929 Box({
930 key: 'tabbox-' + x.id,
931 width: cellW,
932 justifyContent: 'center',
933 backgroundColor: tab === x.id ? C.tabBg : undefined,
934 children: [
935 Button({
936 key: 'tab-' + x.id,
937 label: tabLabel(x),
938 hotkey: x.hotkey,
939 plain: true,
940 dimColor: tab !== x.id,
941 onPress: () => {
942 tab = x.id
943 $.store.set('tab', tab).catch(() => undefined)
944 if (x.id === 'hw') refreshHw($)
945 if (x.id === 'crew') refreshCrew($)
946 if (x.id === 'forge') refreshForge($)
947 if (x.id === 'nodd') refreshNodd($)
948 if (x.id === 'cortex') refreshCortex($)
949 if (x.id === 'yt') ytPoll($)
950 $.ui.invalidate('ui.render')
951 },
952 }),
953 ],
954 })
955 for (let i = 0; i < TABS.length; i += perRow)
956 out.push(Box({ key: 'tabrow-' + i, flexDirection: 'row', columnGap: 1, marginTop: i === 0 && !compact ? 1 : 0, children: TABS.slice(i, i + perRow).map(tabButton) }))
957 out.push(t([span('─'.repeat(Math.max(0, cols)), C.dim)]))
958 const headN = out.length
959 if (tab !== bodyTab) {
960 bodyTab = tab
961 bodyOffset = 0
962 }
963
964 if (tab === 'magi') {
965 if (recap)
966 out.push(
967 card('recap', '⟲ ÚLTIMA MISIÓN', C.cyan, [
968 t([span(`la sesión anterior se cortó ${ago(now() - recap.at)}`, C.muted)]),
969 t([span('» ' + clip(recap.prompt, w * 2 - 2), C.text)], C.text, { wrap: 'wrap' }),
970 Button({
971 key: 'recap-ok',
972 label: 'entendido',
973 hotkey: 'x',
974 plain: true,
975 dimColor: true,
976 onPress: () => {
977 recap = undefined
978 $.ui.invalidate('ui.render')
979 },
980 }),
981 ]),
982 )
983 gauges = []
984 const ctx = usage?.context?.percent
985 const five = pct('five_hour')
986 const seven = pct('seven_day')
987 const gw = Math.max(6, w - 14)
988 const gauge = (name: string, p: number | undefined) =>
989 typeof p === 'number' && e.surface === 'terminal' && Raster
990 ? (gauges.push({ key: 'gauge-' + name, p, w: gw, hue: heat(p) }),
991 Box({ key: 'gbox-' + name, flexDirection: 'row', children: [t([span(name.padEnd(6), C.muted)]), Raster({ key: 'gauge-' + name, columns: gw, rows: 1, cells: gaugeCells(p, gw, heat(p)) }), t([span(` ${Math.round(p)}%`.padStart(5), C.text)])] }))
992 : t([span(name.padEnd(6), C.muted), span('┈'.repeat(gw), C.dim), span(typeof p === 'number' ? ` ${Math.round(p)}%`.padStart(5) : ' –', C.text)])
993 const acctRows: any[] = []
994 if (acctNow()) {
995 acctRows.push(kvBtn([span('CUENTA ', C.muted), span(clip(acctName(acctNow()), w - 18), C.text, { bold: true })], Button({ key: 'acct-menu', label: acctMenu ? '▴' : 'cambiar ▾', plain: true, dimColor: !acctMenu, onPress: () => ((acctMenu = !acctMenu), (acctNote = ''), $.ui.invalidate('ui.render')) })))
996 if (acctMenu)
997 for (const a of ACCOUNTS.filter((x) => x.id !== acctNow()))
998 acctRows.push(Box({ key: 'acct-' + a.id, flexDirection: 'row', children: [t([span(' → ', C.dim)]), Button({ key: 'acct-go-' + a.id, label: `${acctName(a.id)} · ${a.tag}`, plain: true, onPress: () => void switchAccount($, a.id) })] }))
999 if (acctNote) acctRows.push(t([span(' ' + acctNote, C.amber)]))
1000 }
1001 const syncRows = [...acctRows, t([span('PILOT ', C.muted), span(clip(model || '?', w - 6), C.text, { bold: true })]), gauge('SYNC', ctx), gauge('5H', five), gauge('7D', seven)]
1002 if (typeof usage?.cost?.usd === 'number') syncRows.push(t([span('COSTO ', C.muted), span(`US$ ${usage.cost.usd.toFixed(2)}`, C.text)]))
1003 if (alert) {
1004 const rem = remaining()
1005 syncRows.push(t([span(blink() ? '▲ ACTIVE TIME REMAINING' : '△ ACTIVE TIME REMAINING', C.red, { bold: true })]))
1006 syncRows.push(t([span(rem ? ` ~${mmss(rem)} al ritmo actual` : ' cable umbilical cortado', C.pink)]))
1007 }
1008 out.push(card('sync', alert ? '⚠ BATERÍA INTERNA' : account ? `⬢ SINCRONIZACIÓN · ${account}` : '⬢ SINCRONIZACIÓN', alert ? (blink() ? C.lime : C.purple) : C.purple, syncRows))
1009
1010 const act: any[] = []
1011 if (activity) act.push(t([span('▶ ', C.lime), span(activity.tool + ' ', C.lime, { bold: true }), span(clip(activity.label, w - activity.tool.length - 10), C.text), span(' ' + mmss(now() - activity.start), C.muted)]))
1012 else if (working) act.push(t([span(`${SPIN[frame % 4]} pensando `, C.purple), span(mmss(now() - turnStart), C.muted)]))
1013 else act.push(t([span('◎ esperando tu próxima orden', C.muted)]))
1014 if (working && lastProgress && now() - lastProgress > 300000) act.push(t([span(`⏸ sin avance hace ${mmss(now() - lastProgress)}`, C.amber)]))
1015 if (unverified.size) {
1016 act.push(t([span(`⚠ ${unverified.size} archivo${unverified.size > 1 ? 's' : ''} sin verificar`, C.amber, { bold: true })]))
1017 for (const f of [...unverified].slice(-3)) act.push(t([span(' · ' + clip(base(f), w - 4), C.muted)]))
1018 } else act.push(t([span('✔ cambios verificados', C.lime)]))
1019 if (patternBlue) {
1020 act.push(t([span(blink() ? '◆ PATTERN BLUE' : '◇ PATTERN BLUE', C.orange, { bold: true }), span(` · ${patternBlue.count}× ${patternBlue.tool}`, C.orange)]))
1021 act.push(t([span(' ' + clip(patternBlue.text, w - 2), C.muted)]))
1022 }
1023 out.push(card('act', '▶ ACTIVIDAD', patternBlue ? C.orange : C.violet, act))
1024
1025 const live = [...tasks.values()].filter((a) => !a.end || now() - a.end < 600000).slice(-6)
1026 if (live.length) {
1027 const running = live.filter((a) => !a.end).length
1028 out.push(
1029 card(
1030 'tasks',
1031 `◌ TAREAS · ${running} corriendo`,
1032 C.purple,
1033 live.map((a) => {
1034 const [g, col] = !a.end ? [SPIN[frame % 4], C.purple] : a.status === 'completed' || !a.status ? ['✔', C.lime] : a.status === 'killed' ? ['■', C.muted] : ['✖', C.red]
1035 return t([span(g + ' ', col), span(a.kind === 'agent' ? '⬡ ' : '$ ', C.dim), span(clip(a.desc, w - 12), a.end ? C.muted : C.text), span(' ' + mmss((a.end || now()) - a.start), C.dim)])
1036 }),
1037 ),
1038 )
1039 }
1040
1041
1042 if (sessionTasks.length) {
1043 const open = (x: { status: string }) => x.status !== 'completed'
1044 const stuck = (x: { blockedBy: string[] }) => x.blockedBy.some((id) => sessionTasks.some((y) => y.id === id && open(y)))
1045 const doing = sessionTasks.filter((x) => x.status === 'in_progress')
1046 const waiting = sessionTasks.filter((x) => x.status === 'pending' && stuck(x))
1047 const next = sessionTasks.filter((x) => x.status === 'pending' && !stuck(x))
1048 const done = sessionTasks.filter((x) => !open(x))
1049 const row = (x: { id: string; subject: string }, mark: string, mc: string, tc: string, tail = '') =>
1050 t([span(x.id.padStart(2) + ' ', C.dim), span(mark + ' ', mc), span(clip(x.subject, Math.max(8, w - 6 - tail.length)), tc), ...(tail ? [span(tail, C.dim)] : [])])
1051 const head = (label: string, n: number) => t([span('─ ' + label + ' ', C.dim), span(String(n), C.muted)])
1052 const rows: any[] = doing.map((x) => row(x, '▶', C.orange, C.text, ''))
1053 if (next.length) rows.push(head('pendientes', next.length), ...next.map((x) => row(x, '□', C.muted, C.text)))
1054 if (waiting.length) rows.push(head('bloqueadas', waiting.length), ...waiting.map((x) => row(x, '⊘', C.dim, C.muted, ' ← ' + x.blockedBy.filter((id) => sessionTasks.some((y) => y.id === id && open(y))).map((id) => '#' + id).join(' '))))
1055 if (done.length) rows.push(t([span('✓ ', C.lime), span(`${done.length} hecha${done.length > 1 ? 's' : ''}`, C.muted), ...(done.length ? [span(' · última: ' + clip(done.at(-1)!.subject, Math.max(6, w - 22)), C.dim)] : [])]))
1056 const bar = Math.max(3, Math.min(8, w - 36))
1057 const k = Math.round((done.length / sessionTasks.length) * bar)
1058 out.push(scard('tasks', `▣ TAREAS ${done.length}/${sessionTasks.length} ${'■'.repeat(k)}${'·'.repeat(bar - k)}`, doing.length ? C.orange : C.violet, rows, 12))
1059 }
1060 if (health.when) out.push(t([span(health.ok ? '♥ ' : '✖ ', health.ok ? C.lime : C.red), span(clip(health.text, w - 10), health.ok ? C.muted : C.red), span(' ' + health.when, C.dim)]))
1061 out.push(
1062 Box({
1063 flexDirection: 'row',
1064 columnGap: 2,
1065 children: [
1066 Button({
1067 key: 'refresh',
1068 label: '↻ refrescar',
1069 hotkey: 'r',
1070 plain: true,
1071 dimColor: true,
1072 onPress: async () => {
1073 await refreshPRs($)
1074 await refreshLocal($)
1075 $.ui.invalidate('ui.render')
1076 },
1077 }),
1078 Button({
1079 key: 'quiet',
1080 label: '◌ silenciar',
1081 hotkey: 'q',
1082 plain: true,
1083 dimColor: true,
1084 onPress: async () => {
1085 quiet = true
1086 await $.store.set('quiet', true).catch(() => undefined)
1087 await savePrefs($, { quiet: true })
1088 await $.ui.close({ id: PANE }).catch(() => undefined)
1089 paneOpen = false
1090 },
1091 }),
1092 ],
1093 }),
1094 )
1095 out.push(
1096 Box({
1097 flexDirection: 'row',
1098 flexWrap: 'wrap',
1099 columnGap: 1,
1100 children: [
1101 t([span('◈ UNIDAD', C.muted, { bold: true })]),
1102 ...THEME_IDS.map((id) => chip('theme-' + id, THEMES[id].label, themeId === id, THEMES[id].tabBg, () => void setTheme($, id))),
1103 ],
1104 }),
1105 )
1106 }
1107
1108 if (tab === 'git') {
1109 const g = gitInfo
1110 const gi: any[] = []
1111 if (!g.ok && !ws) gi.push(t([span('no es un repo de git · leyendo tus proyectos…', C.muted)]))
1112 else if (!g.ok && ws)
1113 for (const rp of ws.repos)
1114 gi.push(
1115 t([
1116 span('⎇ ', C.purple),
1117 span(clip(rp.name, Math.max(8, w - 26)), C.text, { bold: true }),
1118 span(' ' + clip(rp.branch, 10), C.dim),
1119 span(rp.dirty ? ` ● ${rp.dirty}` : ' ✔', rp.dirty ? C.orange : C.lime),
1120 span(' ' + ago(now() - rp.ct * 1000).replace('hace ', ''), C.dim),
1121 ]),
1122 )
1123 else {
1124 gi.push(t([span('⎇ ', C.purple), span(clip(branch || '?', w - 20), C.text, { bold: true }), span(g.upstream ? ` → ${clip(g.upstream, 14)}` : ' sin upstream', C.dim)]))
1125 gi.push(
1126 t([
1127 span(`▲ ${g.ahead} `, g.ahead ? C.lime : C.dim),
1128 span(`▼ ${g.behind} `, g.behind ? C.amber : C.dim),
1129 span(g.dirty ? ` ● ${g.dirty} cambio${g.dirty > 1 ? 's' : ''} sin commitear` : ' ✔ limpio', g.dirty ? C.orange : C.lime),
1130 ]),
1131 )
1132 }
1133 out.push(card('git-branch', g.ok ? '⎇ RAMA' : '⎇ PROYECTOS', C.purple, gi))
1134 const act: any[] = []
1135 if (e.surface === 'terminal' && Raster && Object.keys(commitDays).length) {
1136 const weeks = Math.max(4, Math.floor((w + 1) / 2))
1137 const st = commitStats(weeks)
1138 act.push(t([span('▤ ', C.purple), span(`${st.total} commits`, C.text, { bold: true }), span(` · ${weeks} sem`, C.dim), span(st.streak ? ` ▲ racha ${st.streak} d` : '', C.orange), span(` hoy ${st.today}`, st.today ? C.lime : C.dim)]))
1139 act.push(Raster({ key: 'heat', columns: weeks * 2 - 1, rows: 7, cells: heatCells(weeks * 2 - 1, weeks) }))
1140 act.push(t([span('frío ', C.dim), ...[0, 1, 2, 5, 8, 12].map((n) => span('■', '#' + heatColor(n).toString(16).padStart(6, '0'))), span(' caliente', C.dim)]))
1141 }
1142 if (!act.length) act.push(t([span('sin commits en las últimas semanas', C.muted)]))
1143 out.push(card('git-heat', '▤ COMMITS', C.violet, act))
1144 if (!g.ok && ws?.recent.length)
1145 out.push(
1146 scard(
1147 'git-log',
1148 '◷ ÚLTIMOS COMMITS',
1149 C.purple,
1150 ws.recent.map((c: any) => t([span(clip(c.repo, 14) + ' ', C.orange), span(clip(c.s || '', w - 24), C.text), span(' ' + ago(now() - c.ct * 1000).replace('hace ', ''), C.dim)])),
1151 6,
1152 ),
1153 )
1154 if (g.recent.length)
1155 out.push(
1156 scard(
1157 'git-log',
1158 '◷ ÚLTIMOS COMMITS',
1159 C.purple,
1160 g.recent.map((c: string[]) => t([span(c[0] + ' ', C.orange), span(clip(c[1] || '', w - 20), C.text), span(' ' + (c[2] || '').replace(/ ago$/, ''), C.dim)])),
1161 6,
1162 ),
1163 )
1164 const prRows: any[] = []
1165 if (prError) prRows.push(t([span(prError, C.muted)]))
1166 else if (!prs) prRows.push(t([span('consultando…', C.muted)]))
1167 else if (!prs.length) prRows.push(t([span('ninguna abierta', C.muted)]))
1168 else
1169 for (const p of prs) {
1170 const checks = p.statusCheckRollup || []
1171 const failed = checks.some((c: any) => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT'].includes(c.conclusion || c.state))
1172 const pending = checks.some((c: any) => ['PENDING', 'QUEUED', 'IN_PROGRESS', 'EXPECTED'].includes(c.status || c.state))
1173 const cic = !checks.length ? ['·', C.dim] : failed ? ['✖', C.red] : pending ? ['◌', C.amber] : ['●', C.lime]
1174 const rv = p.reviewDecision === 'APPROVED' ? ['✔', C.lime] : p.reviewDecision === 'CHANGES_REQUESTED' ? ['✎', C.red] : p.isDraft ? ['◇', C.dim] : ['◌', C.amber]
1175 const who = p.author?.login && p.author.login !== ghMe ? ` @${p.author.login}` : ''
1176 prRows.push(
1177 Box({
1178 key: 'prr-' + (p.repo || '') + p.number,
1179 flexDirection: 'row',
1180 children: [t([span(rv[0] + ' ', rv[1]), span(cic[0] + ' ', cic[1])]), Link({ key: 'pr-' + (p.repo || '') + p.number, href: p.url, label: clip(`${p.repo ? p.repo + ' ' : ''}#${p.number} ${p.title}${who}`, w - 5) })],
1181 }),
1182 )
1183 }
1184 const counts = prs?.length ? ` · ${prs.length} abiertas` : ''
1185 out.push(scard('prs', `⎇ ${prsWide ? 'TUS PRs' : 'PRs'}${counts}${!prsWide && branch ? ' · ' + clip(branch, w - 24) : ''}`, C.violet, prRows, 8))
1186 }
1187
1188 if (tab === 'forge') drawForge($, e, out, { t, span, card, scard, chip, w, cols })
1189
1190 if (tab === 'yt') {
1191 const n = yt.now
1192 const cur = n ? yt.queue[n.idx] : undefined
1193 const nowRows: any[] = [
1194 Input({
1195 key: 'yt-q',
1196 placeholder: '⌕ buscar en YouTube…',
1197 value: ytDraft,
1198 submitLabel: 'buscar',
1199 onInput: (v: string) => (ytDraft = v),
1200 onSubmit: (v: string) => {