code-puppy 0.0.325__py3-none-any.whl → 0.0.336__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 (44) hide show
  1. code_puppy/agents/base_agent.py +41 -103
  2. code_puppy/cli_runner.py +105 -2
  3. code_puppy/command_line/add_model_menu.py +4 -0
  4. code_puppy/command_line/autosave_menu.py +5 -0
  5. code_puppy/command_line/colors_menu.py +5 -0
  6. code_puppy/command_line/config_commands.py +24 -1
  7. code_puppy/command_line/core_commands.py +51 -0
  8. code_puppy/command_line/diff_menu.py +5 -0
  9. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  10. code_puppy/command_line/mcp/install_menu.py +5 -1
  11. code_puppy/command_line/model_settings_menu.py +5 -0
  12. code_puppy/command_line/motd.py +13 -7
  13. code_puppy/command_line/onboarding_slides.py +180 -0
  14. code_puppy/command_line/onboarding_wizard.py +340 -0
  15. code_puppy/config.py +3 -2
  16. code_puppy/http_utils.py +155 -196
  17. code_puppy/keymap.py +10 -8
  18. code_puppy/messaging/rich_renderer.py +101 -19
  19. code_puppy/model_factory.py +86 -15
  20. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  21. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  22. code_puppy/plugins/antigravity_oauth/antigravity_model.py +653 -0
  23. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  24. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  25. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  26. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  27. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  28. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  29. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  30. code_puppy/plugins/antigravity_oauth/transport.py +664 -0
  31. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  32. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  33. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  34. code_puppy/reopenable_async_client.py +8 -8
  35. code_puppy/terminal_utils.py +168 -3
  36. code_puppy/tools/command_runner.py +42 -54
  37. code_puppy/uvx_detection.py +242 -0
  38. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/METADATA +30 -1
  39. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/RECORD +44 -29
  40. {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models.json +0 -0
  41. {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models_dev_api.json +0 -0
  42. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/WHEEL +0 -0
  43. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/entry_points.txt +0 -0
  44. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/licenses/LICENSE +0 -0
code_puppy/http_utils.py CHANGED
@@ -4,29 +4,19 @@ HTTP utilities module for code-puppy.
4
4
  This module provides functions for creating properly configured HTTP clients.
5
5
  """
6
6
 
7
+ import asyncio
8
+ import logging
7
9
  import os
8
10
  import socket
9
- from typing import Dict, Optional, Union
11
+ import time
12
+ from typing import Any, Dict, Optional, Union
10
13
 
11
14
  import httpx
12
15
  import requests
13
- from tenacity import stop_after_attempt, wait_exponential
14
16
 
15
17
  from code_puppy.config import get_http2
16
18
 
17
- try:
18
- from pydantic_ai.retries import (
19
- AsyncTenacityTransport,
20
- RetryConfig,
21
- TenacityTransport,
22
- wait_retry_after,
23
- )
24
- except ImportError:
25
- # Fallback if pydantic_ai.retries is not available
26
- AsyncTenacityTransport = None
27
- RetryConfig = None
28
- TenacityTransport = None
29
- wait_retry_after = None
19
+ logger = logging.getLogger(__name__)
30
20
 
31
21
  try:
32
22
  from .reopenable_async_client import ReopenableAsyncClient
@@ -34,12 +24,109 @@ except ImportError:
34
24
  ReopenableAsyncClient = None
35
25
 
36
26
  try:
37
- from .messaging import emit_info
27
+ from .messaging import emit_info, emit_warning
38
28
  except ImportError:
39
29
  # Fallback if messaging system is not available
40
30
  def emit_info(content: str, **metadata):
41
31
  pass # No-op if messaging system is not available
42
32
 
33
+ def emit_warning(content: str, **metadata):
34
+ pass
35
+
36
+
37
+ class RetryingAsyncClient(httpx.AsyncClient):
38
+ """AsyncClient with built-in rate limit handling (429) and retries.
39
+
40
+ This replaces the Tenacity transport with a more direct subclass implementation,
41
+ which plays nicer with proxies and custom transports (like Antigravity).
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ retry_status_codes: tuple = (429, 502, 503, 504),
47
+ max_retries: int = 5,
48
+ **kwargs,
49
+ ):
50
+ super().__init__(**kwargs)
51
+ self.retry_status_codes = retry_status_codes
52
+ self.max_retries = max_retries
53
+
54
+ async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
55
+ """Send request with automatic retries for rate limits and server errors."""
56
+ last_response = None
57
+ last_exception = None
58
+
59
+ for attempt in range(self.max_retries + 1):
60
+ try:
61
+ # Clone request for retry (streams might be consumed)
62
+ # But only if it's not the first attempt
63
+ req_to_send = request
64
+ if attempt > 0:
65
+ # httpx requests are reusable, but we need to be careful with streams
66
+ pass
67
+
68
+ response = await super().send(req_to_send, **kwargs)
69
+ last_response = response
70
+
71
+ # Check for retryable status
72
+ if response.status_code not in self.retry_status_codes:
73
+ return response
74
+
75
+ # Close response if we're going to retry
76
+ await response.aclose()
77
+
78
+ # Determine wait time
79
+ wait_time = 1.0 * (
80
+ 2**attempt
81
+ ) # Default exponential backoff: 1s, 2s, 4s...
82
+
83
+ # Check Retry-After header
84
+ retry_after = response.headers.get("Retry-After")
85
+ if retry_after:
86
+ try:
87
+ wait_time = float(retry_after)
88
+ except ValueError:
89
+ # Try parsing http-date
90
+ from email.utils import parsedate_to_datetime
91
+
92
+ try:
93
+ date = parsedate_to_datetime(retry_after)
94
+ wait_time = date.timestamp() - time.time()
95
+ except Exception:
96
+ pass
97
+
98
+ # Cap wait time
99
+ wait_time = max(0.5, min(wait_time, 60.0))
100
+
101
+ if attempt < self.max_retries:
102
+ emit_info(
103
+ f"HTTP retry: {response.status_code} received. Waiting {wait_time:.1f}s (attempt {attempt + 1}/{self.max_retries})"
104
+ )
105
+ await asyncio.sleep(wait_time)
106
+
107
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.PoolTimeout) as e:
108
+ last_exception = e
109
+ wait_time = 1.0 * (2**attempt)
110
+ if attempt < self.max_retries:
111
+ emit_warning(
112
+ f"HTTP connection error: {e}. Retrying in {wait_time}s..."
113
+ )
114
+ await asyncio.sleep(wait_time)
115
+ else:
116
+ raise
117
+ except Exception:
118
+ raise
119
+
120
+ # Return last response (even if it's an error status)
121
+ if last_response:
122
+ return last_response
123
+
124
+ # Should catch this in loop, but just in case
125
+ if last_exception:
126
+ raise last_exception
127
+
128
+ return last_response
129
+
43
130
 
