ripperdoc 0.2.8__py3-none-any.whl → 0.2.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +257 -123
  3. ripperdoc/cli/commands/__init__.py +2 -1
  4. ripperdoc/cli/commands/agents_cmd.py +138 -8
  5. ripperdoc/cli/commands/clear_cmd.py +9 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/exit_cmd.py +1 -0
  10. ripperdoc/cli/commands/hooks_cmd.py +27 -53
  11. ripperdoc/cli/commands/models_cmd.py +27 -10
  12. ripperdoc/cli/commands/permissions_cmd.py +27 -9
  13. ripperdoc/cli/commands/resume_cmd.py +9 -3
  14. ripperdoc/cli/commands/stats_cmd.py +244 -0
  15. ripperdoc/cli/commands/status_cmd.py +4 -4
  16. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  17. ripperdoc/cli/ui/file_mention_completer.py +2 -1
  18. ripperdoc/cli/ui/interrupt_handler.py +2 -3
  19. ripperdoc/cli/ui/message_display.py +4 -2
  20. ripperdoc/cli/ui/panels.py +1 -0
  21. ripperdoc/cli/ui/provider_options.py +247 -0
  22. ripperdoc/cli/ui/rich_ui.py +403 -81
  23. ripperdoc/cli/ui/spinner.py +54 -18
  24. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  25. ripperdoc/cli/ui/tool_renderers.py +8 -2
  26. ripperdoc/cli/ui/wizard.py +213 -0
  27. ripperdoc/core/agents.py +19 -6
  28. ripperdoc/core/config.py +51 -17
  29. ripperdoc/core/custom_commands.py +7 -6
  30. ripperdoc/core/default_tools.py +101 -12
  31. ripperdoc/core/hooks/config.py +1 -3
  32. ripperdoc/core/hooks/events.py +27 -28
  33. ripperdoc/core/hooks/executor.py +4 -6
  34. ripperdoc/core/hooks/integration.py +12 -21
  35. ripperdoc/core/hooks/llm_callback.py +59 -0
  36. ripperdoc/core/hooks/manager.py +40 -15
  37. ripperdoc/core/permissions.py +118 -12
  38. ripperdoc/core/providers/anthropic.py +109 -36
  39. ripperdoc/core/providers/gemini.py +70 -5
  40. ripperdoc/core/providers/openai.py +89 -24
  41. ripperdoc/core/query.py +273 -68
  42. ripperdoc/core/query_utils.py +2 -0
  43. ripperdoc/core/skills.py +9 -3
  44. ripperdoc/core/system_prompt.py +4 -2
  45. ripperdoc/core/tool.py +17 -8
  46. ripperdoc/sdk/client.py +79 -4
  47. ripperdoc/tools/ask_user_question_tool.py +5 -3
  48. ripperdoc/tools/background_shell.py +307 -135
  49. ripperdoc/tools/bash_output_tool.py +1 -1
  50. ripperdoc/tools/bash_tool.py +63 -24
  51. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  52. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  53. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  54. ripperdoc/tools/file_edit_tool.py +167 -54
  55. ripperdoc/tools/file_read_tool.py +28 -4
  56. ripperdoc/tools/file_write_tool.py +13 -10
  57. ripperdoc/tools/glob_tool.py +3 -2
  58. ripperdoc/tools/grep_tool.py +3 -2
  59. ripperdoc/tools/kill_bash_tool.py +1 -1
  60. ripperdoc/tools/ls_tool.py +1 -1
  61. ripperdoc/tools/lsp_tool.py +615 -0
  62. ripperdoc/tools/mcp_tools.py +13 -10
  63. ripperdoc/tools/multi_edit_tool.py +8 -7
  64. ripperdoc/tools/notebook_edit_tool.py +7 -4
  65. ripperdoc/tools/skill_tool.py +1 -1
  66. ripperdoc/tools/task_tool.py +519 -69
  67. ripperdoc/tools/todo_tool.py +2 -2
  68. ripperdoc/tools/tool_search_tool.py +3 -2
  69. ripperdoc/utils/conversation_compaction.py +9 -5
  70. ripperdoc/utils/file_watch.py +214 -5
  71. ripperdoc/utils/json_utils.py +2 -1
  72. ripperdoc/utils/lsp.py +806 -0
  73. ripperdoc/utils/mcp.py +11 -3
  74. ripperdoc/utils/memory.py +4 -2
  75. ripperdoc/utils/message_compaction.py +21 -7
  76. ripperdoc/utils/message_formatting.py +14 -7
  77. ripperdoc/utils/messages.py +126 -67
  78. ripperdoc/utils/path_ignore.py +35 -8
  79. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  80. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  81. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  82. ripperdoc/utils/safe_get_cwd.py +2 -1
  83. ripperdoc/utils/session_heatmap.py +244 -0
  84. ripperdoc/utils/session_history.py +13 -6
  85. ripperdoc/utils/session_stats.py +293 -0
  86. ripperdoc/utils/todo.py +2 -1
  87. ripperdoc/utils/token_estimation.py +6 -1
  88. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  89. ripperdoc-0.2.10.dist-info/RECORD +129 -0
  90. ripperdoc-0.2.8.dist-info/RECORD +0 -121
  91. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  92. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  93. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  94. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import fnmatch
