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
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.1] — 2026-05-22

### Fixed (TUI honesty + correctness)

- **Focus on tab-switch.** Each screen now calls `.focus()` on its
primary `DataTable` via `on_show`, so screen-level BINDINGS (Sessions
`d`, Perms `p`/`D`, Schedule `a`/`r`/`n`/`p`, Hooks `t`/`l`/`v`,
Memory `r`/`a`/`f`, Audit `s`) actually fire after a tab switch — they
previously silently no-op'd because focus stayed on `TabbedContent`'s
tab strip.
- **Schedule key naming aligned.** Header text, F1 help and the empty-
state CTA now agree with the actual BINDINGS: `a` add, `r` remove, `n`
run-now, `p` promote. The header previously said `n` new job / Del
remove / `r` run-now — all three of which were wrong.

### Changed (TUI now honest about its scope)

- **Sessions `d` is real.** Highlight a row, press `d` → `ConfirmModal`
with session id / size / message count → on Yes, soft-deletes via
`core.sessions.delete_session` inside `tui_confirmed()` + `audit_action
(mode="tui")`. Restoration remains a CLI path
(`cc-janitor trash restore`).
- **Perms `p` and `D` are real.** New `PrunePreviewModal` and
`DedupPreviewModal` list affected rules + source paths before applying;
on confirm, `core.permissions.remove_rule` is called per rule (each
call backs up the settings.json to `~/.cc-janitor/backups/` and writes
an audit entry).
- **Sham bindings dropped.** Sessions `/` search and Audit `u` undo are
removed from BINDINGS, headers, and F1 help. Use `cc-janitor session
find` and `cc-janitor undo` from the CLI.

### Fixed (cost calculation)

- **Skill cost reflects per-request reality.** `core.context.enabled_skills`
used to count the full `SKILL.md` size in tokens, but Claude Code
truncates each skill body at `skillListingMaxDescChars` (default 1536).
cc-janitor now reads that setting from `~/.claude/settings.json` and
reports the truncated-slice cost. Also honours `skillOverrides`
(`"off"` excludes; `"name-only"` keeps only YAML frontmatter). Heavy
skills like graphify previously over-reported ~16x.

### Tests

- `tests/tui/test_focus_on_mount.py` — focus moves to DataTable on tab-
activate for every screen.
- `tests/tui/test_sessions_delete.py` — `d` opens ConfirmModal; on Y the
row is removed.
- `tests/tui/test_perms_actions.py` — `p` and `D` open the new preview
modals.
- `tests/unit/test_context_skill_cost.py` — cap respected, overrides
applied, default value used when settings absent.

## [0.5.0] — 2026-05-21

### Fixed (Critical — TUI rendering)
Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,21 +169,24 @@ cc-janitor stats sleep-hygiene # memory hygiene metrics
[F1] anywhere opens a tab-specific help modal):

- **Sessions** — every Claude Code conversation in `~/.claude/projects/`,
sorted by last activity. `d` soft-deletes to trash. `/` filters.
sorted by last activity. `d` soft-deletes the highlighted session
(preview + confirm modal; restorable via `cc-janitor trash restore`).
- **Permissions** — Bash/Edit/etc. allow rules merged from all settings
layers, annotated with `Used90d` match-counts and STALE/DUP flags.
`p` prune-stale and `D` dedup both open preview modals before mutating.
- **Context** — what gets injected into every request (CLAUDE.md, memory,
skills) with token totals and per-request cost.
skills) with token totals and per-request cost. Skill tokens reflect
the `skillListingMaxDescChars` cap (default 1536), not full file size.
- **Memory** — your CLAUDE.md / MEMORY.md / feedback / project-state /
reference files. `r` reinjects, `a` archives, `f` finds duplicates.
- **Hooks** — PreToolUse / PostToolUse / Stop hooks merged across layers.
`l` toggles logging, `t` simulates the command.
- **Schedule** — maintenance jobs via cron / schtasks. Empty? The right
pane lists the five built-in templates so you know what to add.
- **Audit** — every mutation cc-janitor made, plus daily-stats sparklines
(toggle with `s`).
`l` toggles logging, `t` simulates the command, `v` opens source in $EDITOR.
- **Schedule** — maintenance jobs via cron / schtasks. `a` add (from
template), `r` remove, `n` run-now, `p` promote-from-dry-run.
- **Audit** — daily-stats sparklines (toggle with `s`); undo via
`cc-janitor undo` CLI.
- **Dream** — Auto Dream before/after snapshot pairs with file-level
diffs and one-key rollback.
diffs (rollback via `cc-janitor dream rollback` CLI).

On first launch a Welcome modal lists all eight tabs and what each shows;
dismissing it touches `~/.cc-janitor/state/seen-welcome` so it never
Expand Down Expand Up @@ -406,9 +409,9 @@ cc-janitor completions install [bash|zsh|fish|powershell]

-----

## Status — `v0.4.0` Dream safety net
## Status — `v0.5.1` honest TUI

