aboutsummaryrefslogtreecommitdiffstats
path: root/claude_sesiones/terminal.py
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-08-16 19:57:17 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-08-16 19:57:17 -0300
commitbd3ca3a04fcd8d882d23deeb3f3a0d6f04b3f93f (patch)
treea9e0dd65940de54c8f956befe9d86b80350bde2e /claude_sesiones/terminal.py
parentda1c60458b0d28e84739475491a3f2d61ed8eff8 (diff)
downloadclaude-logbook-bd3ca3a04fcd8d882d23deeb3f3a0d6f04b3f93f.tar.gz
claude-logbook-bd3ca3a04fcd8d882d23deeb3f3a0d6f04b3f93f.zip
Preparar el proyecto para publicarlo
El script suelto pasa a ser un paquete instalable, con tests y documentación. El comportamiento del CLI no cambia: mismos flags, misma salida. Estructura - claude_sesiones/{sessions,terminal,webpage,cli}.py separa parseo, presentación, generación del HTML y argumentos. El template pasa a ser data del paquete. - pyproject.toml con el entry point claude-sesiones, sin dependencias. - build.sh se disuelve en `claude-sesiones --html [ARCHIVO]`: el HTML se arma en proceso, sin subprocess ni data.json intermedio. `--open` lo abre en el navegador. Arreglos - El template no tenía doctype ni <meta charset>: abierto como file:// quedaba en quirks mode y con la codificación del sistema, así que los acentos salían rotos. Tampoco tenía viewport, con lo que en el celular se veía a escala de escritorio. - `delete_sessions()` se llamaba con un argumento de menos (lo encontró el test de borrado). - El orden de la tabla comparaba timestamps como strings; ahora compara los datetimes ya parseados. - `mark()` buscaba el texto crudo dentro del HTML ya escapado, así que resaltar algo con & o < nunca encontraba nada. `esc()` no escapaba la comilla simple. - El caché no tenía versión: al cambiar el esquema del registro se leían registros de la forma anterior. Ahora se invalida solo. El temporal lleva el pid, para que dos corridas simultáneas no se pisen. - `pick()` cortaba el proceso con sys.exit desde adentro; ahora levanta SessionError y el código de salida lo decide la CLI. Mejoras - Respeta CLAUDE_CONFIG_DIR. - La página tiene botón de tema claro/oscuro que recuerda la elección, deep links (#uuid abre esa conversación), atajos de teclado, trampa de foco en el lector y aviso si el JS está apagado. Las tres copias de la paleta quedaron en dos, una por tema. - 92 tests con unittest, sin dependencias, contra árboles de .jsonl falsos: nunca tocan ~/.claude. CI en GitHub Actions, Python 3.9 a 3.13. - README en inglés y español, con el esquema del JSON y una advertencia sobre lo que hay adentro de sesiones.html. - Apache-2.0. Claude-Session: https://claude.ai/code/session_01RmtZ9qBemrc9TncwVTG6ED
Diffstat (limited to 'claude_sesiones/terminal.py')
-rw-r--r--claude_sesiones/terminal.py251
1 files changed, 251 insertions, 0 deletions
diff --git a/claude_sesiones/terminal.py b/claude_sesiones/terminal.py
new file mode 100644
index 0000000..2bf9031
--- /dev/null
+++ b/claude_sesiones/terminal.py
@@ -0,0 +1,251 @@
+"""Salida para la terminal: colores, tabla y lectura de una conversación."""
+
+import os
+import re
+import shutil
+import subprocess
+import sys
+import textwrap
+
+from .sessions import parse_ts
+
+MES = ["ene", "feb", "mar", "abr", "may", "jun",
+ "jul", "ago", "sep", "oct", "nov", "dic"]
+
+ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
+
+# Anchos mínimos de terminal para mostrar cada columna opcional.
+MIN_COLS_PATH = 92
+MIN_COLS_DUR = 74
+
+
+class Style:
+ """Códigos ANSI, o cadenas vacías si la salida no es una terminal."""
+
+ CODES = {
+ "reset": "0", "bold": "1", "dim": "2", "italic": "3",
+ "amber": "38;5;179", "copper": "38;5;173", "grey": "38;5;245",
+ "faint": "38;5;240", "ink": "38;5;252", "blue": "38;5;110",
+ # barra de antigüedad, del ámbar vivo al gris
+ "age0": "38;5;214", "age1": "38;5;179",
+ "age2": "38;5;137", "age3": "38;5;239",
+ }
+
+ def __init__(self, enabled):
+ self.on = enabled
+
+ def __getattr__(self, name):
+ try:
+ code = self.CODES[name]
+ except KeyError:
+ raise AttributeError(name) from None
+ return f"\x1b[{code}m" if self.on else ""
+
+ @classmethod
+ def from_stream(cls, stream, no_color=False):
+ """Color solo si hay terminal, no lo desactivaron y no hay NO_COLOR."""
+ return cls(bool(getattr(stream, "isatty", lambda: False)())
+ and not no_color
+ and not os.environ.get("NO_COLOR"))
+
+
+# ──────────────────────────────── formato ────────────────────────────────
+
+def visible_len(s):
+ return len(ANSI_RE.sub("", s))
+
+
+def clip(s, width):
+ """Recorta a `width` columnas, con … si no entra."""
+ s = s.replace("\n", " ")
+ if len(s) <= width:
+ return s
+ return s[: max(0, width - 1)] + "…"
+
+
+def fmt_date(iso):
+ d = parse_ts(iso).astimezone()
+ return f"{d.day:02d} {MES[d.month - 1]}"
+
+
+def fmt_time(iso):
+ return parse_ts(iso).astimezone().strftime("%H:%M")
+
+
+def fmt_rel(iso, now):
+ n = (now - parse_ts(iso)).total_seconds() / 86400
+ if n < 1:
+ return "hoy"
+ if n < 2:
+ return "ayer"
+ if n < 7:
+ return f"hace {int(n)}d"
+ if n < 30:
+ return f"hace {int(n // 7)}sem"
+ return f"hace {int(n // 30)}mes"
+
+
+def plural(n, singular, plural_):
+ return f"{n} {singular if n == 1 else plural_}"
+
+
+def fmt_dur(m):
+ if m is None:
+ return "—"
+ if m < 1:
+ return "<1m"
+ if m < 60:
+ return f"{m}m"
+ h, r = divmod(m, 60)
+ return f"{h}h{r:02d}" if r else f"{h}h"
+
+
+def fmt_size(kb):
+ return f"{kb / 1024:.1f} MB" if kb >= 1024 else f"{kb:.1f} KB"
+
+
+def stripe(iso, now, st):
+ """Barra de antigüedad a la izquierda de cada fila."""
+ if not st.on:
+ return "|"
+ n = (now - parse_ts(iso)).total_seconds() / 86400
+ color = st.age0 if n < 2 else st.age1 if n < 7 else st.age2 if n < 14 else st.age3
+ return f"{color}▌{st.reset}"
+
+
+# ──────────────────────────────── tabla ────────────────────────────────
+
+def print_table(sessions, st, now, out, width=None):
+ width = width or shutil.get_terminal_size((100, 24)).columns
+ show_path = width >= MIN_COLS_PATH
+ show_dur = width >= MIN_COLS_DUR
+
+ # columnas fijas: idx(3) barra(1) fecha(6) rel(9) msgs(4) dur(6) id(8) + gaps
+ fixed = 3 + 1 + 6 + 9 + 4 + (6 if show_dur else 0) + 8
+ gaps = 7 if show_dur else 6
+ flex = max(24, width - fixed - gaps)
+ w_title = int(flex * 0.58) if show_path else flex
+ w_path = flex - w_title - 1 if show_path else 0
+
+ head = [
+ f"{'#':>3}", " ",
+ f"{st.faint}{'SESIÓN':<{w_title}}{st.reset}",
+ ]
+ if show_path:
+ head.append(f"{st.faint}{'RUTA':<{w_path}}{st.reset}")
+ head.append(f"{st.faint}{'FECHA':<6} {'CUÁNDO':<9}{st.reset}")
+ head.append(f"{st.faint}{'MSG':>4}{st.reset}")
+ if show_dur:
+ head.append(f"{st.faint}{'DUR':>6}{st.reset}")
+ head.append(f"{st.faint}{'ID':<8}{st.reset}")
+ print(" ".join(head), file=out)
+
+ for i, s in enumerate(sessions, 1):
+ title = s["t"] or "sesión abierta sin mensajes"
+ tcolor = st.dim + st.italic if s["e"] else (st.ink if s["ai"] else "")
+ tags = ""
+ if s["e"]:
+ tags = f" {st.faint}[vacía]{st.reset}"
+ elif s["n"]:
+ tags = f" {st.faint}[auto]{st.reset}"
+
+ avail = w_title - visible_len(tags)
+ cells = [
+ f"{st.faint}{i:>3}{st.reset}",
+ stripe(s["l"], now, st),
+ f"{tcolor}{clip(title, avail):<{avail}}{st.reset}{tags}",
+ ]
+ if show_path:
+ cells.append(f"{st.grey}{clip(s['p'], w_path):<{w_path}}{st.reset}")
+ cells.append(
+ f"{fmt_date(s['l']):<6} {st.faint}{fmt_rel(s['l'], now):<9}{st.reset}"
+ )
+ cells.append(f"{s['u'] or '—':>4}")
+ if show_dur:
+ cells.append(f"{st.grey}{fmt_dur(s['d']):>6}{st.reset}")
+ cells.append(f"{st.faint}{s['id'][:8]}{st.reset}")
+ print(" ".join(cells), file=out)
+
+
+# ──────────────────────────── una conversación ────────────────────────────
+
+def strip_md(text):
+ """Markdown mínimo para la terminal: saca ** y marcadores de título."""
+ text = re.sub(r"^#{1,6}\s+", "", text, flags=re.M)
+ return re.sub(r"\*\*([^*\n]+)\*\*", r"\1", text)
+
+
+def render_block(text, st, width, code_color):
+ """Formatea un mensaje respetando las cercas de código."""
+ lines = []
+ in_code = False
+ for raw in text.split("\n"):
+ if raw.lstrip().startswith("```"):
+ in_code = not in_code
+ continue
+ if in_code:
+ lines.append(f" {code_color}{raw}{st.reset}")
+ elif not raw.strip():
+ lines.append("")
+ else:
+ wrapped = textwrap.wrap(
+ strip_md(raw), width=width,
+ break_long_words=False, break_on_hyphens=False,
+ )
+ lines.extend(" " + w for w in (wrapped or [""]))
+ return lines
+
+
+def print_chat(s, st, out, show_tools=True, width=None):
+ width = min(width or shutil.get_terminal_size((100, 24)).columns, 100)
+ body = width - 2
+
+ print(f"{st.amber}{st.bold}{s['t'] or 'Sesión sin título'}{st.reset}", file=out)
+ meta = (f"{s['p']} · {fmt_date(s['l'])} {fmt_time(s['l'])} · "
+ f"{s['u']} tuyos / {s['a']} de Claude · {fmt_dur(s['d'])}")
+ print(f"{st.faint}{meta}{st.reset}", file=out)
+ print(f"{st.faint}{resume_cmd(s)}{st.reset}", file=out)
+ print(f"{st.faint}{'─' * min(width, 80)}{st.reset}\n", file=out)
+
+ if not s["c"]:
+ print(f"{st.dim}Esta sesión no tiene mensajes.{st.reset}", file=out)
+ return
+
+ first = True
+ for m in s["c"]:
+ if m["r"] == "t":
+ if show_tools:
+ print(f" {st.faint}⚒ {clip(m['x'], body - 4)}{st.reset}", file=out)
+ first = False
+ continue
+
+ # La separación va antes de cada turno: así una tanda de herramientas
+ # queda pegada al mensaje que la lanzó y separada del siguiente.
+ if not first:
+ print(file=out)
+ first = False
+
+ who = "vos" if m["r"] == "u" else "claude"
+ color = st.amber if m["r"] == "u" else st.blue
+ print(f"{color}{who}{st.reset}", file=out)
+ for line in render_block(m["x"], st, body, st.grey):
+ print(line, file=out)
+
+
+def resume_cmd(s):
+ """El --resume solo encuentra la sesión desde su directorio original."""
+ return f"cd {s['p']} && claude --resume {s['id']}"
+
+
+def pager(text):
+ """Manda el texto a $PAGER si hay terminal; si no, a stdout."""
+ if not sys.stdout.isatty():
+ sys.stdout.write(text)
+ return
+ cmd = os.environ.get("PAGER", "less")
+ args = [cmd, "-R", "-F", "-X"] if os.path.basename(cmd) == "less" else [cmd]
+ try:
+ p = subprocess.Popen(args, stdin=subprocess.PIPE)
+ p.communicate(text.encode())
+ except (OSError, BrokenPipeError):
+ sys.stdout.write(text)