5
6
  from dataclasses import dataclass
6
7
  from typing import Callable, Iterable, List, Optional, Set
7
8
 
@@ -27,8 +28,25 @@ class PermissionDecision:
27
28
  rule_suggestions: Optional[List[ToolRule] | List[str]] = None
28
29
 
29
30
 
30
- def create_wildcard_rule(rule_name: str) -> str:
31
- """Create a wildcard/prefix rule string."""
31
+ def create_wildcard_rule(rule_name: str, use_glob_style: bool = False) -> str:
32
+ """Create a wildcard/prefix rule string.
33
+
34
+ Args:
35
+ rule_name: The command prefix (e.g., "git", "npm")
36
+ use_glob_style: If True, creates "rule_name *" format;
37
+ if False, creates "rule_name:*" format (default for compatibility)
38
+
39
+ Returns:
40
+ Wildcard rule string in requested format
41
+
42
+ Examples:
43
+ >>> create_wildcard_rule("git")
44
+ "git:*"
45
+ >>> create_wildcard_rule("git", use_glob_style=True)
46
+ "git *"
47
+ """
48
+ if use_glob_style:
49
+ return f"{rule_name} *"
32
50
  return f"{rule_name}:*"
33
51
 
34
52
 
@@ -36,22 +54,122 @@ def create_tool_rule(rule_content: str) -> List[ToolRule]:
36
54
  return [ToolRule(tool_name="Bash", rule_content=rule_content)]
37
55
 
38
56
 
39
- def create_wildcard_tool_rule(rule_name: str) -> List[ToolRule]:
40
- return [ToolRule(tool_name="Bash", rule_content=create_wildcard_rule(rule_name))]
57
+ def create_wildcard_tool_rule(rule_name: str, use_glob_style: bool = False) -> List[ToolRule]:
58
+ """Create a wildcard tool rule.
59
+
60
+ Args:
61
+ rule_name: The command prefix
62
+ use_glob_style: Whether to use glob-style format
63
+
64
+ Returns:
65
+ List containing a single ToolRule with wildcard pattern
66
+ """
67
+ return [ToolRule(tool_name="Bash", rule_content=create_wildcard_rule(rule_name, use_glob_style))]
41
68
 
42
69
 
43
70
  def extract_rule_prefix(rule_string: str) -> Optional[str]:
44
71
  return rule_string[:-2] if rule_string.endswith(":*") else None
45
72
 
46
73
 
