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.0] — 2026-05-21

### Fixed (Critical — TUI rendering)

- **TUI bodies no longer render empty.** All 8 screens previously used
`height: NN%` CSS, which Textual 8.2.5 collapses to 0 inside a
`TabPane` — so opening any tab produced an empty viewport. Switched to
`1fr` fractional units; `DataTable` and side panes now share space
correctly.

### Added (UX polish)

- **Per-screen header.** Every tab now shows a 1-line Rich-markup header
at the top stating what the tab shows and the key actions available.
- **Empty-state CTAs.** Schedule lists the five built-in templates when
no jobs exist. Audit shows the `cc-janitor stats snapshot` recipe when
no snapshots are taken yet. Dream explains the safety-net concept
before any pairs exist.
- **F1 tab-specific help modal.** Press F1 anywhere; a `HelpModal`
shows 5–10 lines of guidance (keybindings, column meanings, tips)
scoped to the currently-active tab. Eight help texts live in
`tui/_help.py::HELP`.
- **First-run Welcome modal.** On first launch a `WelcomeModal` lists
all eight tabs and what each one shows. Dismissing it touches
`~/.cc-janitor/state/seen-welcome` so it never reappears. Re-show via
`cc-janitor --tutorial`.
- **Filter-by-scope labels** next to the source-filter `Select` on
Permissions, Memory, and Hooks.
- **Inline column legends** on Sessions (explains `Msgs`/`Size`) and in
the Permissions summary panel (explains `Used90d` and `STALE`/`DUP`).
- **Screen-level BINDINGS** on every screen so the Footer surfaces the
per-tab action keys when that tab has focus.

### Changed

- Audit screen gains a separate `#audit-body` widget so the new header
doesn't collide with the existing body text.
- Dream screen layout becomes vertical at the root (header on top,
horizontal list+diff body below) instead of a single horizontal flex.

### Tests

- `tests/tui/test_ux_polish.py` covers headers, legends, filter labels,
empty-state CTAs, F1 modal, Welcome-marker logic, and the
`--tutorial` flag (12 new tests).
- `tests/conftest.py` pre-touches the welcome marker so existing TUI
tests aren't blocked on the new first-run modal.

### Counts

- 302 passing tests (was 290)

## [0.4.2] — 2026-05-13

### Fixed (Critical — post-Phase-4 UX audit)
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,37 @@ uv tool install "cc-janitor[watcher]"

```bash
cc-janitor # launches the TUI (8 tabs)
cc-janitor --tutorial # re-show the first-run Welcome tour
cc-janitor perms audit # which permission rules are stale / dupes
cc-janitor context cost # what your context costs per request, in $
cc-janitor dream doctor # 10 health checks for Auto Dream
cc-janitor stats sleep-hygiene # memory hygiene metrics
```

