From 607f6edfb78c1fa911ab1c0d5b4a354e17437a47 Mon Sep 17 00:00:00 2001 From: Creatman Date: Thu, 21 May 2026 06:24:21 -0400 Subject: [PATCH 1/7] fix(tui): 1fr CSS units (Textual 8.2.5 collapses % heights in TabPane) All 8 screens used height: NN% which Textual 8.2.5 resolves to 0 when the screen sits inside a TabPane, producing empty bodies. Switching to 1fr fractional units makes the panes share remaining space correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/tui/app.py | 2 +- src/cc_janitor/tui/screens/audit_screen.py | 2 +- src/cc_janitor/tui/screens/context_screen.py | 6 +++--- src/cc_janitor/tui/screens/dream_screen.py | 2 +- src/cc_janitor/tui/screens/hooks_screen.py | 6 +++--- src/cc_janitor/tui/screens/memory_screen.py | 6 +++--- src/cc_janitor/tui/screens/perms_screen.py | 6 +++--- src/cc_janitor/tui/screens/schedule_screen.py | 6 +++--- src/cc_janitor/tui/screens/sessions_screen.py | 6 +++--- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/cc_janitor/tui/app.py b/src/cc_janitor/tui/app.py index 406cf4c..9fbbc90 100644 --- a/src/cc_janitor/tui/app.py +++ b/src/cc_janitor/tui/app.py @@ -8,7 +8,7 @@ class CcJanitorApp(App): CSS = """ - TabbedContent { height: 100%; } + TabbedContent { height: 1fr; } """ BINDINGS = [ ("q", "quit", "Quit"), diff --git a/src/cc_janitor/tui/screens/audit_screen.py b/src/cc_janitor/tui/screens/audit_screen.py index 1cea78a..0d57c26 100644 --- a/src/cc_janitor/tui/screens/audit_screen.py +++ b/src/cc_janitor/tui/screens/audit_screen.py @@ -14,7 +14,7 @@ class AuditScreen(Widget): """ DEFAULT_CSS = """ - AuditScreen { height: 100%; } + AuditScreen { layout: vertical; height: 1fr; } #audit-header { height: auto; padding: 1; } #audit-sparklines { height: auto; diff --git a/src/cc_janitor/tui/screens/context_screen.py b/src/cc_janitor/tui/screens/context_screen.py index ffa0dda..3c8ac63 100644 --- a/src/cc_janitor/tui/screens/context_screen.py +++ b/src/cc_janitor/tui/screens/context_screen.py @@ -13,9 +13,9 @@ class ContextScreen(Widget): """Context-cost inspector: CLAUDE.md + memory + skills with token totals.""" DEFAULT_CSS = """ - ContextScreen { height: 100%; } - DataTable { height: 70%; } - #context-totals { height: 30%; border: round green; padding: 1; } + ContextScreen { layout: vertical; height: 1fr; } + DataTable { height: 7fr; } + #context-totals { height: 3fr; border: round green; padding: 1; } """ def compose(self) -> ComposeResult: diff --git a/src/cc_janitor/tui/screens/dream_screen.py b/src/cc_janitor/tui/screens/dream_screen.py index 1c5d87f..e53c169 100644 --- a/src/cc_janitor/tui/screens/dream_screen.py +++ b/src/cc_janitor/tui/screens/dream_screen.py @@ -17,7 +17,7 @@ class DreamScreen(Widget): DEFAULT_CSS = """ - DreamScreen { layout: horizontal; height: 100%; } + DreamScreen { layout: horizontal; height: 1fr; } DreamScreen DataTable { width: 60; } DreamScreen Static { width: 1fr; padding: 0 1; } """ diff --git a/src/cc_janitor/tui/screens/hooks_screen.py b/src/cc_janitor/tui/screens/hooks_screen.py index ef77950..84267a4 100644 --- a/src/cc_janitor/tui/screens/hooks_screen.py +++ b/src/cc_janitor/tui/screens/hooks_screen.py @@ -14,10 +14,10 @@ class HooksScreen(Widget): """Hook browser with simulate + logging-toggle actions.""" DEFAULT_CSS = """ - HooksScreen { height: 100%; } + HooksScreen { layout: vertical; height: 1fr; } #hooks-source-filter { height: 3; } - DataTable { height: 57%; } - #hooks-source { height: 40%; border: round green; padding: 1; } + DataTable { height: 1fr; } + #hooks-source { height: 12; border: round green; padding: 1; } """ BINDINGS = [ diff --git a/src/cc_janitor/tui/screens/memory_screen.py b/src/cc_janitor/tui/screens/memory_screen.py index d4d4b2c..01775b6 100644 --- a/src/cc_janitor/tui/screens/memory_screen.py +++ b/src/cc_janitor/tui/screens/memory_screen.py @@ -18,10 +18,10 @@ class MemoryScreen(Widget): """Memory files browser with preview and reinject action.""" DEFAULT_CSS = """ - MemoryScreen { height: 100%; } + MemoryScreen { layout: vertical; height: 1fr; } #memory-source-filter { height: 3; } - DataTable { height: 57%; } - #memory-preview { height: 40%; border: round green; padding: 1; } + DataTable { height: 1fr; } + #memory-preview { height: 12; border: round green; padding: 1; } """ # 0.4.2: `e` (edit) and `m` (move-type) were declared but never diff --git a/src/cc_janitor/tui/screens/perms_screen.py b/src/cc_janitor/tui/screens/perms_screen.py index b16ed64..4b3f931 100644 --- a/src/cc_janitor/tui/screens/perms_screen.py +++ b/src/cc_janitor/tui/screens/perms_screen.py @@ -13,10 +13,10 @@ class PermsScreen(Widget): """Effective permission rules + source summary.""" DEFAULT_CSS = """ - PermsScreen { height: 100%; } + PermsScreen { layout: vertical; height: 1fr; } #perms-source-filter { height: 3; } - DataTable { height: 62%; } - #perms-summary { height: 35%; border: round green; padding: 1; } + DataTable { height: 1fr; } + #perms-summary { height: 12; border: round green; padding: 1; } """ def compose(self) -> ComposeResult: diff --git a/src/cc_janitor/tui/screens/schedule_screen.py b/src/cc_janitor/tui/screens/schedule_screen.py index ddbffe4..7319d89 100644 --- a/src/cc_janitor/tui/screens/schedule_screen.py +++ b/src/cc_janitor/tui/screens/schedule_screen.py @@ -82,9 +82,9 @@ class ScheduleScreen(Widget): """Scheduled-job manager (cron / Windows schtasks).""" DEFAULT_CSS = """ - ScheduleScreen { height: 100%; } - #schedule-table { height: 70%; } - #schedule-status { height: 30%; border: round green; padding: 1; } + ScheduleScreen { layout: vertical; height: 1fr; } + #schedule-table { height: 7fr; } + #schedule-status { height: 3fr; border: round green; padding: 1; } """ BINDINGS = [ diff --git a/src/cc_janitor/tui/screens/sessions_screen.py b/src/cc_janitor/tui/screens/sessions_screen.py index ad2017b..1f4131b 100644 --- a/src/cc_janitor/tui/screens/sessions_screen.py +++ b/src/cc_janitor/tui/screens/sessions_screen.py @@ -12,9 +12,9 @@ class SessionsScreen(Widget): """Sessions list + preview pane.""" DEFAULT_CSS = """ - SessionsScreen { height: 100%; } - DataTable { height: 60%; } - #preview { height: 40%; border: round green; padding: 1; } + SessionsScreen { layout: vertical; height: 1fr; } + DataTable { height: 3fr; } + #preview { height: 2fr; border: round green; padding: 1; } """ def compose(self) -> ComposeResult: From 109d967087343c334a31ee7bcb86472197617a25 Mon Sep 17 00:00:00 2001 From: Creatman Date: Thu, 21 May 2026 06:31:55 -0400 Subject: [PATCH 2/7] feat(tui): per-screen header widget with tab-specific guidance Add a 1-line Rich-markup header at the top of each screen indicating what the tab shows and the key actions available. Also bundles in: - inline column legends (Sessions: Msgs/Size; Permissions: Used90d/Flags) - "Filter by scope:" labels next to source-filter dropdowns - proper screen-level BINDINGS so Footer surfaces the right keys Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/tui/screens/context_screen.py | 6 +++ src/cc_janitor/tui/screens/hooks_screen.py | 25 +++++++--- src/cc_janitor/tui/screens/memory_screen.py | 25 +++++++--- src/cc_janitor/tui/screens/perms_screen.py | 47 +++++++++++++++---- src/cc_janitor/tui/screens/sessions_screen.py | 26 ++++++++++ 5 files changed, 107 insertions(+), 22 deletions(-) diff --git a/src/cc_janitor/tui/screens/context_screen.py b/src/cc_janitor/tui/screens/context_screen.py index 3c8ac63..c023a09 100644 --- a/src/cc_janitor/tui/screens/context_screen.py +++ b/src/cc_janitor/tui/screens/context_screen.py @@ -14,11 +14,17 @@ class ContextScreen(Widget): DEFAULT_CSS = """ ContextScreen { layout: vertical; height: 1fr; } + #context-header { height: auto; padding: 0 1; background: $boost; color: $text; } DataTable { height: 7fr; } #context-totals { height: 3fr; border: round green; padding: 1; } """ def compose(self) -> ComposeResult: + yield Static( + "[bold]What gets injected into every Claude Code request[/bold] " + "[dim]· ↑↓ navigate · Enter file preview[/dim]", + id="context-header", + ) yield DataTable(id="context-table") yield Static("", id="context-totals") diff --git a/src/cc_janitor/tui/screens/hooks_screen.py b/src/cc_janitor/tui/screens/hooks_screen.py index 84267a4..14bf969 100644 --- a/src/cc_janitor/tui/screens/hooks_screen.py +++ b/src/cc_janitor/tui/screens/hooks_screen.py @@ -1,8 +1,9 @@ from __future__ import annotations from textual.app import ComposeResult +from textual.containers import Horizontal from textual.widget import Widget -from textual.widgets import DataTable, Select, Static +from textual.widgets import DataTable, Label, Select, Static from ...cli._audit import audit_action from ...core.hooks import HookEntry, discover_hooks, simulate_hook @@ -15,7 +16,10 @@ class HooksScreen(Widget): DEFAULT_CSS = """ HooksScreen { layout: vertical; height: 1fr; } - #hooks-source-filter { height: 3; } + #hooks-header { height: auto; padding: 0 1; background: $boost; color: $text; } + #hooks-filter-row { height: 3; } + #hooks-filter-row Label { padding: 1 1 0 1; width: auto; } + #hooks-source-filter { width: 1fr; } DataTable { height: 1fr; } #hooks-source { height: 12; border: round green; padding: 1; } """ @@ -27,12 +31,19 @@ class HooksScreen(Widget): ] def compose(self) -> ComposeResult: - yield Select( - list(source_filter_options()), - id="hooks-source-filter", - value="real", - allow_blank=False, + yield Static( + "[bold]Hooks merged from all settings layers[/bold] [dim]" + "· l toggle logging · t test/simulate[/dim]", + id="hooks-header", ) + with Horizontal(id="hooks-filter-row"): + yield Label("Filter by scope:") + yield Select( + list(source_filter_options()), + id="hooks-source-filter", + value="real", + allow_blank=False, + ) yield DataTable(id="hooks-table") yield Static("", id="hooks-source") diff --git a/src/cc_janitor/tui/screens/memory_screen.py b/src/cc_janitor/tui/screens/memory_screen.py index 01775b6..fd6eafb 100644 --- a/src/cc_janitor/tui/screens/memory_screen.py +++ b/src/cc_janitor/tui/screens/memory_screen.py @@ -1,8 +1,9 @@ from __future__ import annotations from textual.app import ComposeResult +from textual.containers import Horizontal from textual.widget import Widget -from textual.widgets import DataTable, Select, Static +from textual.widgets import DataTable, Label, Select, Static from ...cli._audit import audit_action from ...core.memory import ( @@ -19,7 +20,10 @@ class MemoryScreen(Widget): DEFAULT_CSS = """ MemoryScreen { layout: vertical; height: 1fr; } - #memory-source-filter { height: 3; } + #memory-header { height: auto; padding: 0 1; background: $boost; color: $text; } + #memory-filter-row { height: 3; } + #memory-filter-row Label { padding: 1 1 0 1; width: auto; } + #memory-source-filter { width: 1fr; } DataTable { height: 1fr; } #memory-preview { height: 12; border: round green; padding: 1; } """ @@ -34,12 +38,19 @@ class MemoryScreen(Widget): ] def compose(self) -> ComposeResult: - yield Select( - list(source_filter_options()), - id="memory-source-filter", - value="real", - allow_blank=False, + yield Static( + "[bold]Your project memory files[/bold] [dim]" + "· r reinject · a archive · f find duplicates[/dim]", + id="memory-header", ) + with Horizontal(id="memory-filter-row"): + yield Label("Filter by scope:") + yield Select( + list(source_filter_options()), + id="memory-source-filter", + value="real", + allow_blank=False, + ) yield DataTable(id="memory-table") yield Static("", id="memory-preview") diff --git a/src/cc_janitor/tui/screens/perms_screen.py b/src/cc_janitor/tui/screens/perms_screen.py index 4b3f931..3d5ffc6 100644 --- a/src/cc_janitor/tui/screens/perms_screen.py +++ b/src/cc_janitor/tui/screens/perms_screen.py @@ -1,8 +1,9 @@ from __future__ import annotations from textual.app import ComposeResult +from textual.containers import Horizontal from textual.widget import Widget -from textual.widgets import DataTable, Select, Static +from textual.widgets import DataTable, Label, Select, Static from ...core.permissions import analyze_usage, discover_rules, find_duplicates from ...core.sessions import discover_sessions @@ -12,20 +13,35 @@ class PermsScreen(Widget): """Effective permission rules + source summary.""" + BINDINGS = [ + ("p", "prune", "Prune stale"), + ("D", "dedup", "Dedup"), + ] + DEFAULT_CSS = """ PermsScreen { layout: vertical; height: 1fr; } - #perms-source-filter { height: 3; } + #perms-header { height: auto; padding: 0 1; background: $boost; color: $text; } + #perms-filter-row { height: 3; } + #perms-filter-row Label { padding: 1 1 0 1; width: auto; } + #perms-source-filter { width: 1fr; } DataTable { height: 1fr; } - #perms-summary { height: 12; border: round green; padding: 1; } + #perms-summary { height: 14; border: round green; padding: 1; } """ def compose(self) -> ComposeResult: - yield Select( - list(source_filter_options()), - id="perms-source-filter", - value="real", - allow_blank=False, + yield Static( + "[bold]Effective permission rules from all sources[/bold] [dim]" + "· ↑↓ navigate · p prune stale · D dedup · / filter[/dim]", + id="perms-header", ) + with Horizontal(id="perms-filter-row"): + yield Label("Filter by scope:") + yield Select( + list(source_filter_options()), + id="perms-source-filter", + value="real", + allow_blank=False, + ) yield DataTable(id="perms-table") yield Static("", id="perms-summary") @@ -39,6 +55,16 @@ def on_select_changed(self, event: Select.Changed) -> None: self._source_filter = str(event.value) self._reload() + def action_prune(self) -> None: + self.notify( + "Run `cc-janitor perms prune --apply` from the CLI to remove stale rules.", + ) + + def action_dedup(self) -> None: + self.notify( + "Run `cc-janitor perms dedup --apply` from the CLI to merge duplicate rules.", + ) + def _reload(self) -> None: rules = analyze_usage( discover_rules(scope=getattr(self, "_source_filter", None)), @@ -84,4 +110,9 @@ def _reload(self) -> None: line += f", {stale} stale" line += "]" lines.append(line) + lines.append("") + lines.append( + "[dim]Used90d = matches in your transcripts over last 90 days. " + "Flags: STALE = no matches in last 90d; DUP = duplicate of another rule.[/dim]" + ) self.query_one("#perms-summary", Static).update("\n".join(lines)) diff --git a/src/cc_janitor/tui/screens/sessions_screen.py b/src/cc_janitor/tui/screens/sessions_screen.py index 1f4131b..5b7d267 100644 --- a/src/cc_janitor/tui/screens/sessions_screen.py +++ b/src/cc_janitor/tui/screens/sessions_screen.py @@ -11,13 +11,30 @@ class SessionsScreen(Widget): """Sessions list + preview pane.""" + BINDINGS = [ + ("d", "delete", "Delete"), + ("slash", "search", "Search"), + ] + DEFAULT_CSS = """ SessionsScreen { layout: vertical; height: 1fr; } + #sessions-header { height: auto; padding: 0 1; background: $boost; color: $text; } + #sessions-legend { height: auto; padding: 0 1; } DataTable { height: 3fr; } #preview { height: 2fr; border: round green; padding: 1; } """ def compose(self) -> ComposeResult: + yield Static( + "[bold]Your Claude Code sessions[/bold] [dim]" + "· ↑↓ navigate · Enter preview · d delete · / search[/dim]", + id="sessions-header", + ) + yield Static( + "[dim]Msgs = total message count (user + assistant). " + "Size = JSONL file size on disk.[/dim]", + id="sessions-legend", + ) yield DataTable(id="sessions-table") yield Static("", id="preview") @@ -37,6 +54,15 @@ def on_mount(self) -> None: key=s.id, ) + def action_delete(self) -> None: + self.notify( + "Run `cc-janitor session prune --older-than 90d --apply` to remove stale " + "sessions in bulk.", + ) + + def action_search(self) -> None: + self.notify("Search is coming in a future release. Use `cc-janitor session find` for now.") + def on_data_table_row_highlighted(self, ev: DataTable.RowHighlighted) -> None: if ev.row_key is None or ev.row_key.value is None: return From 36114661855f66d4c17f7f5d503a6233ef8e199c Mon Sep 17 00:00:00 2001 From: Creatman Date: Thu, 21 May 2026 06:32:03 -0400 Subject: [PATCH 3/7] feat(tui): headers + empty-state CTAs for schedule/audit/dream When a tab has nothing to show yet, replace the terse "no data" line with a concrete next-step recipe pointing at CLI commands. - Schedule: list the available templates - Audit: show `cc-janitor stats snapshot` invocation - Dream: explain the safety-net concept and link to `dream doctor` Also adds the per-screen header line and bindings so Footer surfaces the screen-level keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/tui/screens/audit_screen.py | 24 +++++++++++-- src/cc_janitor/tui/screens/dream_screen.py | 35 +++++++++++++++---- src/cc_janitor/tui/screens/schedule_screen.py | 22 ++++++++++-- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/cc_janitor/tui/screens/audit_screen.py b/src/cc_janitor/tui/screens/audit_screen.py index 0d57c26..f2efd44 100644 --- a/src/cc_janitor/tui/screens/audit_screen.py +++ b/src/cc_janitor/tui/screens/audit_screen.py @@ -15,7 +15,8 @@ class AuditScreen(Widget): DEFAULT_CSS = """ AuditScreen { layout: vertical; height: 1fr; } - #audit-header { height: auto; padding: 1; } + #audit-header { height: auto; padding: 0 1; background: $boost; color: $text; } + #audit-body { height: auto; padding: 1; } #audit-sparklines { height: auto; border: round green; @@ -25,10 +26,16 @@ class AuditScreen(Widget): BINDINGS = [ ("s", "toggle_sparklines", "Toggle stats"), + ("u", "undo", "Undo recent"), ] def compose(self) -> ComposeResult: - yield Static("Audit log viewer", id="audit-header") + yield Static( + "[bold]What cc-janitor has done on your behalf[/bold] [dim]" + "· s toggle sparklines · u undo recent[/dim]", + id="audit-header", + ) + yield Static("Audit log viewer", id="audit-body") yield Static("", id="audit-sparklines") def on_mount(self) -> None: @@ -38,7 +45,13 @@ def _refresh_sparklines(self) -> None: snaps = load_snapshots() panel = self.query_one("#audit-sparklines", Static) if not snaps: - panel.update("No snapshots in window. Run `cc-janitor stats snapshot`.") + panel.update( + "[b]No daily snapshots taken yet.[/]\n\n" + "Run from CLI:\n" + " cc-janitor stats snapshot # one-off\n" + " cc-janitor schedule add context-audit # daily via cron/schtasks\n\n" + "Sparklines need ≥2 snapshots to draw." + ) return last = snaps[-1] lines = [ @@ -56,3 +69,8 @@ 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.", + ) diff --git a/src/cc_janitor/tui/screens/dream_screen.py b/src/cc_janitor/tui/screens/dream_screen.py index e53c169..f922f7c 100644 --- a/src/cc_janitor/tui/screens/dream_screen.py +++ b/src/cc_janitor/tui/screens/dream_screen.py @@ -8,6 +8,7 @@ from __future__ import annotations from textual.app import ComposeResult +from textual.containers import Horizontal from textual.widget import Widget from textual.widgets import DataTable, Static @@ -17,20 +18,30 @@ class DreamScreen(Widget): DEFAULT_CSS = """ - DreamScreen { layout: horizontal; height: 1fr; } - DreamScreen DataTable { width: 60; } - DreamScreen Static { width: 1fr; padding: 0 1; } + DreamScreen { layout: vertical; height: 1fr; } + #dream-header { height: auto; padding: 0 1; background: $boost; color: $text; } + #dream-body { layout: horizontal; height: 1fr; } + #dream-body DataTable { width: 60; } + #dream-body #dream-diff { width: 1fr; padding: 0 1; } """ def compose(self) -> ComposeResult: - yield DataTable(id="dream-list") - yield Static(id="dream-diff", expand=True) + yield Static( + "[bold]Auto Dream snapshots (before/after pairs)[/bold] [dim]" + "· ↑↓ navigate · Enter diff · b rollback[/dim]", + id="dream-header", + ) + with Horizontal(id="dream-body"): + yield DataTable(id="dream-list") + yield Static(id="dream-diff", expand=True) def on_mount(self) -> None: table: DataTable = self.query_one("#dream-list", DataTable) table.add_columns("Date", "Project", "ΔFiles", "ΔLines") table.cursor_type = "row" + self._has_pairs = False for pair in reversed(history()): + self._has_pairs = True table.add_row( pair.ts_pre[:19], pair.project_slug, @@ -49,7 +60,19 @@ def on_data_table_row_highlighted( def _show_diff_for(self, pair_id: str | None) -> None: diff_widget: Static = self.query_one("#dream-diff", Static) if not pair_id: - diff_widget.update("Select a snapshot pair on the left.") + if not getattr(self, "_has_pairs", False): + diff_widget.update( + "[bold]Auto Dream safety net.[/bold]\n\n" + "cc-janitor watches for Claude Code's Auto Dream consolidation.\n" + "When Auto Dream runs, you'll see before/after pairs here with\n" + "file-level diffs and one-key rollback.\n\n" + "To start watching:\n" + " cc-janitor watch start --dream\n\n" + "Run `cc-janitor dream doctor` to check if Auto Dream is enabled in\n" + "your ~/.claude/settings.json." + ) + else: + diff_widget.update("Select a snapshot pair on the left.") return pre = _dream_root() / f"{pair_id}-pre" post = _dream_root() / f"{pair_id}-post" diff --git a/src/cc_janitor/tui/screens/schedule_screen.py b/src/cc_janitor/tui/screens/schedule_screen.py index 7319d89..3381db7 100644 --- a/src/cc_janitor/tui/screens/schedule_screen.py +++ b/src/cc_janitor/tui/screens/schedule_screen.py @@ -83,6 +83,7 @@ class ScheduleScreen(Widget): DEFAULT_CSS = """ ScheduleScreen { layout: vertical; height: 1fr; } + #schedule-header { height: auto; padding: 0 1; background: $boost; color: $text; } #schedule-table { height: 7fr; } #schedule-status { height: 3fr; border: round green; padding: 1; } """ @@ -95,6 +96,11 @@ class ScheduleScreen(Widget): ] def compose(self) -> ComposeResult: + yield Static( + "[bold]Scheduled maintenance jobs[/bold] [dim]" + "· n new job · Del remove · r run now[/dim]", + id="schedule-header", + ) yield DataTable(id="schedule-table") yield Static("", id="schedule-status") @@ -128,9 +134,19 @@ def _reload(self) -> None: "yes" if j.dry_run_pending else "", key=j.name, ) - self.query_one("#schedule-status", Static).update( - f"[b]{len(self._jobs)}[/] scheduled job(s)" - ) + status = self.query_one("#schedule-status", Static) + if not self._jobs: + status.update( + "[b]No scheduled jobs yet.[/]\n" + "Press [bold]n[/bold] to add one — pick from templates:\n" + " · perms-prune weekly cleanup of stale permissions\n" + " · trash-cleanup empty .trash older than 30d\n" + " · backup-rotate prune ~/.cc-janitor/backups older than 30d\n" + " · context-audit daily context-cost snapshot for stats\n" + " · dream-tar-compact archive old Dream snapshots after 7d" + ) + else: + status.update(f"[b]{len(self._jobs)}[/] scheduled job(s)") def _highlighted(self) -> ScheduledJob | None: table = self.query_one("#schedule-table", DataTable) From 6d625cf86b658cbd0700f7c686a7eb379bd6c532 Mon Sep 17 00:00:00 2001 From: Creatman Date: Thu, 21 May 2026 06:32:14 -0400 Subject: [PATCH 4/7] feat(tui): F1 tab-specific help modal + first-run Welcome tour - _help.py: HelpModal renders Rich-markup guidance keyed by active TabPane id; WelcomeModal shows the 8-tab tour on first launch. - app.action_help() pushes the help modal with the currently-active tab id. - On mount the app pushes WelcomeModal if ~/.cc-janitor/state/seen-welcome is absent; dismissing the modal touches the marker so it never reappears. - `cc-janitor --tutorial` forces the Welcome modal regardless of marker. - tests/conftest.py pre-touches the marker for both mock_claude_home and an autouse fixture so existing TUI tests aren't blocked on a modal. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cc_janitor/__main__.py | 6 + src/cc_janitor/tui/_help.py | 213 ++++++++++++++++++++++++++++++++++++ src/cc_janitor/tui/app.py | 33 +++++- tests/conftest.py | 20 ++++ 4 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 src/cc_janitor/tui/_help.py diff --git a/src/cc_janitor/__main__.py b/src/cc_janitor/__main__.py index 3c8af87..d1847a5 100644 --- a/src/cc_janitor/__main__.py +++ b/src/cc_janitor/__main__.py @@ -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() diff --git a/src/cc_janitor/tui/_help.py b/src/cc_janitor/tui/_help.py new file mode 100644 index 0000000..22d1017 --- /dev/null +++ b/src/cc_janitor/tui/_help.py @@ -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//.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 `)\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() diff --git a/src/cc_janitor/tui/app.py b/src/cc_janitor/tui/app.py index 9fbbc90..b005fd5 100644 --- a/src/cc_janitor/tui/app.py +++ b/src/cc_janitor/tui/app.py @@ -4,6 +4,7 @@ from textual.widgets import Footer, Header, TabbedContent, TabPane from ..i18n import detect_lang, set_lang, t +from ._help import HelpModal, WelcomeModal, has_seen_welcome, mark_welcome_seen class CcJanitorApp(App): @@ -16,9 +17,12 @@ class CcJanitorApp(App): ("f2", "toggle_lang", "Lang"), ] - def __init__(self) -> None: + def __init__(self, *, show_welcome: bool | None = None) -> None: super().__init__() set_lang(detect_lang()) + # Caller may force the welcome modal (e.g. --tutorial); otherwise we + # show it only on the first launch (marker file absent). + self._force_welcome = show_welcome def compose(self) -> ComposeResult: yield Header(show_clock=False) @@ -55,6 +59,29 @@ def action_toggle_lang(self) -> None: new = "ru" if _current_lang == "en" else "en" set_lang(new) + def action_help(self) -> None: + tab_id = None + try: + tabs = self.query_one(TabbedContent) + tab_id = tabs.active + except Exception: + tab_id = None + self.push_screen(HelpModal(tab_id)) -def run() -> None: - CcJanitorApp().run() + def on_mount(self) -> None: + want_welcome = ( + self._force_welcome + if self._force_welcome is not None + else not has_seen_welcome() + ) + if want_welcome: + def _after(_: object) -> None: + try: + mark_welcome_seen() + except Exception: + pass + self.push_screen(WelcomeModal(), _after) + + +def run(*, show_welcome: bool | None = None) -> None: + CcJanitorApp(show_welcome=show_welcome).run() diff --git a/tests/conftest.py b/tests/conftest.py index 544311b..53eae5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,11 @@ def mock_claude_home(tmp_path: Path, monkeypatch) -> Path: monkeypatch.setenv("HOME", str(target)) monkeypatch.setenv("USERPROFILE", str(target)) # Windows monkeypatch.setenv("CC_JANITOR_HOME", str(target / ".cc-janitor")) + # Suppress the first-run Welcome modal by pre-creating its marker so + # existing TUI tests aren't blocked on a modal they don't expect. + marker = target / ".cc-janitor" / "state" / "seen-welcome" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.touch() # Isolate cwd from the project tree so monorepo .claude/ discovery # (used by discover_rules/discover_hooks/discover_memory_files in 0.3.1+) # does not leak fixture files via the project's working directory. @@ -23,3 +28,18 @@ def mock_claude_home(tmp_path: Path, monkeypatch) -> Path: isolated_cwd.mkdir() monkeypatch.chdir(isolated_cwd) return target + + +@pytest.fixture(autouse=True) +def _isolate_welcome_marker(tmp_path_factory, monkeypatch, request) -> None: + """For TUI tests not using mock_claude_home, set a dedicated CC_JANITOR_HOME + with a pre-touched welcome marker so the Welcome modal stays suppressed.""" + # If a test already declares mock_claude_home we let it run first; this + # fixture is a no-op safety net for the few tests that don't. + if "mock_claude_home" in request.fixturenames: + return + base = tmp_path_factory.mktemp("cc-janitor-home") + monkeypatch.setenv("CC_JANITOR_HOME", str(base)) + marker = base / "state" / "seen-welcome" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.touch() From 67e6d67e7cb6b14d0126c590e7bd3169285b2b42 Mon Sep 17 00:00:00 2001 From: Creatman Date: Thu, 21 May 2026 06:35:41 -0400 Subject: [PATCH 5/7] test(tui): cover 0.5.0 UX polish surface (12 new tests) Verifies per-screen headers, sessions/perms legends, filter labels, empty-state CTAs, F1 help modal, and Welcome marker logic. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/tui/test_ux_polish.py | 196 ++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/tui/test_ux_polish.py diff --git a/tests/tui/test_ux_polish.py b/tests/tui/test_ux_polish.py new file mode 100644 index 0000000..93e82d0 --- /dev/null +++ b/tests/tui/test_ux_polish.py @@ -0,0 +1,196 @@ +"""Tests for the 0.5.0 UX-polish surface. + +Covers per-screen headers, inline legends, filter-label widgets, empty-state +CTAs, F1 help modal, and first-run Welcome modal behaviour. +""" +from __future__ import annotations + +import pytest +from textual.widgets import Label, Static, TabbedContent + + +@pytest.mark.asyncio +async def test_every_screen_has_header(mock_claude_home): + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + for sid in ("sessions", "perms", "context", "memory", "hooks", + "schedule", "audit", "dream"): + tabbed = app.query_one(TabbedContent) + tabbed.active = sid + await pilot.pause() + header = app.query_one(f"#{sid}-header", Static) + text = str(header.render()) + assert "bold" in text or len(text) > 5, ( + f"#{sid}-header empty or missing bold markup" + ) + + +@pytest.mark.asyncio +async def test_sessions_legend_visible(mock_claude_home): + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + legend = app.query_one("#sessions-legend", Static) + text = str(legend.render()) + assert "Msgs" in text and "Size" in text + + +@pytest.mark.asyncio +async def test_perms_summary_includes_legend(mock_claude_home): + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + tabbed = app.query_one(TabbedContent) + tabbed.active = "perms" + await pilot.pause() + summary = app.query_one("#perms-summary", Static) + text = str(summary.render()) + assert "Used90d" in text + assert "STALE" in text + + +@pytest.mark.asyncio +async def test_filter_labels_present(mock_claude_home): + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + # Visit each filterable tab so its widgets exist in the DOM + for sid in ("perms", "memory", "hooks"): + tabbed = app.query_one(TabbedContent) + tabbed.active = sid + await pilot.pause() + labels = list(app.query(Label)) + label_texts = " | ".join(str(lab.render()) for lab in labels) + assert "Filter by scope" in label_texts, ( + f"no 'Filter by scope:' label on {sid}: {label_texts}" + ) + + +@pytest.mark.asyncio +async def test_schedule_empty_state_lists_templates(mock_claude_home): + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + tabbed = app.query_one(TabbedContent) + tabbed.active = "schedule" + await pilot.pause() + status = app.query_one("#schedule-status", Static) + text = str(status.render()) + # Either we have 0 jobs and see the CTA, or jobs exist and we see a count. + # Mock claude home should have no jobs by default. + assert ( + "No scheduled jobs yet" in text and "perms-prune" in text + ) or "scheduled job(s)" in text + + +@pytest.mark.asyncio +async def test_audit_empty_state_shows_cli_recipe(mock_claude_home): + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + tabbed = app.query_one(TabbedContent) + tabbed.active = "audit" + await pilot.pause() + panel = app.query_one("#audit-sparklines", Static) + text = str(panel.render()) + # Either no snapshots in fixture (CTA) or sparkline data + assert "stats snapshot" in text or "Sessions:" in text + + +@pytest.mark.asyncio +async def test_dream_empty_state_shows_safety_net_message(mock_claude_home): + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + tabbed = app.query_one(TabbedContent) + tabbed.active = "dream" + await pilot.pause() + diff_widget = app.query_one("#dream-diff", Static) + text = str(diff_widget.render()) + # Either empty -> CTA, or has pairs -> regular prompt + assert ( + "Auto Dream safety net" in text or "snapshot pair" in text + ) + + +@pytest.mark.asyncio +async def test_f1_pushes_help_modal(mock_claude_home): + from cc_janitor.tui._help import HelpModal + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("f1") + await pilot.pause() + # Top of the screen stack should now be HelpModal + assert isinstance(app.screen, HelpModal) + + +@pytest.mark.asyncio +async def test_help_modal_content_varies_by_tab(mock_claude_home): + from cc_janitor.tui._help import HELP + + # Sanity: each of the 8 tabs has its own help text + for sid in ("sessions", "perms", "context", "memory", "hooks", + "schedule", "audit", "dream"): + assert sid in HELP + assert len(HELP[sid]) > 100 + + +@pytest.mark.asyncio +async def test_welcome_modal_skipped_when_marker_present(mock_claude_home): + # mock_claude_home pre-touches the marker; no welcome should appear. + from cc_janitor.tui._help import WelcomeModal + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + assert not isinstance(app.screen, WelcomeModal) + + +@pytest.mark.asyncio +async def test_welcome_modal_appears_when_marker_absent(tmp_path, monkeypatch): + monkeypatch.setenv("CC_JANITOR_HOME", str(tmp_path / "ccj")) + # Marker is NOT pre-touched here. + from cc_janitor.tui._help import WelcomeModal, has_seen_welcome + from cc_janitor.tui.app import CcJanitorApp + + assert not has_seen_welcome() + app = CcJanitorApp() + async with app.run_test() as pilot: + await pilot.pause() + assert isinstance(app.screen, WelcomeModal) + # Dismiss it + await pilot.press("enter") + await pilot.pause() + # Marker should now exist + assert has_seen_welcome() + + +@pytest.mark.asyncio +async def test_welcome_modal_force_via_show_welcome_flag(mock_claude_home): + # Marker is present (via fixture) but show_welcome=True forces the modal. + from cc_janitor.tui._help import WelcomeModal + from cc_janitor.tui.app import CcJanitorApp + + app = CcJanitorApp(show_welcome=True) + async with app.run_test() as pilot: + await pilot.pause() + assert isinstance(app.screen, WelcomeModal) From 36909c6540009c7c797e6a6594ec9fadde03bece Mon Sep 17 00:00:00 2001 From: Creatman Date: Thu, 21 May 2026 06:36:49 -0400 Subject: [PATCH 6/7] docs: README tab-by-tab tour + cookbook first-time-user recipe Documents the new TUI surface introduced in 0.5.0: - F1 tab-specific help modal - First-run Welcome modal + --tutorial flag - 1-line header on every screen - Per-tab keybindings and column meanings Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 25 +++++++++++++++++++++++++ docs/cookbook.md | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4d64133..8b057b4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/cookbook.md b/docs/cookbook.md index 3912867..559eb81 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -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 From edac4df948af6e1ec3002d76e881f70ece3d6ebf Mon Sep 17 00:00:00 2001 From: Creatman Date: Thu, 21 May 2026 06:42:05 -0400 Subject: [PATCH 7/7] chore: bump to 0.5.0 + CHANGELOG Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 52 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/cc_janitor/cli/__init__.py | 2 +- tests/unit/test_cli_skeleton.py | 2 +- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61560b4..9f47ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 49b6f27..84e3e9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/cc_janitor/cli/__init__.py b/src/cc_janitor/cli/__init__.py index b336f78..dc01c0a 100644 --- a/src/cc_janitor/cli/__init__.py +++ b/src/cc_janitor/cli/__init__.py @@ -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") diff --git a/tests/unit/test_cli_skeleton.py b/tests/unit/test_cli_skeleton.py index cee840b..a4ae05d 100644 --- a/tests/unit/test_cli_skeleton.py +++ b/tests/unit/test_cli_skeleton.py @@ -6,7 +6,7 @@ def test_version(): r = CliRunner().invoke(app, ["--version"]) assert r.exit_code == 0 - assert "0.4.2" in r.stdout + assert "0.5.0" in r.stdout def test_help_works():