Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.2] — 2026-05-23

### Added

- **Universal clipboard yank.** Every screen with a `DataTable`
(Sessions, Permissions, Context, Memory, Hooks, Schedule, Audit,
Dream) now binds `y` to copy the highlighted cell (or full row,
tab-joined) to the clipboard via Textual's OSC 52 escape. Notification
shows a preview of what was copied. Works around Textual's mouse
capture which blocks the terminal's native select-to-copy.
- **Sessions drill-down.** Pressing `Enter` on a Sessions row opens a
new `SessionDetailScreen` modal with full metadata (started, last
activity + relative, duration, messages, compactions, size, token
estimate + cost), all summaries (indexer markdown, compaction,
first-msg), and first/last user messages (truncated past 30 lines).
Footer offers `d` Delete, `r` Resume hint (`claude --resume <id>`),
`y` Copy full UUID, `Esc` Back.

### Changed (Sessions tab UX overhaul)

- **Columns reorganized.** New order: `Topic · Activity · Duration ·
Msgs · Tokens · Cost · ID · Project`. Topic eats available width and
pulls from either the user-indexer markdown summary stem or the first
user message. Activity is now relative (`2h ago`, `yesterday`,
`Apr 14`). Duration shows `started → last_activity` (`4h22m`, `1d3h`).
Tokens shows a chars/4 size-based estimate (cheap, no full re-read)
formatted as `245k` / `1.2M`. Cost converts that to `$3.67` at Opus
input rate ($15/M).
- **Header is action-oriented.** Now shows `Sessions — N stored` plus
the current key map and a hint about the biggest session
(`top: 31.6MB (abc12345)`) with a `cc-janitor session prune` tip.
- **Session ID truncated** to 8 chars + `..` in the table; full UUID is
visible in the detail modal and copyable via `y`.

### Internal

- New `src/cc_janitor/tui/_yank.py` (`YankMixin`) — small mixin used by
every DataTable screen.
- New `src/cc_janitor/tui/_format.py` — `format_tokens`, `format_cost`,
`relative_time`, `duration`, `short_id`, `strip_project_slug`,
`topic_from_session`, `estimate_tokens_from_size`.
- Token estimate uses `size_bytes // 4` heuristic rather than a full
JSONL re-read; trades ±20% accuracy for O(1) per session vs
O(file_size) per session — important for users with hundreds of big
transcripts. The CLI `session token-count` command remains the source
of truth for an exact count.

## [0.5.1] — 2026-05-22

### Fixed (TUI honesty + correctness)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "cc-janitor"
version = "0.5.1"
version = "0.5.2"
description = "Tidy up your Claude Code environment — sessions, permissions, context, hooks, schedule."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/cc_janitor/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from .commands.undo import undo as _undo
from .commands.watch import watch_app

__VERSION__ = "0.5.1"
__VERSION__ = "0.5.2"

app = typer.Typer(no_args_is_help=False, help="cc-janitor — Tidy Claude Code")

Expand Down
139 changes: 139 additions & 0 deletions src/cc_janitor/tui/_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Small formatting helpers shared by TUI screens (Sessions UX 0.5.2)."""
from __future__ import annotations

from datetime import UTC, datetime, timedelta


def format_tokens(n: int) -> str:
"""Compact token counts: ``< 1k``, ``2.4k``, ``245k``, ``1.2M``."""
if n is None or n <= 0:
return "—"
if n < 1_000:
return "< 1k"
if n < 10_000:
return f"{n / 1000:.1f}k"
if n < 1_000_000:
return f"{n // 1000}k"
if n < 10_000_000:
return f"{n / 1_000_000:.2f}M"
return f"{n / 1_000_000:.1f}M"


def format_cost(tokens: int, *, per_million: float = 15.0) -> str:
"""Estimate USD cost for ``tokens`` at ``per_million`` rate (default Opus input)."""
if tokens is None or tokens <= 0:
return "—"
dollars = tokens * per_million / 1_000_000
if dollars < 0.01:
return "< $0.01"
if dollars < 1000:
return f"${dollars:.2f}"
return f"${dollars:.0f}"


def relative_time(dt: datetime | None, *, now: datetime | None = None) -> str:
"""Human-friendly relative time: ``2h ago``, ``yesterday``, ``3d ago``, ``Apr 14``."""
if dt is None:
return "—"
if now is None:
now = datetime.now(tz=dt.tzinfo or UTC)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=now.tzinfo)
delta = now - dt
secs = int(delta.total_seconds())
if secs < 0:
return "future"
if secs < 60:
return "just now"
if secs < 3600:
return f"{secs // 60}m ago"
if secs < 86_400:
return f"{secs // 3600}h ago"
days = secs // 86_400
if days == 1:
return "yesterday"
if days < 7:
return f"{days}d ago"
if days < 365:
return dt.strftime("%b %d")
return dt.strftime("%Y-%m-%d")


def duration(td: timedelta | None) -> str:
"""Format a duration: ``4h22m``, ``1d3h``, ``12m``, ``45s``. ``None``/<=0 → ``—``."""
if td is None:
return "—"
secs = int(td.total_seconds())
if secs <= 0:
return "—"
if secs < 60:
return f"{secs}s"
if secs < 3600:
return f"{secs // 60}m"
if secs < 86_400:
h, m = divmod(secs // 60, 60)
return f"{h}h{m:02d}m" if m else f"{h}h"
d, rem = divmod(secs, 86_400)
h = rem // 3600
return f"{d}d{h}h" if h else f"{d}d"


def estimate_tokens_from_size(size_bytes: int) -> int:
"""Approx tokens from raw JSONL byte count.

