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.
- code_puppy/agents/base_agent.py +41 -103
- code_puppy/cli_runner.py +105 -2
- code_puppy/command_line/add_model_menu.py +4 -0
- code_puppy/command_line/autosave_menu.py +5 -0
- code_puppy/command_line/colors_menu.py +5 -0
- code_puppy/command_line/config_commands.py +24 -1
- code_puppy/command_line/core_commands.py +51 -0
- code_puppy/command_line/diff_menu.py +5 -0
- code_puppy/command_line/mcp/custom_server_form.py +4 -0
- code_puppy/command_line/mcp/install_menu.py +5 -1
- code_puppy/command_line/model_settings_menu.py +5 -0
- code_puppy/command_line/motd.py +13 -7
- code_puppy/command_line/onboarding_slides.py +180 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/config.py +3 -2
- code_puppy/http_utils.py +155 -196
- code_puppy/keymap.py +10 -8
- code_puppy/messaging/rich_renderer.py +101 -19
- code_puppy/model_factory.py +86 -15
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +653 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +664 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/terminal_utils.py +168 -3
- code_puppy/tools/command_runner.py +42 -54
- code_puppy/uvx_detection.py +242 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/METADATA +30 -1
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/RECORD +44 -29
- {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
#
|
|
332
|
-
|
|
333
|
-
proxy_url
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
343
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
|
90
|
-
|
|
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
|
|
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
|
-
#
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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"{
|
|
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(
|