**TUI tab-by-tab tour** (each tab has a 1-line header at the top, and
[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.
- **Permissions** — Bash/Edit/etc. allow rules merged from all settings
layers, annotated with `Used90d` match-counts and STALE/DUP flags.
- **Context** — what gets injected into every request (CLAUDE.md, memory,
skills) with token totals and per-request cost.
- **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`).
- **Dream** — Auto Dream before/after snapshot pairs with file-level
diffs and one-key rollback.

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
reappears. Run `cc-janitor --tutorial` to see it again.

If `context cost` reveals you’re burning more tokens per request than you thought, or `perms audit` shows 60% of your rules are stale, you’ll know whether to proceed to step 3.

### 3. Clean up (with confirmation)
Expand Down
35 changes: 34 additions & 1 deletion docs/cookbook.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,43 @@
# Cookbook

Thirteen task-oriented recipes for everyday cc-janitor use. Each recipe follows
Task-oriented recipes for everyday cc-janitor use. Each recipe follows
the same shape: **Problem → Command → Expected output → Next step.**

---

## 0. First-time user — the 60-second tour

**Problem:** You just `pip install cc-janitor`'d and want to know what's
in the box before touching anything.

**Command:**

```bash
cc-janitor # launches the TUI
```

**Expected output:** A Welcome modal appears on first launch listing the
eight tabs (Sessions, Permissions, Context, Memory, Hooks, Schedule,
Audit, Dream) and what each one shows. Dismiss with Enter; a marker file
at `~/.cc-janitor/state/seen-welcome` ensures it won't reappear.

Each tab has a 1-line header at the top describing what it shows and the
relevant keys. Press [F1] anywhere for tab-specific deeper help (which
keys do what, what the columns mean, when to use which CLI follow-up).

To see the Welcome modal again, run:

```bash
cc-janitor --tutorial
```

**Next step:** Open the **Schedule** tab — if you've never added a job,
the right pane lists the five built-in templates (`perms-prune`,
`trash-cleanup`, `backup-rotate`, `context-audit`, `dream-tar-compact`)
so you can pick something sensible to automate.

---

## 1. Clean up your permission rules

**Problem:** `~/.claude/settings.json` (and friends) have grown to dozens of
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.4.2"
version = "0.5.0"
description = "Tidy up your Claude Code environment — sessions, permissions, context, hooks, schedule."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
6 changes: 6 additions & 0 deletions src/cc_janitor/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ def main() -> None:
# When Click's shell-completion hook is active (env var set by the
# shell completion script), let the Typer/Click app handle it so it
# can emit the completion script and exit.
# --tutorial re-displays the first-run Welcome modal even when the marker
# file already exists; it's the only CLI flag that launches the TUI.
if sys.argv[1:] == ["--tutorial"]:
from .tui.app import run
run(show_welcome=True)
return
if len(sys.argv) > 1 or os.environ.get("_CC_JANITOR_COMPLETE"):
from .cli import app
app()
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.4.2"
__VERSION__ = "0.5.0"

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

Expand Down
213 changes: 213 additions & 0 deletions src/cc_janitor/tui/_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"""Per-tab help text shown by the F1 modal.

Keyed by TabPane id (matches what's set in :mod:`cc_janitor.tui.app`).
Body uses Rich markup; the modal renders it via a Static widget.
"""
from __future__ import annotations

from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Static

HELP: dict[str, str] = {
"sessions": (
"[bold]Sessions[/bold]\n\n"
"Claude Code stores every conversation as a JSONL file under\n"
"~/.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"
" 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"
"ones via `cc-janitor session prune --older-than 90d --apply`."
),
"perms": (
"[bold]Permissions[/bold]\n\n"
"Lists every Bash/Edit/etc. allow rule merged from all settings\n"
"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"
"[bold]Columns[/bold]\n"
" Used90d number of tool_use events matching this rule\n"
" Flags STALE = no recent matches, DUP = overlaps another rule"
),
"context": (
"[bold]Context[/bold]\n\n"
"Shows what gets injected into every Claude Code request:\n"
"CLAUDE.md, memory files, skills, hooks.\n\n"
"[bold]Keys[/bold]\n"
" ↑↓ navigate rows\n"
" Enter preview the highlighted file\n\n"
"Total tokens drive the per-request input cost shown in the footer."
),
"memory": (
"[bold]Memory[/bold]\n\n"
"Your project and user memory files (CLAUDE.md, MEMORY.md,\n"
"feedback/, project-state/, reference/).\n\n"
"[bold]Keys[/bold]\n"
" r reinject memory on next Claude Code tool call\n"
" a archive the highlighted file (reversible via undo)\n"
" f find duplicate lines across files"
),
"hooks": (
"[bold]Hooks[/bold]\n\n"
"PreToolUse / PostToolUse / Stop hooks merged from every settings\n"
"layer with their commands and logging state.\n\n"
"[bold]Keys[/bold]\n"
" l toggle logging wrapper on the highlighted hook\n"
" t simulate / test the hook command\n"
" v open the source settings.json in $EDITOR"
),
"schedule": (
"[bold]Schedule[/bold]\n\n"
"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"
" 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`."
),
"dream": (
"[bold]Dream[/bold]\n\n"
"Anthropic's Auto Dream silently consolidates your memory between\n"
"sessions. cc-janitor takes before/after snapshots so you can audit\n"
"and roll back if Dream made a bad call.\n\n"
"[bold]Keys[/bold]\n"
" ↑↓ navigate snapshot pairs\n"
" Enter show the file-level diff\n"
" b roll back to the pre-Dream state\n\n"
"Run `cc-janitor dream doctor` to check if Auto Dream is enabled."
),
}