74
+ def _has_unquoted_shell_operators(command: str) -> bool:
75
+ """Check if command contains shell operators outside of quotes.
76
+
77
+ This prevents wildcard rules from matching commands with shell operators
78
+ like &&, ||, ;, | which could be used to chain dangerous commands.
79
+
80
+ Args:
81
+ command: The command to check
82
+
83
+ Returns:
84
+ True if command contains unquoted shell operators, False otherwise
85
+
86
+ Examples:
87
+ >>> _has_unquoted_shell_operators("git status && rm -rf /")
88
+ True
89
+ >>> _has_unquoted_shell_operators("echo 'foo && bar'")
90
+ False
91
+ >>> _has_unquoted_shell_operators("ls | grep foo")
92
+ True
93
+ >>> _has_unquoted_shell_operators("echo hi; rm -rf /")
94
+ True
95
+ """
96
+ # Parse the command into tokens, which handles quotes correctly
97
+ tokens = parse_shell_tokens(command)
98
+
99
+ # Check for shell operators in the tokens
100
+ # Note: parse_shell_tokens may attach semicolon to adjacent tokens
101
+ # (e.g., "echo hi; rm" -> ["echo", "hi;", "rm"] or ";echo" -> [";echo"])
102
+ for token in tokens:
103
+ # Check for separate operator tokens
104
+ if token in {"&&", "||", "|"}:
105
+ return True
106
+ # Check for semicolon (may be attached to previous or next token)
107
+ if token.endswith(";") or token.startswith(";"):
108
+ return True
109
+
110
+ return False
111
+
112
+
47
113
  def match_rule(command: str, rule: str) -> bool:
48
- """Return True if a command matches a rule (exact or wildcard)."""
114
+ """Return True if a command matches a rule (exact, prefix wildcard, or glob pattern).
115
+
116
+ Supports three formats:
117
+ - Exact match: "git status" matches "git status" only
118
+ - Legacy prefix: "git:*" matches any command starting with "git"
119
+ - Glob patterns: "git * main" matches "git push main", "git pull main", etc.
120
+
121
+ Glob patterns support:
122
+ - * (matches any characters)
123
+ - ? (matches single character)
124
+ - [seq] (matches any character in seq)
125
+ - [!seq] (matches any character NOT in seq)
126
+
127
+ Security: Wildcard rules (both legacy and glob) will NOT match commands
128
+ containing shell operators (&&, ||, ;, |) outside of quotes. This prevents
129
+ a rule like "git:*" from matching "git status && rm -rf /". Exact match
130
+ rules still work with operators (user explicitly approved the full command).
131
+
132
+ Args:
133
+ command: The command to check
134
+ rule: The rule pattern to match against
135
+
136
+ Returns:
137
+ True if command matches rule, False otherwise
138
+
139
+ Examples:
140
+ >>> match_rule("git status", "git:*")
141
+ True
142
+ >>> match_rule("npm install", "npm *")
143
+ True
144
+ >>> match_rule("pip install", "* install")
145
+ True
146
+ >>> match_rule("git push main", "git * main")
147
+ True
148
+ >>> match_rule("git status && rm -rf /", "git:*")
149
+ False
150
+ >>> match_rule("git status && git commit", "git status && git commit")
151
+ True
152
+ """
49
153
  command = command.strip()
50
154
  if not command:
51
155
  return False
156
+
157
+ # Legacy format: prefix:* (highest priority for backward compatibility)
52
158
  prefix = extract_rule_prefix(rule)
53
159
  if prefix is not None:
160
+ # For wildcard rules, reject commands with shell operators for security
161
+ if _has_unquoted_shell_operators(command):
162
+ return False
54
163
  return command.startswith(prefix)
164
+
165
+ # New format: glob-style patterns with wildcards
166
+ if "*" in rule or "?" in rule or "[" in rule:
167
+ # For wildcard rules, reject commands with shell operators for security
168
+ if _has_unquoted_shell_operators(command):
169
+ return False
170
+ return fnmatch.fnmatch(command, rule)
171
+
172
+ # Exact match - allow even with operators (user explicitly approved full command)
55
173
  return command == rule
56
174
 
57
175
 