Shipped 2026-05-11. 202 passing tests. CI matrix Python 3.11 × 3.12 × Ubuntu × Windows.
Shipped 2026-05-22. CI matrix Python 3.11 × 3.12 × Ubuntu × Windows.

|Phase |Released |Highlights |
|-------------|-------------------------|------------------------------------------------------------------------------------------------------|
Expand All @@ -417,6 +420,7 @@ Shipped 2026-05-11. 202 passing tests. CI matrix Python 3.11 × 3.12 × Ubuntu
|**Phase 3** |v0.3.0 (2026-05-11) |Monorepo discovery / watcher daemon / stats dashboard / bundle export-import / shell completions |
|**Phase 3.x**|v0.3.1–0.3.3 (2026-05-11)|TUI safety-gate fix, undo, schedule audit, memory delete, backups list/prune |
|**Phase 4** |v0.4.0 (2026-05-11) |Dream snapshot harness / dream doctor / sleep-hygiene metrics / settings audit hook |
|**0.5.x** |v0.5.0–0.5.1 (2026-05-22)|TUI rendering fix (1fr CSS), F1 help, focus on tab-switch, real Sessions delete / Perms prune+dedup, accurate skill cost |
|**Phase 5** |planned |Cross-platform hook fixers, `dream fix-stale-lock`, mutating Dream TUI actions, fuller I10/I11 closure|

**Looking for early users on Windows.** CI covers the matrix but real-world Windows hook compatibility (PowerShell vs Git Bash vs WSL) has reported edge cases. The dedicated [issue template](.github/ISSUE_TEMPLATE/os-compatibility-report.md) takes ~5 min.
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.0"
version = "0.5.1"
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.0"
__VERSION__ = "0.5.1"

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

Expand Down
105 changes: 96 additions & 9 deletions src/cc_janitor/core/context.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path

from .tokens import count_file_tokens
from .tokens import count_file_tokens, count_tokens

# Claude Code default for `skillListingMaxDescChars` in user settings.
DEFAULT_SKILL_LISTING_CAP = 1536


@dataclass
Expand Down Expand Up @@ -68,18 +72,101 @@ def memory_files(*, claude_project_dir: str) -> list[ContextFile]:
return out


def _read_user_settings() -> dict:
"""Read ~/.claude/settings.json, returning {} on missing/malformed."""
p = _user_home() / ".claude" / "settings.json"
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}


def _skill_listing_cap() -> int:
"""Claude Code truncates each skill's body at this many chars per request.

Default is :data:`DEFAULT_SKILL_LISTING_CAP` (1536) — Claude Code reads
this from ``skillListingMaxDescChars`` in ~/.claude/settings.json.
"""
raw = _read_user_settings().get("skillListingMaxDescChars")
try:
v = int(raw)
if v > 0:
return v
except (TypeError, ValueError):
pass
return DEFAULT_SKILL_LISTING_CAP


def _skill_overrides() -> dict[str, str]:
"""Claude Code's per-skill override map.

Values: ``"off"`` excludes the skill entirely; ``"name-only"`` hides the
description body so only the YAML frontmatter contributes tokens.
"""
raw = _read_user_settings().get("skillOverrides") or {}
if isinstance(raw, dict):
return {str(k): str(v) for k, v in raw.items()}
return {}


def _skill_name_from_path(skill_md: Path) -> str:
"""Skill name = parent dir of SKILL.md (e.g. .claude/skills/graphify/ → 'graphify')."""
return skill_md.parent.name


def _skill_text_for_request(body: str, cap: int, override: str | None) -> str:
"""Return the slice of SKILL.md that actually loads into a request.

``override="off"`` returns "" (skill excluded). ``override="name-only"``
returns only the YAML frontmatter (description hidden). Otherwise the
body is truncated to ``cap`` characters.
"""
if override == "off":
return ""
if override == "name-only":
# Keep only the leading YAML frontmatter (between '---' delimiters).
if body.startswith("---"):
end = body.find("\n---", 3)
if end != -1:
return body[: end + 4]
# No frontmatter → fall back to first line (the name reference).
return body.split("\n", 1)[0]
return body[:cap]


def enabled_skills() -> list[ContextFile]:
"""Skill files actually injected per request.

Each SKILL.md is truncated at ``skillListingMaxDescChars`` (default
1536 — Claude Code's hard cap). Skills marked ``"off"`` in
``skillOverrides`` are excluded; ``"name-only"`` keeps only the YAML
frontmatter.
"""
home = _user_home()
out: list[ContextFile] = []
skills_root = home / ".claude" / "skills"
if skills_root.exists():
for skill_md in skills_root.rglob("SKILL.md"):
out.append(ContextFile(
path=skill_md,
size_bytes=skill_md.stat().st_size,
tokens=count_file_tokens(skill_md),
kind="skill",
))
if not skills_root.exists():
return out
cap = _skill_listing_cap()
overrides = _skill_overrides()
for skill_md in skills_root.rglob("SKILL.md"):
name = _skill_name_from_path(skill_md)
override = overrides.get(name)
if override == "off":
continue
try:
body = skill_md.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
effective = _skill_text_for_request(body, cap, override)
out.append(ContextFile(
path=skill_md,
size_bytes=skill_md.stat().st_size,
tokens=count_tokens(effective),
kind="skill",
))
return out