JSONL transcripts are mostly UTF-8 text; the conservative
chars-to-tokens ratio is ~4 bytes/token for English-heavy chat with
moderate JSON overhead. We pick 4.0 to match the existing
``size_bytes / 4`` heuristic that other parts of the codebase rely
on, so the estimate stays consistent.
"""
if not size_bytes:
return 0
return max(1, size_bytes // 4)


def strip_project_slug(slug: str, *, max_len: int = 14) -> str:
"""Strip leading ``C--`` (Windows path indicator) and truncate."""
s = slug
if s.startswith("C--"):
s = s[3:]
if len(s) > max_len:
return s[: max_len - 1] + "…"
return s


def short_id(sid: str, *, n: int = 8) -> str:
if len(sid) <= n:
return sid
return sid[:n] + ".."


def topic_from_session(s) -> str:
"""Best-of topic line for the Topic column.

Preference order:
1. ``user_indexer_md`` summary's md_path stem (last segment after
``_<sid>``), with leading date prefix trimmed.
2. First 80 chars of ``first_user_msg``.
3. ``(no topic)`` placeholder.
"""
for summary in getattr(s, "summaries", []) or []:
if summary.source == "user_indexer_md" and summary.md_path is not None:
stem = summary.md_path.stem
# split off trailing _<sid> if present
sid = getattr(s, "id", "")
if sid and f"_{sid[:8]}" in stem:
stem = stem.rsplit(f"_{sid[:8]}", 1)[0]
elif sid and f"_{sid}" in stem:
stem = stem.rsplit(f"_{sid}", 1)[0]
# drop leading YYYY-MM-DD_ prefix
if len(stem) >= 11 and stem[4] == "-" and stem[7] == "-" and stem[10] == "_":
stem = stem[11:]
stem = stem.strip().replace("_", " ")
if stem:
return stem[:80]
first = getattr(s, "first_user_msg", "") or ""
first = first.strip().splitlines()[0] if first.strip() else ""
if first:
return first[:80]
return "(no topic)"
93 changes: 93 additions & 0 deletions src/cc_janitor/tui/_yank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Universal clipboard-yank helper for DataTable screens.

Textual captures mouse events, which blocks the terminal's native
copy-on-select. To compensate, every screen that exposes a ``DataTable``
binds ``y`` to :meth:`YankMixin.action_yank`, which copies the highlighted
cell (or full row, if the cursor isn't on a particular column) to the
clipboard via Textual's :meth:`App.copy_to_clipboard` (OSC 52 escape;
natively supported by Windows Terminal, iTerm2, kitty, etc.).
"""
from __future__ import annotations

from textual.widgets import DataTable


class YankMixin:
"""Mixin: adds an ``action_yank`` that copies focused cell/row text.

Subclasses must contain at least one ``DataTable``. The mixin walks
focused widget chain → first DataTable in the screen, picks the
highlighted cell, falls back to the whole row joined by tabs.
"""

def _yank_target_table(self) -> DataTable | None:
try:
focused = getattr(self, "focused", None) or self.app.focused # type: ignore[attr-defined]
except Exception:
focused = None
if isinstance(focused, DataTable):
return focused
try:
return self.query_one(DataTable) # type: ignore[attr-defined]
except Exception:
return None

def action_yank(self) -> None:
table = self._yank_target_table()
if table is None or table.cursor_row is None:
self._yank_notify("Nothing to copy", severity="warning") # type: ignore[arg-type]
return