@@ -131,10 +249,37 @@ def _is_command_read_only(
131
249
 
132
250
 
133
251
  def _collect_rule_suggestions(command: str) -> List[ToolRule]:
134
- suggestions: list[ToolRule] = [ToolRule(tool_name="Bash", rule_content=command)]
252
+ """Collect rule suggestions for a command.
253
+
254
+ Suggests three options:
255
+ 1. Exact command match
256
+ 2. Legacy prefix wildcard (git:*)
257
+ 3. Glob-style wildcard (git *)
258
+
259
+ This gives users choice between formats while maintaining backward compatibility.
260
+
261
+ Args:
262
+ command: The command to generate suggestions for
263
+
264
+ Returns:
265
+ List of suggested ToolRule objects
266
+ """
267
+ suggestions: list[ToolRule] = [
268
+ # Exact match
269
+ ToolRule(tool_name="Bash", rule_content=command)
270
+ ]
271
+
135
272
  tokens = parse_and_clean_shell_tokens(command)
136
273
  if tokens:
137
- suggestions.append(ToolRule(tool_name="Bash", rule_content=create_wildcard_rule(tokens[0])))
274
+ # Legacy prefix format
275
+ suggestions.append(
276
+ ToolRule(tool_name="Bash", rule_content=create_wildcard_rule(tokens[0], use_glob_style=False))
277
+ )
278
+ # New glob-style format
279
+ suggestions.append(
280
+ ToolRule(tool_name="Bash", rule_content=create_wildcard_rule(tokens[0], use_glob_style=True))
281
+ )
282
+
138
283
  return suggestions
139
284
 
140
285
 
@@ -144,19 +289,34 @@ def evaluate_shell_command_permissions(
144
289
  denied_rules: Iterable[str],
145
290
  allowed_working_dirs: Set[str] | None = None,
146
291
  *,
147
- command_injection_detected: bool = False,
148
- injection_detector: Callable[[str], bool] | None = None,
292
+ danger_detector: Callable[[str], bool] | None = None,
149
293
  read_only_detector: Callable[[str, Callable[[str], bool]], bool] | None = None,
150
294
  ) -> PermissionDecision:
151
- """Evaluate whether a bash command should be allowed."""
295
+ """Evaluate whether a bash command should be allowed.
296
+
297
+ Args:
298
+ tool_request: The tool request containing the command.
299
+ allowed_rules: Rules that allow commands.
300
+ denied_rules: Rules that deny commands.
301
+ allowed_working_dirs: Allowed working directories.
302
+ danger_detector: Callable that returns True if command contains dangerous
303
+ shell patterns (injection, destructive commands, etc.).
304
+ read_only_detector: Callable that returns True if command is read-only.
305
+
306
+ Returns:
307
+ PermissionDecision indicating whether the command should be allowed.
308
+ """
152
309
  command = tool_request.command if hasattr(tool_request, "command") else str(tool_request)
153
310
  trimmed_command = command.strip()
154
311
  allowed_working_dirs = allowed_working_dirs or {safe_get_cwd()}
155
- injection_detector = injection_detector or (
312
+ danger_detector = danger_detector or (
156
313
  lambda cmd: validate_shell_command(cmd).behavior != "passthrough"
157
314
  )
158
315
  read_only_detector = read_only_detector or _is_command_read_only
159
316
 
317
+ # Detect dangerous patterns once, reuse the result
318
+ has_dangerous_patterns = danger_detector(trimmed_command)
319
+
160
320
  merged_denied = _merge_rules(denied_rules)
161
321
  merged_allowed = _merge_rules(allowed_rules)
162
322
 
@@ -217,11 +377,10 @@ def evaluate_shell_command_permissions(
217
377
  merged_allowed,
218
378
  merged_denied,
219
379
  allowed_working_dirs,
220
- command_injection_detected=command_injection_detected,
221
- injection_detector=injection_detector,
380
+ danger_detector=danger_detector,
222
381
  read_only_detector=read_only_detector,
223
382
  )
224
- right_read_only = read_only_detector(right_command, injection_detector)
383
+ right_read_only = read_only_detector(right_command, danger_detector)
225
384
 
226
385
  if left_result.behavior == "deny":
227
386
  return left_result
@@ -245,7 +404,7 @@ def evaluate_shell_command_permissions(
245
404
  rule_suggestions=_collect_rule_suggestions(trimmed_command),
246
405
  )
247
406
 
248
- if read_only_detector(trimmed_command, injection_detector) and not command_injection_detected:
407
+ if read_only_detector(trimmed_command, danger_detector) and not has_dangerous_patterns:
249
408
  return PermissionDecision(
250
409
  behavior="allow",
251
410
  updated_input=tool_request,
@@ -23,7 +23,8 @@ def safe_get_cwd() -> str:
23
23
  except (OSError, RuntimeError, ValueError) as exc:
24
24
  logger.warning(
25
25
  "[safe_get_cwd] Failed to resolve cwd: %s: %s",
26
- type(exc).__name__, exc,
26
+ type(exc).__name__,
27
+ exc,
27
28
  )
28
29
  return get_original_cwd()
29
30
 
@@ -0,0 +1,244 @@
1
+ """Activity heatmap visualization for session statistics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+ from typing import Dict
7
+
8
+ from rich.console import Console
9
+ from rich.text import Text
10
+
11
+
12
+ def _get_intensity_char(count: int, max_count: int) -> str:
13
+ """Get unicode block character based on activity intensity.
14
+
15
+ Returns character and color code for 8 intensity levels.
16
+ """
17
+ if count == 0:
18
+ return "·"
19
+ if max_count == 0:
20
+ return "█"
21
+
22
+ # Calculate intensity (0-1)
23
+ intensity = count / max_count
24
+
25
+ # Use unicode block characters for different intensities (8 levels)
26
+ if intensity <= 0.125:
27
+ return "░"
28
+ elif intensity <= 0.25:
29
+ return "░"
30
+ elif intensity <= 0.375:
31
+ return "▒"
32
+ elif intensity <= 0.5:
33
+ return "▒"
34
+ elif intensity <= 0.625:
35
+ return "▓"
36
+ elif intensity <= 0.75:
37
+ return "▓"
38
+ elif intensity <= 0.875:
39
+ return "█"
40
+ else:
41
+ return "█"
42
+
43
+
44
+ def _get_intensity_color(count: int, max_count: int) -> str:
45
+ """Get color style based on activity intensity (8 levels)."""
46
+ if count == 0:
47
+ return "dim white"
48
+ if max_count == 0:
49
+ return "bold color(46)"
50
+
51
+ # Calculate intensity (0-1)
52
+ intensity = count / max_count
53
+
54
+ # 8-level green gradient from very light to very dark
55
+ if intensity <= 0.125:
56
+ return "color(22)" # Very light green
57
+ elif intensity <= 0.25:
58
+ return "color(28)" # Light green
59
+ elif intensity <= 0.375:
60
+ return "color(34)" # Light-medium green
61
+ elif intensity <= 0.5:
62
+ return "color(40)" # Medium green
63
+ elif intensity <= 0.625:
64
+ return "color(46)" # Medium-dark green
65
+ elif intensity <= 0.75:
66
+ return "color(82)" # Dark green
67
+ elif intensity <= 0.875:
68
+ return "bold color(46)" # Bright green with bold
69
+ else:
70
+ return "bold color(82)" # Very dark green with bold
71
+
72
+
73
+ def _get_week_grid(
74
+ daily_activity: Dict[str, int], weeks_count: int = 52
75
+ ) -> tuple[list[list[tuple[str, int]]], int]:
76
+ """Build a grid of weeks for heatmap display.
77
+
78
+ Args:
79
+ daily_activity: Dictionary mapping date strings to activity counts
80
+ weeks_count: Number of weeks to display (default 52 for full year)
81
+
82
+ Returns:
83
+ Tuple of (grid, max_count) where grid is a list of weeks,
84
+ each week is a list of (date_str, count) tuples.
85
+ """
86
+ # Calculate total days to display
87
+ total_days = weeks_count * 7
88
+
89
+ # Start from today and go back
90
+ end_date = datetime.now().date()
91
+ start_date = end_date - timedelta(days=total_days - 1)
92
+
93
+ # Find max count for intensity calculation
94
+ max_count = max(daily_activity.values()) if daily_activity else 1
95
+
96
+ # Build grid by weeks (Sunday to Saturday)
97
+ weeks: list[list[tuple[str, int]]] = []
98
+ current_week: list[tuple[str, int]] = []
99
+
100
+ current_date = start_date
101
+ # Pad the start to align with week start (Sunday = 6 in Python's weekday, we want 0)
102
+ weekday = (current_date.weekday() + 1) % 7 # Convert to Sunday=0
103
+ if weekday > 0:
104
+ # Add empty days at the start
105
+ for _ in range(weekday):
106
+ current_week.append(("", 0))
107
+
108
+ while current_date <= end_date:
109
+ date_str = current_date.isoformat()
110
+ count = daily_activity.get(date_str, 0)
111
+ current_week.append((date_str, count))
112
+
113
+ # If we've completed a week (7 days), start a new week
114
+ if len(current_week) == 7:
115
+ weeks.append(current_week)
116
+ current_week = []
117
+
118
+ current_date += timedelta(days=1)
119
+
120
+ # Add remaining days
121
+ if current_week:
122
+ # Pad to complete the week
123
+ while len(current_week) < 7:
124
+ current_week.append(("", 0))
125
+ weeks.append(current_week)
126
+
127
+ return weeks, max_count
128
+
129
+
130
+ def render_heatmap(
131
+ console: Console, daily_activity: Dict[str, int], weeks_count: int = 52
132
+ ) -> None:
133
+ """Render activity heatmap to console.
134
+
135
+ Args:
136
+ console: Rich console for output
137
+ daily_activity: Dictionary mapping date strings to activity counts
138
+ weeks_count: Number of weeks to display (default 52 for full year)
139
+ """
140
+ # Alignment constant: width of weekday labels column
141
+ WEEKDAY_LABEL_WIDTH = 8
142
+
143
+ weeks, max_count = _get_week_grid(daily_activity, weeks_count)
144
+ if not weeks:
145
+ console.print("[dim]No activity data[/dim]")
146
+ return
147
+
148
+ # Build month labels row
149
+ # Each week column is exactly 1 character wide in the heatmap
150
+ # Month labels are 3 characters wide (e.g., "Dec", "Jan", "Feb")
151
+ month_positions = [] # List of (week_idx, month_name) tuples
152
+ current_month = None
153
+
154
+ for week_idx, week in enumerate(weeks):
155
+ # Check first non-empty day in week for month
156
+ for date_str, _ in week:
157
+ if date_str:
158
+ date = datetime.fromisoformat(date_str).date()
159
+ month_str = date.strftime("%b")
160
+
161
+ # Record position when month changes
162
+ if month_str != current_month:
163
+ month_positions.append((week_idx, month_str))
164
+ current_month = month_str
165
+ break
166
+
167
+ # Build month label string with precise alignment
168
+ month_chars = [" "] * len(weeks)
169
+
170
+ for week_idx, month_name in month_positions:
171
+ # Check if we have space for the full month name (3 chars)
172
+ can_place = True
173
+ for i in range(3):
174
+ if week_idx + i < len(month_chars) and month_chars[week_idx + i] != " ":
175
+ can_place = False
176
+ break
177
+
178
+ if can_place and week_idx < len(month_chars):
179
+ # Place the month name starting at this week position
180
+ for i, char in enumerate(month_name):
181
+ if week_idx + i < len(month_chars):
182
+ month_chars[week_idx + i] = char
183
+
184
+ # Print month labels with proper alignment
185
+ month_row = " " * WEEKDAY_LABEL_WIDTH + "".join(month_chars)
186
+ console.print(month_row)
187
+
188
+ # Weekday labels - show Mon, Wed, Fri
189
+ weekday_labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
190
+ show_weekdays = [1, 3, 5] # Show Mon, Wed, Fri
191
+
192
+ # Render each row (weekday)
193
+ for weekday_idx in range(7):
194
+ row = Text()
195
+
196
+ # Add weekday label - exactly WEEKDAY_LABEL_WIDTH characters
197
+ if weekday_idx in show_weekdays:
198
+ label = weekday_labels[weekday_idx]
199
+ # Right-align the label within the width, then add a space
200
+ row.append(f"{label:>3} ", style="dim") # "Mon " = 4 chars
201
+ row.append(" " * (WEEKDAY_LABEL_WIDTH - 4)) # Fill remaining space
202
+ else:
203
+ row.append(" " * WEEKDAY_LABEL_WIDTH)
204
+
205
+ # Add activity cells for this weekday across all weeks
206
+ # Each week column is exactly 1 character wide
207
+ for week in weeks:
208
+ if weekday_idx < len(week):
209
+ date_str, count = week[weekday_idx]
210
+ if date_str:
211
+ char = _get_intensity_char(count, max_count)
212
+ color = _get_intensity_color(count, max_count)
213
+ row.append(char, style=color)
214
+ else:
215
+ row.append("·", style="dim white")
216
+ else:
217
+ row.append(" ")
218
+
219
+ console.print(row)
220
+
221
+ # Legend with 8-level green gradient
222
+ console.print()
223
+ legend = Text(" " * WEEKDAY_LABEL_WIDTH + "Less ", style="dim")
224
+ # Show representative levels from the 8-level gradient
225
+ legend.append("░", style="color(22)") # Level 1: Very light green
226
+ legend.append(" ", style="dim")
227
+ legend.append("░", style="color(28)") # Level 2: Light green
228
+ legend.append(" ", style="dim")
229
+ legend.append("▒", style="color(34)") # Level 3: Light-medium green
230
+ legend.append(" ", style="dim")
231
+ legend.append("▒", style="color(40)") # Level 4: Medium green
232
+ legend.append(" ", style="dim")
233
+ legend.append("▓", style="color(46)") # Level 5: Medium-dark green
234
+ legend.append(" ", style="dim")
235
+ legend.append("▓", style="color(82)") # Level 6: Dark green
236
+ legend.append(" ", style="dim")
237
+ legend.append("█", style="bold color(46)") # Level 7: Bright green
238
+ legend.append(" ", style="dim")
239
+ legend.append("█", style="bold color(82)") # Level 8: Very dark green
240
+ legend.append(" More", style="dim")
241
+ console.print(legend)
242
+
243
+
244
+ __all__ = ["render_heatmap"]
@@ -104,13 +104,15 @@ class SessionHistory:
104
104
  except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
105
105
  logger.debug(
106
106
  "[session_history] Failed to parse session history line: %s: %s",
107
- type(exc).__name__, exc,
107
+ type(exc).__name__,
108
+ exc,
108
109
  )
109
110
  continue
110
111
  except (OSError, IOError) as exc:
111
112
  logger.warning(
112
113
  "Failed to load seen IDs from session: %s: %s",
113
- type(exc).__name__, exc,
114
+ type(exc).__name__,
115
+ exc,
114
116
  extra={"session_id": self.session_id, "path": str(self.path)},
115
117
  )
116
118
  return
@@ -142,7 +144,8 @@ class SessionHistory:
142
144
  # Avoid crashing the UI if logging fails
143
145
  logger.warning(
144
146
  "Failed to append message to session log: %s: %s",
145
- type(exc).__name__, exc,
147
+ type(exc).__name__,
148
+ exc,
146
149
  extra={"session_id": self.session_id, "path": str(self.path)},
147
150
  )
148
151
  return
@@ -163,7 +166,8 @@ def list_session_summaries(project_path: Path) -> List[SessionSummary]:
163
166
  except (OSError, IOError, json.JSONDecodeError) as exc:
164
167
  logger.warning(
165
168
  "Failed to load session summary: %s: %s",
166
- type(exc).__name__, exc,
169
+ type(exc).__name__,
170
+ exc,
167
171
  extra={"path": str(jsonl_path)},
168
172
  )
169
173
  continue
@@ -250,13 +254,16 @@ def load_session_messages(project_path: Path, session_id: str) -> List[Conversat
250
254
  except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
251
255
  logger.debug(
252
256
  "[session_history] Failed to deserialize message in session %s: %s: %s",
253
- session_id, type(exc).__name__, exc,
257
+ session_id,
258
+ type(exc).__name__,
259
+ exc,
254
260
  )
255
261
  continue
256
262
  except (OSError, IOError) as exc:
257
263
  logger.warning(
258
264
  "Failed to load session messages: %s: %s",
259
- type(exc).__name__, exc,
265
+ type(exc).__name__,
266
+ exc,
260
267
  extra={"session_id": session_id, "path": str(path)},
261
268
  )
262
269
  return []