class HelpModal(ModalScreen[None]):
"""F1 help — shows context-specific guidance for the active tab."""

BINDINGS = [
("escape", "dismiss_modal", "Close"),
("enter", "dismiss_modal", "Close"),
("f1", "dismiss_modal", "Close"),
]

DEFAULT_CSS = """
HelpModal { align: center middle; }
#help-box {
width: 80;
max-width: 90%;
height: auto;
background: $panel;
border: round $accent;
padding: 1 2;
}
#help-body { padding-bottom: 1; }
"""

def __init__(self, tab_id: str | None) -> None:
super().__init__()
self._tab_id = tab_id or "sessions"

def compose(self) -> ComposeResult:
body = HELP.get(self._tab_id, HELP["sessions"])
with Vertical(id="help-box"):
yield Static(body, id="help-body")
yield Static("[dim]Esc / Enter / F1 to close[/dim]")
yield Button("Close", variant="primary", id="help-close")

def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(None)

def action_dismiss_modal(self) -> None:
self.dismiss(None)


_WELCOME_BODY = (
"[bold]cc-janitor[/bold] — tidy up your Claude Code environment\n\n"
"This is what each tab shows:\n"
" [bold]Sessions[/bold] — all your past Claude Code conversations\n"
" [bold]Permissions[/bold] — Bash/Edit/etc. allow rules merged from all settings\n"
" [bold]Context[/bold] — what gets sent in every request (CLAUDE.md, memory, skills)\n"
" [bold]Memory[/bold] — your project memory files\n"
" [bold]Hooks[/bold] — PreToolUse/PostToolUse/Stop hooks across all settings\n"
" [bold]Schedule[/bold] — scheduled maintenance (uses cron / schtasks)\n"
" [bold]Audit[/bold] — log of every mutation cc-janitor made\n"
" [bold]Dream[/bold] — Auto Dream snapshots (if Anthropic has enabled it for you)\n\n"
"Press [bold]F1[/bold] anywhere for tab-specific help.\n"
"Press [bold]Enter[/bold] to dismiss this welcome.\n"
"Press [bold]q[/bold] to quit."
)


class WelcomeModal(ModalScreen[None]):
"""First-run tour modal. Dismiss writes a marker so it never reappears."""

BINDINGS = [
("enter", "dismiss_modal", "OK"),
("escape", "dismiss_modal", "OK"),
]

DEFAULT_CSS = """
WelcomeModal { align: center middle; }
#welcome-box {
width: 80;
max-width: 90%;
height: auto;
background: $panel;
border: round $success;
padding: 1 2;
}
#welcome-body { padding-bottom: 1; }
"""

def compose(self) -> ComposeResult:
with Vertical(id="welcome-box"):
yield Static(_WELCOME_BODY, id="welcome-body")
yield Button("Got it", variant="primary", id="welcome-ok")

def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(None)

def action_dismiss_modal(self) -> None:
self.dismiss(None)


def welcome_marker_path():
"""Path of the seen-welcome marker file."""
import os
from pathlib import Path

home_override = os.environ.get("CC_JANITOR_HOME")
if home_override:
base = Path(home_override)
else:
base = Path.home() / ".cc-janitor"
return base / "state" / "seen-welcome"


def has_seen_welcome() -> bool:
return welcome_marker_path().exists()


def mark_welcome_seen() -> None:
p = welcome_marker_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.touch()
Loading
Loading