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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +257 -123
- ripperdoc/cli/commands/__init__.py +2 -1
- ripperdoc/cli/commands/agents_cmd.py +138 -8
- ripperdoc/cli/commands/clear_cmd.py +9 -4
- ripperdoc/cli/commands/config_cmd.py +1 -1
- ripperdoc/cli/commands/context_cmd.py +3 -2
- ripperdoc/cli/commands/doctor_cmd.py +18 -4
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/hooks_cmd.py +27 -53
- ripperdoc/cli/commands/models_cmd.py +27 -10
- ripperdoc/cli/commands/permissions_cmd.py +27 -9
- ripperdoc/cli/commands/resume_cmd.py +9 -3
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/commands/status_cmd.py +4 -4
- ripperdoc/cli/commands/tasks_cmd.py +8 -4
- ripperdoc/cli/ui/file_mention_completer.py +2 -1
- ripperdoc/cli/ui/interrupt_handler.py +2 -3
- ripperdoc/cli/ui/message_display.py +4 -2
- ripperdoc/cli/ui/panels.py +1 -0
- ripperdoc/cli/ui/provider_options.py +247 -0
- ripperdoc/cli/ui/rich_ui.py +403 -81
- ripperdoc/cli/ui/spinner.py +54 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/tool_renderers.py +8 -2
- ripperdoc/cli/ui/wizard.py +213 -0
- ripperdoc/core/agents.py +19 -6
- ripperdoc/core/config.py +51 -17
- ripperdoc/core/custom_commands.py +7 -6
- ripperdoc/core/default_tools.py +101 -12
- ripperdoc/core/hooks/config.py +1 -3
- ripperdoc/core/hooks/events.py +27 -28
- ripperdoc/core/hooks/executor.py +4 -6
- ripperdoc/core/hooks/integration.py +12 -21
- ripperdoc/core/hooks/llm_callback.py +59 -0
- ripperdoc/core/hooks/manager.py +40 -15
- ripperdoc/core/permissions.py +118 -12
- ripperdoc/core/providers/anthropic.py +109 -36
- ripperdoc/core/providers/gemini.py +70 -5
- ripperdoc/core/providers/openai.py +89 -24
- ripperdoc/core/query.py +273 -68
- ripperdoc/core/query_utils.py +2 -0
- ripperdoc/core/skills.py +9 -3
- ripperdoc/core/system_prompt.py +4 -2
- ripperdoc/core/tool.py +17 -8
- ripperdoc/sdk/client.py +79 -4
- ripperdoc/tools/ask_user_question_tool.py +5 -3
- ripperdoc/tools/background_shell.py +307 -135
- ripperdoc/tools/bash_output_tool.py +1 -1
- ripperdoc/tools/bash_tool.py +63 -24
- ripperdoc/tools/dynamic_mcp_tool.py +29 -8
- ripperdoc/tools/enter_plan_mode_tool.py +1 -1
- ripperdoc/tools/exit_plan_mode_tool.py +1 -1
- ripperdoc/tools/file_edit_tool.py +167 -54
- ripperdoc/tools/file_read_tool.py +28 -4
- ripperdoc/tools/file_write_tool.py +13 -10
- ripperdoc/tools/glob_tool.py +3 -2
- ripperdoc/tools/grep_tool.py +3 -2
- ripperdoc/tools/kill_bash_tool.py +1 -1
- ripperdoc/tools/ls_tool.py +1 -1
- ripperdoc/tools/lsp_tool.py +615 -0
- ripperdoc/tools/mcp_tools.py +13 -10
- ripperdoc/tools/multi_edit_tool.py +8 -7
- ripperdoc/tools/notebook_edit_tool.py +7 -4
- ripperdoc/tools/skill_tool.py +1 -1
- ripperdoc/tools/task_tool.py +519 -69
- ripperdoc/tools/todo_tool.py +2 -2
- ripperdoc/tools/tool_search_tool.py +3 -2
- ripperdoc/utils/conversation_compaction.py +9 -5
- ripperdoc/utils/file_watch.py +214 -5
- ripperdoc/utils/json_utils.py +2 -1
- ripperdoc/utils/lsp.py +806 -0
- ripperdoc/utils/mcp.py +11 -3
- ripperdoc/utils/memory.py +4 -2
- ripperdoc/utils/message_compaction.py +21 -7
- ripperdoc/utils/message_formatting.py +14 -7
- ripperdoc/utils/messages.py +126 -67
- ripperdoc/utils/path_ignore.py +35 -8
- ripperdoc/utils/permissions/path_validation_utils.py +2 -1
- ripperdoc/utils/permissions/shell_command_validation.py +427 -91
- ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
- ripperdoc/utils/safe_get_cwd.py +2 -1
- ripperdoc/utils/session_heatmap.py +244 -0
- ripperdoc/utils/session_history.py +13 -6
- ripperdoc/utils/session_stats.py +293 -0
- ripperdoc/utils/todo.py +2 -1
- ripperdoc/utils/token_estimation.py +6 -1
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
- ripperdoc-0.2.10.dist-info/RECORD +129 -0
- ripperdoc-0.2.8.dist-info/RECORD +0 -121
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
ripperdoc/utils/safe_get_cwd.py
CHANGED
|
@@ -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__,
|
|
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__,
|
|
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__,
|
|
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__,
|
|
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,
|
|
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__,
|
|
265
|
+
type(exc).__name__,
|
|
266
|
+
exc,
|
|
260
267
|
extra={"session_id": session_id, "path": str(path)},
|
|
261
268
|
)
|
|
262
269
|
return []
|