text = _extract_cell_or_row(table)
if not text:
self._yank_notify("Nothing to copy", severity="warning")
return

try:
self.app.copy_to_clipboard(text) # type: ignore[attr-defined]
except Exception as exc:
self._yank_notify(f"Copy failed: {exc}", severity="error")
return
preview = text if len(text) <= 60 else text[:57] + "..."
self._yank_notify(f"Copied to clipboard: {preview}")

def _yank_notify(self, msg: str, *, severity: str = "information") -> None:
try:
self.notify(msg, severity=severity) # type: ignore[attr-defined]
except Exception:
try:
self.app.notify(msg, severity=severity) # type: ignore[attr-defined]
except Exception:
pass


def _extract_cell_or_row(table: DataTable) -> str:
"""Return highlighted cell text; fallback to full row joined by tabs."""
row = table.cursor_row
if row is None:
return ""
col = table.cursor_column if table.cursor_column is not None else 0
# try the focused cell first
try:
cell = table.get_cell_at((row, col))
cell_text = _stringify(cell)
if cell_text:
return cell_text
except Exception:
pass
# fallback: assemble the row
try:
row_data = table.get_row_at(row)
return "\t".join(_stringify(c) for c in row_data)
except Exception:
return ""


def _stringify(cell) -> str:
if cell is None:
return ""
# Rich Text objects expose .plain
plain = getattr(cell, "plain", None)
if plain is not None:
return str(plain)
return str(cell)
6 changes: 4 additions & 2 deletions src/cc_janitor/tui/screens/audit_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
from textual.widgets import Static

from ...core.stats import load_snapshots, render_sparkline
from .._yank import YankMixin


class AuditScreen(Widget):
class AuditScreen(YankMixin, Widget):
"""Audit tab with a daily-stats sparkline sub-pane.

Footer key `s` toggles the sub-pane.
Expand All @@ -26,12 +27,13 @@ class AuditScreen(Widget):

BINDINGS = [
("s", "toggle_sparklines", "Toggle stats"),
("y", "yank", "Copy cell"),
]

def compose(self) -> ComposeResult:
yield Static(
"[bold]What cc-janitor has done on your behalf[/bold] [dim]"
"· s toggle sparklines · undo via `cc-janitor undo` CLI[/dim]",
"· s toggle sparklines · y copy · undo via `cc-janitor undo` CLI[/dim]",
id="audit-header",
)
yield Static("Audit log viewer", id="audit-body")
Expand Down
9 changes: 7 additions & 2 deletions src/cc_janitor/tui/screens/context_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
from textual.widgets import DataTable, Static

from ...core.context import context_cost
from .._yank import YankMixin


class ContextScreen(Widget):
class ContextScreen(YankMixin, Widget):
"""Context-cost inspector: CLAUDE.md + memory + skills with token totals."""

BINDINGS = [
("y", "yank", "Copy cell"),
]

DEFAULT_CSS = """
ContextScreen { layout: vertical; height: 1fr; }
#context-header { height: auto; padding: 0 1; background: $boost; color: $text; }
Expand All @@ -22,7 +27,7 @@ class ContextScreen(Widget):
def compose(self) -> ComposeResult:
yield Static(
"[bold]What gets injected into every Claude Code request[/bold] "
"[dim]· ↑↓ navigate · Enter file preview[/dim]",
"[dim]· ↑↓ navigate · Enter file preview · y copy[/dim]",
id="context-header",
)
yield DataTable(id="context-table")
Expand Down
9 changes: 7 additions & 2 deletions src/cc_janitor/tui/screens/dream_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@

from ...core.dream_diff import compute_diff
from ...core.dream_snapshot import _dream_root, history
from .._yank import YankMixin


class DreamScreen(Widget):
class DreamScreen(YankMixin, Widget):
BINDINGS = [
("y", "yank", "Copy cell"),
]

DEFAULT_CSS = """
DreamScreen { layout: vertical; height: 1fr; }
#dream-header { height: auto; padding: 0 1; background: $boost; color: $text; }
Expand All @@ -28,7 +33,7 @@ class DreamScreen(Widget):
def compose(self) -> ComposeResult:
yield Static(
"[bold]Auto Dream snapshots (before/after pairs)[/bold] [dim]"
"· ↑↓ navigate · Enter diff · b rollback[/dim]",
"· ↑↓ navigate · Enter diff · b rollback · y copy[/dim]",
id="dream-header",
)
with Horizontal(id="dream-body"):
Expand Down
Loading
Loading