Expand Down
23 changes: 10 additions & 13 deletions src/cc_janitor/tui/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,9 @@
"~/.claude/projects/<project>/<uuid>.jsonl. This tab lists all sessions,\n"
"sorted by last activity.\n\n"
"[bold]Keys[/bold]\n"
" ↑↓ navigate rows\n"
" Enter show full preview in the bottom pane\n"
" d soft-delete the highlighted session (goes to trash,\n"
" ↑↓ navigate rows (preview updates on highlight)\n"
" d soft-delete the highlighted session (with confirm;\n"
" recoverable via `cc-janitor trash restore <id>`)\n"
" / filter by message content\n"
" q quit cc-janitor\n\n"
"[bold]Tip[/bold]\n"
"Heavy sessions (> 5 MB) inflate Claude Code's index. Prune old\n"
Expand All @@ -33,9 +31,8 @@
"layers (user, project, local) with usage stats.\n\n"
"[bold]Keys[/bold]\n"
" ↑↓ navigate rows\n"
" p prune stale rules (no matches in 90d)\n"
" D dedup overlapping rules\n"
" / filter by tool or pattern\n\n"
" p prune stale rules (no matches in 90d) — preview + confirm\n"
" D dedup overlapping rules — preview + confirm\n\n"
"[bold]Columns[/bold]\n"
" Used90d number of tool_use events matching this rule\n"
" Flags STALE = no recent matches, DUP = overlaps another rule"
Expand Down Expand Up @@ -72,19 +69,19 @@
"Maintenance jobs running via cron (Unix) or schtasks (Windows).\n"
"All new jobs start in dry-run mode — verify, then promote.\n\n"
"[bold]Keys[/bold]\n"
" n add a new job from a template\n"
" Del remove the highlighted job\n"
" r run the highlighted job now\n"
" a add a new job from a template\n"
" r remove the highlighted job\n"
" n run the highlighted job now\n"
" p promote a dry-run job to live mode"
),
"audit": (
"[bold]Audit[/bold]\n\n"
"Every mutation cc-janitor made — when, why, and how to revert.\n"
"Daily snapshots feed the sparklines panel.\n\n"
"[bold]Keys[/bold]\n"
" s toggle the sparklines panel\n"
" u show undo instructions for the most recent action\n\n"
"Take a snapshot with `cc-janitor stats snapshot`."
" s toggle the sparklines panel\n\n"
"Take a snapshot with `cc-janitor stats snapshot`.\n"
"Use `cc-janitor undo` from the CLI to revert the most recent mutation."
),
"dream": (
"[bold]Dream[/bold]\n\n"
Expand Down
8 changes: 1 addition & 7 deletions src/cc_janitor/tui/screens/audit_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,12 @@ class AuditScreen(Widget):

BINDINGS = [
("s", "toggle_sparklines", "Toggle stats"),
("u", "undo", "Undo recent"),
]

def compose(self) -> ComposeResult:
yield Static(
"[bold]What cc-janitor has done on your behalf[/bold] [dim]"
"· s toggle sparklines · u undo recent[/dim]",
"· s toggle sparklines · undo via `cc-janitor undo` CLI[/dim]",
id="audit-header",
)
yield Static("Audit log viewer", id="audit-body")
Expand Down Expand Up @@ -69,8 +68,3 @@ def _refresh_sparklines(self) -> None:
def action_toggle_sparklines(self) -> None:
panel = self.query_one("#audit-sparklines", Static)
panel.display = not panel.display

def action_undo(self) -> None:
self.notify(
"Run `cc-janitor undo` from the CLI to revert the most recent mutation.",
)
3 changes: 3 additions & 0 deletions src/cc_janitor/tui/screens/context_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ def on_mount(self) -> None:
f"[b]≈ ${dollars:.4f}[/] per request at Opus input rate"
)
self.query_one("#context-totals", Static).update(text)

def on_show(self) -> None:
self.query_one("#context-table", DataTable).focus()
3 changes: 3 additions & 0 deletions src/cc_janitor/tui/screens/dream_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ def on_mount(self) -> None:
)
self._show_diff_for(None)

def on_show(self) -> None:
self.query_one("#dream-list", DataTable).focus()

def on_data_table_row_highlighted(
self, event: DataTable.RowHighlighted
) -> None:
Expand Down
3 changes: 3 additions & 0 deletions src/cc_janitor/tui/screens/hooks_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ def on_mount(self) -> None:
self._source_filter = "real"
self._reload()

def on_show(self) -> None:
self.query_one("#hooks-table", DataTable).focus()

def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id != "hooks-source-filter":
return
Expand Down
3 changes: 3 additions & 0 deletions src/cc_janitor/tui/screens/memory_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ def on_mount(self) -> None:
self._source_filter = "real"
self._reload()

def on_show(self) -> None:
self.query_one("#memory-table", DataTable).focus()

def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id != "memory-source-filter":
return
Expand Down
Loading
Loading