44
131
  def get_cert_bundle_path() -> str:
45
132
  # First check if SSL_CERT_FILE environment variable is set
@@ -60,53 +147,15 @@ def create_client(
60
147
  # Check if HTTP/2 is enabled in config
61
148
  http2_enabled = get_http2()
62
149
 
63
- # Check if custom retry transport should be disabled (e.g., for integration tests with proxies)
64
- disable_retry_transport = os.environ.get(
65
- "CODE_PUPPY_DISABLE_RETRY_TRANSPORT", ""
66
- ).lower() in ("1", "true", "yes")
67
-
68
150
  # If retry components are available, create a client with retry transport
69
- if (
70
- TenacityTransport
71
- and RetryConfig
72
- and wait_retry_after
73
- and not disable_retry_transport
74
- ):
75
-
76
- def should_retry_status(response):
77
- """Raise exceptions for retryable HTTP status codes."""
78
- if response.status_code in retry_status_codes:
79
- emit_info(
80
- f"HTTP retry: Retrying request due to status code {response.status_code}"
81
- )
82
- return True
83
-
84
- transport = TenacityTransport(
85
- config=RetryConfig(
86
- retry=lambda e: isinstance(e, httpx.HTTPStatusError)
87
- and e.response.status_code in retry_status_codes,
88
- wait=wait_retry_after(
89
- fallback_strategy=wait_exponential(multiplier=1, max=60),
90
- max_wait=300,
91
- ),
92
- stop=stop_after_attempt(10),
93
- reraise=True,
94
- ),
95
- validate_response=should_retry_status,
96
- )
97
-
98
- return httpx.Client(
99
- transport=transport,
100
- verify=verify,
101
- headers=headers or {},
102
- timeout=timeout,
103
- http2=http2_enabled,
104
- )
105
- else:
106
- # Fallback to regular client if retry components are not available
107
- return httpx.Client(
108
- verify=verify, headers=headers or {}, timeout=timeout, http2=http2_enabled
109
- )
151
+ # Note: TenacityTransport was removed. For now we just return a standard client.
152
+ # Future TODO: Implement RetryingClient(httpx.Client) if needed.
153
+ return httpx.Client(
154
+ verify=verify,
155
+ headers=headers or {},
156
+ timeout=timeout,
157
+ http2=http2_enabled,
158
+ )
110
159
 
111
160
 
112
161
  def create_async_client(
@@ -145,52 +194,21 @@ def create_async_client(
145
194
  else:
146
195
  trust_env = False
147
196
 
148
- # If retry components are available, create a client with retry transport
149
- # BUT: disable retry transport when proxies are detected because custom transports
150
- # don't play nicely with proxy configuration
151
- if (
152
- AsyncTenacityTransport
153
- and RetryConfig
154
- and wait_retry_after
155
- and not disable_retry_transport
156
- and not has_proxy
157
- ):
158
-
159
- def should_retry_status(response):
160
- """Raise exceptions for retryable HTTP status codes."""
161
- if response.status_code in retry_status_codes:
162
- emit_info(
163
- f"HTTP retry: Retrying request due to status code {response.status_code}"
164
- )
165
- return True
166
-
167
- # Create transport (with or without proxy base)
168
- if has_proxy:
169
- # Extract proxy URL from environment
170
- proxy_url = (
171
- os.environ.get("HTTPS_PROXY")
172
- or os.environ.get("https_proxy")
173
- or os.environ.get("HTTP_PROXY")
174
- or os.environ.get("http_proxy")
175
- )
176
- else:
177
- proxy_url = None
178
-
179
- # Create retry transport wrapper
180
- transport = AsyncTenacityTransport(
181
- config=RetryConfig(
182
- retry=lambda e: isinstance(e, httpx.HTTPStatusError)
183
- and e.response.status_code in retry_status_codes,
184
- wait=wait_retry_after(10),
185
- stop=stop_after_attempt(10),
186
- reraise=True,
187
- ),
188
- validate_response=should_retry_status,
197
+ # Extract proxy URL if needed
198
+ proxy_url = None
199
+ if has_proxy:
200
+ proxy_url = (
201
+ os.environ.get("HTTPS_PROXY")
202
+ or os.environ.get("https_proxy")
203
+ or os.environ.get("HTTP_PROXY")
204
+ or os.environ.get("http_proxy")
189
205
  )
190
206
 
191
- return httpx.AsyncClient(
192
- transport=transport,
193
- proxy=proxy_url, # Pass proxy to client, not transport
207
+ # Use RetryingAsyncClient if retries are enabled
208
+ if not disable_retry_transport:
209
+ return RetryingAsyncClient(
210
+ retry_status_codes=retry_status_codes,
211
+ proxy=proxy_url,
194
212
  verify=verify,
195
213
  headers=headers or {},
196
214
  timeout=timeout,
@@ -198,19 +216,7 @@ def create_async_client(
198
216
  trust_env=trust_env,
199
217
  )
200
218
  else:
201
- # Fallback to regular client if retry components are not available,
202
- # when retry transport is explicitly disabled, or when proxies are detected
203
- # Extract proxy URL if needed
204
- if has_proxy:
205
- proxy_url = (
206
- os.environ.get("HTTPS_PROXY")
207
- or os.environ.get("https_proxy")
208
- or os.environ.get("HTTP_PROXY")
209
- or os.environ.get("http_proxy")
210
- )
211
- else:
212
- proxy_url = None
213
-
219
+ # Regular client for testing
214
220
  return httpx.AsyncClient(
215
221
  proxy=proxy_url,
216
222
  verify=verify,
@@ -295,87 +301,41 @@ def create_reopenable_async_client(
295
301
  else:
296
302
  trust_env = False
297
303
 
298
- # If retry components are available, create a client with retry transport
299
- # BUT: disable retry transport when proxies are detected because custom transports
300
- # don't play nicely with proxy configuration
301
- if (
302
- AsyncTenacityTransport
303
- and RetryConfig
304
- and wait_retry_after
305
- and not disable_retry_transport
306
- and not has_proxy
307
- ):
304
+ # Extract proxy URL if needed
305
+ proxy_url = None
306
+ if has_proxy:
307
+ proxy_url = (
308
+ os.environ.get("HTTPS_PROXY")
309
+ or os.environ.get("https_proxy")
310
+ or os.environ.get("HTTP_PROXY")
311
+ or os.environ.get("http_proxy")
312
+ )
308
313
 
309
- def should_retry_status(response):
310
- """Raise exceptions for retryable HTTP status codes."""
311
- if response.status_code in retry_status_codes:
312
- emit_info(
313
- f"HTTP retry: Retrying request due to status code {response.status_code}"
314
- )
315
- return True
316
-
317
- transport = AsyncTenacityTransport(
318
- config=RetryConfig(
319
- retry=lambda e: isinstance(e, httpx.HTTPStatusError)
320
- and e.response.status_code in retry_status_codes,
321
- wait=wait_retry_after(
322
- fallback_strategy=wait_exponential(multiplier=1, max=60),
323
- max_wait=300,
324
- ),
325
- stop=stop_after_attempt(10),
326
- reraise=True,
327
- ),
328
- validate_response=should_retry_status,
314
+ if ReopenableAsyncClient is not None:
315
+ # Use RetryingAsyncClient if retries are enabled
316
+ client_class = (
317
+ RetryingAsyncClient if not disable_retry_transport else httpx.AsyncClient
329
318
  )
330
319
 
331
- # Extract proxy URL if needed
332
- if has_proxy:
333
- proxy_url = (
334
- os.environ.get("HTTPS_PROXY")
335
- or os.environ.get("https_proxy")
336
- or os.environ.get("HTTP_PROXY")
337
- or os.environ.get("http_proxy")
338
- )
339
- else:
340
- proxy_url = None
320
+ # Pass retry config only if using RetryingAsyncClient
321
+ kwargs = {
322
+ "proxy": proxy_url,
323
+ "verify": verify,
324
+ "headers": headers or {},
325
+ "timeout": timeout,
326
+ "http2": http2_enabled,
327
+ "trust_env": trust_env,
328
+ }
341
329
 
342
- if ReopenableAsyncClient is not None:
343
- return ReopenableAsyncClient(
344
- transport=transport,
345
- proxy=proxy_url,
346
- verify=verify,
347
- headers=headers or {},
348
- timeout=timeout,
349
- http2=http2_enabled,
350
- trust_env=trust_env,
351
- )
352
- else:
353
- # Fallback to regular AsyncClient if ReopenableAsyncClient is not available
354
- return httpx.AsyncClient(
355
- transport=transport,
356
- proxy=proxy_url,
357
- verify=verify,
358
- headers=headers or {},
359
- timeout=timeout,
360
- http2=http2_enabled,
361
- trust_env=trust_env,
362
- )
363
- else:
364
- # Fallback to regular clients if retry components are not available
365
- # or when proxies are detected
366
- # Extract proxy URL if needed
367
- if has_proxy:
368
- proxy_url = (
369
- os.environ.get("HTTPS_PROXY")
370
- or os.environ.get("https_proxy")
371
- or os.environ.get("HTTP_PROXY")
372
- or os.environ.get("http_proxy")
373
- )
374
- else:
375
- proxy_url = None
330
+ if not disable_retry_transport:
331
+ kwargs["retry_status_codes"] = retry_status_codes
376
332
 
377
- if ReopenableAsyncClient is not None:
378
- return ReopenableAsyncClient(
333
+ return ReopenableAsyncClient(client_class=client_class, **kwargs)
334
+ else:
335
+ # Fallback to RetryingAsyncClient
336
+ if not disable_retry_transport:
337
+ return RetryingAsyncClient(
338
+ retry_status_codes=retry_status_codes,
379
339
  proxy=proxy_url,
380
340
  verify=verify,
381
341
  headers=headers or {},
@@ -384,7 +344,6 @@ def create_reopenable_async_client(
384
344
  trust_env=trust_env,
385
345
  )
386
346
  else:
387
- # Fallback to regular AsyncClient if ReopenableAsyncClient is not available
388
347
  return httpx.AsyncClient(
389
348
  proxy=proxy_url,
390
349
  verify=verify,
code_puppy/keymap.py CHANGED
@@ -55,11 +55,19 @@ class KeymapError(Exception):
55
55
  def get_cancel_agent_key() -> str:
56
56
  """Get the configured cancel agent key from config.
57
57
 
58
+ On Windows when launched via uvx, this automatically returns "ctrl+k"
59
+ to work around uvx capturing Ctrl+C before it reaches Python.
60
+
58
61
  Returns:
59
62
  The key name (e.g., "ctrl+c", "ctrl+k") from config,
60
63
  or the default if not configured.
61
64
  """
62
65
  from code_puppy.config import get_value
66
+ from code_puppy.uvx_detection import should_use_alternate_cancel_key
67
+
68
+ # On Windows + uvx, force ctrl+k to bypass uvx's SIGINT capture
69
+ if should_use_alternate_cancel_key():
70
+ return "ctrl+k"
63
71
 
64
72
  key = get_value("cancel_agent_key")
65
73
  if key is None or key.strip() == "":
@@ -86,15 +94,9 @@ def cancel_agent_uses_signal() -> bool:
86
94
  """Check if the cancel agent key uses SIGINT (Ctrl+C).
87
95
 
88
96
  Returns:
89
- True if the cancel key is ctrl+c AND we're not on Windows
90
- (uses SIGINT handler), False if it uses keyboard listener approach.
97
+ True if the cancel key is ctrl+c (uses SIGINT handler),
98
+ False if it uses keyboard listener approach.
91
99
  """
92
- import sys
93
-
94
- # On Windows, always use keyboard listener - SIGINT is unreliable
95
- if sys.platform == "win32":
96
- return False
97
-
98
100
  return get_cancel_agent_key() == "ctrl+c"
99
101
 
100
102
 
@@ -348,7 +348,17 @@ class RichConsoleRenderer:
348
348
  # =========================================================================
349
349
 
350
350
  def _render_file_listing(self, msg: FileListingMessage) -> None:
351
- """Render a directory listing matching the old Rich-formatted output."""
351
+ """Render a compact directory listing with directory summaries.
352
+
353
+ Instead of listing every file, we group by directory and show:
354
+ - Directory name
355
+ - Number of files
356
+ - Total size
357
+ - Number of subdirectories
358
+ """
359
+ import os
360
+ from collections import defaultdict
361
+
352
362
  # Header on single line
353
363
  rec_flag = f"(recursive={msg.recursive})"
354
364
  banner = self._format_banner("directory_listing", "DIRECTORY LISTING")
@@ -357,32 +367,104 @@ class RichConsoleRenderer:
357
367
  f"📂 [bold cyan]{msg.directory}[/bold cyan] [dim]{rec_flag}[/dim]\n"
358
368
  )
359
369
 
360
- # Directory header
361
- dir_name = msg.directory.rstrip("/").split("/")[-1] or msg.directory
362
- self._console.print(f"📁 [bold blue]{dir_name}[/bold blue]")
370
+ # Build a tree structure: {parent_path: {files: [], dirs: set(), size: int}}
371
+ # Each key is a directory path, value contains direct children stats
372
+ dir_stats: dict = defaultdict(
373
+ lambda: {"files": [], "subdirs": set(), "total_size": 0}
374
+ )
375
+
376
+ # Root directory is represented as ""
377
+ root_key = ""
363
378
 
364
- # Build tree structure from flat list
365
379
  for entry in msg.files:
366
- # Calculate indentation based on depth
367
- prefix = ""
368
- for d in range(entry.depth + 1):
369
- if d == entry.depth:
370
- prefix += "└── "
371
- else:
372
- prefix += " "
380
+ path = entry.path
381
+ parent = os.path.dirname(path) if os.path.dirname(path) else root_key
373
382
 
374
383
  if entry.type == "dir":
375
- self._console.print(f"{prefix}📁 [bold blue]{entry.path}/[/bold blue]")
384
+ # Register this dir as a subdir of its parent
385
+ dir_stats[parent]["subdirs"].add(path)
386
+ # Ensure the dir itself exists in stats (even if empty)
387
+ _ = dir_stats[path]
376
388
  else:
377
- icon = self._get_file_icon(entry.path)
378
- if entry.size > 0:
379
- size_str = f" [dim]({self._format_size(entry.size)})[/dim]"
380
- else:
381
- size_str = ""
389
+ # It's a file - add to parent's stats
390
+ dir_stats[parent]["files"].append(entry)
391
+ dir_stats[parent]["total_size"] += entry.size
392
+
393
+ def render_dir_tree(dir_path: str, depth: int = 0) -> None:
394
+ """Recursively render directory with compact summary."""
395
+ stats = dir_stats.get(
396
+ dir_path, {"files": [], "subdirs": set(), "total_size": 0}
397
+ )
398
+ files = stats["files"]
399
+ subdirs = sorted(stats["subdirs"])
400
+
401
+ # Calculate total size including subdirectories (recursive)
402
+ def get_recursive_size(d: str) -> int:
403
+ s = dir_stats.get(d, {"files": [], "subdirs": set(), "total_size": 0})
404
+ size = s["total_size"]
405
+ for sub in s["subdirs"]:
406
+ size += get_recursive_size(sub)
407
+ return size
408
+
409
+ def get_recursive_file_count(d: str) -> int:
410
+ s = dir_stats.get(d, {"files": [], "subdirs": set(), "total_size": 0})
411
+ count = len(s["files"])
412
+ for sub in s["subdirs"]:
413
+ count += get_recursive_file_count(sub)
414
+ return count
415
+
416
+ indent = " " * depth
417
+
418
+ # For root level, just show contents
419
+ if dir_path == root_key:
420
+ # Show files at root level (depth 0)
421
+ for f in sorted(files, key=lambda x: x.path):
422
+ icon = self._get_file_icon(f.path)
423
+ name = os.path.basename(f.path)
424
+ size_str = (
425
+ f" [dim]({self._format_size(f.size)})[/dim]"
426
+ if f.size > 0
427
+ else ""
428
+ )
429
+ self._console.print(
430
+ f"{indent}{icon} [green]{name}[/green]{size_str}"
431
+ )
432
+
433
+ # Show subdirs at root level
434
+ for subdir in subdirs:
435
+ render_dir_tree(subdir, depth)
436
+ else:
437
+ # Show directory with summary
438
+ dir_name = os.path.basename(dir_path)
439
+ rec_size = get_recursive_size(dir_path)
440
+ rec_file_count = get_recursive_file_count(dir_path)
441
+ subdir_count = len(subdirs)
442
+
443
+ # Build summary parts
444
+ parts = []
445
+ if rec_file_count > 0:
446
+ parts.append(
447
+ f"{rec_file_count} file{'s' if rec_file_count != 1 else ''}"
448
+ )
449
+ if subdir_count > 0:
450
+ parts.append(
451
+ f"{subdir_count} subdir{'s' if subdir_count != 1 else ''}"
452
+ )
453
+ if rec_size > 0:
454
+ parts.append(self._format_size(rec_size))
455
+
456
+ summary = f" [dim]({', '.join(parts)})[/dim]" if parts else ""
382
457
  self._console.print(
383
- f"{prefix}{icon} [green]{entry.path}[/green]{size_str}"
458
+ f"{indent}📁 [bold blue]{dir_name}/[/bold blue]{summary}"
384
459
  )
385
460
 
461
+ # Recursively show subdirectories
462
+ for subdir in subdirs:
463
+ render_dir_tree(subdir, depth + 1)
464
+
465
+ # Render the tree starting from root
466
+ render_dir_tree(root_key, 0)
467
+
386
468
  # Summary
387
469
  self._console.print("\n[bold cyan]Summary:[/bold cyan]")
388
470
  self._console.print(