code-puppy 0.0.336__py3-none-any.whl → 0.0.348__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 (40) hide show
  1. code_puppy/agents/base_agent.py +41 -224
  2. code_puppy/agents/event_stream_handler.py +257 -0
  3. code_puppy/claude_cache_client.py +208 -2
  4. code_puppy/cli_runner.py +53 -35
  5. code_puppy/command_line/add_model_menu.py +8 -9
  6. code_puppy/command_line/autosave_menu.py +18 -24
  7. code_puppy/command_line/clipboard.py +527 -0
  8. code_puppy/command_line/core_commands.py +34 -0
  9. code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
  10. code_puppy/command_line/mcp/custom_server_form.py +54 -19
  11. code_puppy/command_line/mcp/custom_server_installer.py +8 -9
  12. code_puppy/command_line/mcp/handler.py +0 -2
  13. code_puppy/command_line/mcp/help_command.py +1 -5
  14. code_puppy/command_line/mcp/start_command.py +36 -18
  15. code_puppy/command_line/onboarding_slides.py +0 -1
  16. code_puppy/command_line/prompt_toolkit_completion.py +124 -0
  17. code_puppy/command_line/utils.py +54 -0
  18. code_puppy/http_utils.py +93 -130
  19. code_puppy/mcp_/async_lifecycle.py +35 -4
  20. code_puppy/mcp_/managed_server.py +49 -24
  21. code_puppy/mcp_/manager.py +81 -52
  22. code_puppy/messaging/message_queue.py +11 -23
  23. code_puppy/messaging/messages.py +3 -0
  24. code_puppy/messaging/rich_renderer.py +13 -3
  25. code_puppy/model_factory.py +16 -0
  26. code_puppy/models.json +2 -2
  27. code_puppy/plugins/antigravity_oauth/antigravity_model.py +17 -2
  28. code_puppy/plugins/claude_code_oauth/utils.py +126 -7
  29. code_puppy/terminal_utils.py +128 -1
  30. code_puppy/tools/agent_tools.py +66 -13
  31. code_puppy/tools/command_runner.py +1 -0
  32. code_puppy/tools/common.py +3 -9
  33. {code_puppy-0.0.336.data → code_puppy-0.0.348.data}/data/code_puppy/models.json +2 -2
  34. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/METADATA +19 -71
  35. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/RECORD +39 -38
  36. code_puppy/command_line/mcp/add_command.py +0 -170
  37. {code_puppy-0.0.336.data → code_puppy-0.0.348.data}/data/code_puppy/models_dev_api.json +0 -0
  38. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/WHEEL +0 -0
  39. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/entry_points.txt +0 -0
  40. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/licenses/LICENSE +0 -0
@@ -9,11 +9,19 @@ serialization, avoiding httpx/Pydantic internals.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import base64
12
13
  import json
13
- from typing import Any, Callable
14
+ import logging
15
+ import time
16
+ from typing import Any, Callable, MutableMapping
14
17
 
15
18
  import httpx
16
19
 
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Refresh token if it's older than 1 hour (3600 seconds)
23
+ TOKEN_MAX_AGE_SECONDS = 3600
24
+
17
25
  try:
18
26
  from anthropic import AsyncAnthropic
19
27
  except ImportError: # pragma: no cover - optional dep
@@ -21,9 +29,108 @@ except ImportError: # pragma: no cover - optional dep
21
29
 
22
30
 
23
31
  class ClaudeCacheAsyncClient(httpx.AsyncClient):
32
+ def _get_jwt_age_seconds(self, token: str | None) -> float | None:
33
+ """Decode a JWT and return its age in seconds.
34
+
35
+ Returns None if the token can't be decoded or has no timestamp claims.
36
+ Uses 'iat' (issued at) if available, otherwise calculates from 'exp'.
37
+ """
38
+ if not token:
39
+ return None
40
+
41
+ try:
42
+ # JWT format: header.payload.signature
43
+ # We only need the payload (second part)
44
+ parts = token.split(".")
45
+ if len(parts) != 3:
46
+ return None
47
+
48
+ # Decode the payload (base64url encoded)
49
+ payload_b64 = parts[1]
50
+ # Add padding if needed (base64url doesn't require padding)
51
+ padding = 4 - len(payload_b64) % 4
52
+ if padding != 4:
53
+ payload_b64 += "=" * padding
54
+
55
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
56
+ payload = json.loads(payload_bytes.decode("utf-8"))
57
+
58
+ now = time.time()
59
+
60
+ # Prefer 'iat' (issued at) claim if available
61
+ if "iat" in payload:
62
+ iat = float(payload["iat"])
63
+ age = now - iat
64
+ return age
65
+
66
+ # Fall back to calculating from 'exp' claim
67
+ # Assume tokens are typically valid for 1 hour
68
+ if "exp" in payload:
69
+ exp = float(payload["exp"])
70
+ # If exp is in the future, calculate how long until expiry
71
+ # and assume the token was issued 1 hour before expiry
72
+ time_until_exp = exp - now
73
+ # If token has less than 1 hour left, it's "old"
74
+ age = TOKEN_MAX_AGE_SECONDS - time_until_exp
75
+ return max(0, age)
76
+
77
+ return None
78
+ except Exception as exc:
79
+ logger.debug("Failed to decode JWT age: %s", exc)
80
+ return None
81
+
82
+ def _extract_bearer_token(self, request: httpx.Request) -> str | None:
83
+ """Extract the bearer token from request headers."""
84
+ auth_header = request.headers.get("Authorization") or request.headers.get(
85
+ "authorization"
86
+ )
87
+ if auth_header and auth_header.lower().startswith("bearer "):
88
+ return auth_header[7:] # Strip "Bearer " prefix
89
+ return None
90
+
91
+ def _should_refresh_token(self, request: httpx.Request) -> bool:
92
+ """Check if the token in the request is older than 1 hour."""
93
+ token = self._extract_bearer_token(request)
94
+ if not token:
95
+ return False
96
+
97
+ age = self._get_jwt_age_seconds(token)
98
+ if age is None:
99
+ return False
100
+
101
+ should_refresh = age >= TOKEN_MAX_AGE_SECONDS
102
+ if should_refresh:
103
+ logger.info(
104
+ "JWT token is %.1f seconds old (>= %d), will refresh proactively",
105
+ age,
106
+ TOKEN_MAX_AGE_SECONDS,
107
+ )
108
+ return should_refresh
109
+
24
110
  async def send(
25
111
  self, request: httpx.Request, *args: Any, **kwargs: Any
26
112
  ) -> httpx.Response: # type: ignore[override]
113
+ # Proactive token refresh: check JWT age before every request
114
+ if not request.extensions.get("claude_oauth_refresh_attempted"):
115
+ try:
116
+ if self._should_refresh_token(request):
117
+ refreshed_token = self._refresh_claude_oauth_token()
118
+ if refreshed_token:
119
+ logger.info("Proactively refreshed token before request")
120
+ # Rebuild request with new token
121
+ headers = dict(request.headers)
122
+ self._update_auth_headers(headers, refreshed_token)
123
+ body_bytes = self._extract_body_bytes(request)
124
+ request = self.build_request(
125
+ method=request.method,
126
+ url=request.url,
127
+ headers=headers,
128
+ content=body_bytes,
129
+ )
130
+ request.extensions["claude_oauth_refresh_attempted"] = True
131
+ except Exception as exc:
132
+ logger.debug("Error during proactive token refresh check: %s", exc)
133
+
27
134
  try:
28
135
  if request.url.path.endswith("/v1/messages"):
29
136
  body_bytes = self._extract_body_bytes(request)
@@ -56,7 +163,47 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
56
163
  except Exception:
57
164
  # Swallow wrapper errors; do not break real calls.
58
165
  pass
59
- return await super().send(request, *args, **kwargs)
166
+ response = await super().send(request, *args, **kwargs)
167
+ try:
168
+ # Check for both 401 and 400 - Anthropic/Cloudflare may return 400 for auth errors
169
+ # Also check if it's a Cloudflare HTML error response
170
+ if response.status_code in (400, 401) and not request.extensions.get(
171
+ "claude_oauth_refresh_attempted"
172
+ ):
173
+ # Determine if this is an auth error (including Cloudflare HTML errors)
174
+ is_auth_error = response.status_code == 401
175
+
176
+ if response.status_code == 400:
177
+ # Check if this is a Cloudflare HTML error
178
+ is_auth_error = self._is_cloudflare_html_error(response)
179
+ if is_auth_error:
180
+ logger.info(
181
+ "Detected Cloudflare 400 error (likely auth-related), attempting token refresh"
182
+ )
183
+
184
+ if is_auth_error:
185
+ refreshed_token = self._refresh_claude_oauth_token()
186
+ if refreshed_token:
187
+ logger.info("Token refreshed successfully, retrying request")
188
+ await response.aclose()
189
+ body_bytes = self._extract_body_bytes(request)
190
+ headers = dict(request.headers)
191
+ self._update_auth_headers(headers, refreshed_token)
192
+ retry_request = self.build_request(
193
+ method=request.method,
194
+ url=request.url,
195
+ headers=headers,
196
+ content=body_bytes,
197
+ )
198
+ retry_request.extensions["claude_oauth_refresh_attempted"] = (
199
+ True
200
+ )
201
+ return await super().send(retry_request, *args, **kwargs)
202
+ else:
203
+ logger.warning("Token refresh failed, returning original error")
204
+ except Exception as exc:
205
+ logger.debug("Error during token refresh attempt: %s", exc)
206
+ return response
60
207
 
61
208
  @staticmethod
62
209
  def _extract_body_bytes(request: httpx.Request) -> bytes | None:
@@ -78,6 +225,65 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
78
225
 
79
226
  return None
80
227
 
228
+ @staticmethod
229
+ def _update_auth_headers(
230
+ headers: MutableMapping[str, str], access_token: str
231
+ ) -> None:
232
+ bearer_value = f"Bearer {access_token}"
233
+ if "Authorization" in headers or "authorization" in headers:
234
+ headers["Authorization"] = bearer_value
235
+ elif "x-api-key" in headers or "X-API-Key" in headers:
236
+ headers["x-api-key"] = access_token
237
+ else:
238
+ headers["Authorization"] = bearer_value
239
+
240
+ @staticmethod
241
+ def _is_cloudflare_html_error(response: httpx.Response) -> bool:
242
+ """Check if this is a Cloudflare HTML error response.
243
+
244
+ Cloudflare often returns HTML error pages with status 400 when
245
+ there are authentication issues.
246
+ """
247
+ # Check content type
248
+ content_type = response.headers.get("content-type", "")
249
+ if "text/html" not in content_type.lower():
250
+ return False
251
+
252
+ # Check if body contains Cloudflare markers
253
+ try:
254
+ # Read response body if not already consumed
255
+ if hasattr(response, "_content") and response._content:
256
+ body = response._content.decode("utf-8", errors="ignore")
257
+ else:
258
+ # Try to read the text (this might be already consumed)
259
+ try:
260
+ body = response.text
261
+ except Exception:
262
+ return False
263
+
264
+ # Look for Cloudflare and 400 Bad Request markers
265
+ body_lower = body.lower()
266
+ return "cloudflare" in body_lower and "400 bad request" in body_lower
267
+ except Exception as exc:
268
+ logger.debug("Error checking for Cloudflare error: %s", exc)
269
+ return False
270
+
271
+ def _refresh_claude_oauth_token(self) -> str | None:
272
+ try:
273
+ from code_puppy.plugins.claude_code_oauth.utils import refresh_access_token
274
+
275
+ logger.info("Attempting to refresh Claude Code OAuth token...")
276
+ refreshed_token = refresh_access_token(force=True)
277
+ if refreshed_token:
278
+ self._update_auth_headers(self.headers, refreshed_token)
279
+ logger.info("Successfully refreshed Claude Code OAuth token")
280
+ else:
281
+ logger.warning("Token refresh returned None")
282
+ return refreshed_token
283
+ except Exception as exc:
284
+ logger.error("Exception during token refresh: %s", exc)
285
+ return None
286
+
81
287
  @staticmethod
82
288
  def _inject_cache_control(body: bytes) -> bytes | None:
83
289
  try:
code_puppy/cli_runner.py CHANGED
@@ -17,14 +17,12 @@ import traceback
17
17
  from pathlib import Path
18
18
 
19
19
  from dbos import DBOS, DBOSConfig
20
- from rich.console import Console, ConsoleOptions, RenderResult
21
- from rich.markdown import CodeBlock, Markdown
22
- from rich.syntax import Syntax
23
- from rich.text import Text
20
+ from rich.console import Console
24
21
 
25
22
  from code_puppy import __version__, callbacks, plugins
26
23
  from code_puppy.agents import get_current_agent
27
24
  from code_puppy.command_line.attachments import parse_prompt_attachments
25
+ from code_puppy.command_line.clipboard import get_clipboard_manager
28
26
  from code_puppy.config import (
29
27
  AUTOSAVE_DIR,
30
28
  COMMAND_HISTORY_FILE,
@@ -43,6 +41,7 @@ from code_puppy.keymap import (
43
41
  )
44
42
  from code_puppy.messaging import emit_info
45
43
  from code_puppy.terminal_utils import (
44
+ print_truecolor_warning,
46
45
  reset_unix_terminal,
47
46
  reset_windows_terminal_ansi,
48
47
  reset_windows_terminal_full,
@@ -91,7 +90,6 @@ async def main():
91
90
  "command", nargs="*", help="Run a single command (deprecated, use -p instead)"
92
91
  )
93
92
  args = parser.parse_args()
94
- from rich.console import Console
95
93
 
96
94
  from code_puppy.messaging import (
97
95
  RichConsoleRenderer,
@@ -146,6 +144,9 @@ async def main():
146
144
  except ImportError:
147
145
  emit_system_message("🐶 Code Puppy is Loading...")
148
146
 
147
+ # Truecolor warning moved to interactive_mode() so it prints LAST
148
+ # after all the help stuff - max visibility for the ugly red box!
149
+
149
150
  available_port = find_available_port()
150
151
  if available_port is None:
151
152
  emit_error("No available ports in range 8090-9010!")
@@ -354,6 +355,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
354
355
  emit_system_message(
355
356
  "Type @ for path completion, or /model to pick a model. Toggle multiline with Alt+M or F2; newline: Ctrl+J."
356
357
  )
358
+ emit_system_message("Paste images: Ctrl+V (even on Mac!), F3, or /paste command.")
359
+ import platform
360
+
361
+ if platform.system() == "Darwin":
362
+ emit_system_message(
363
+ "💡 macOS tip: Use Ctrl+V (not Cmd+V) to paste images in terminal."
364
+ )
357
365
  cancel_key = get_cancel_agent_display_name()
358
366
  emit_system_message(
359
367
  f"Press {cancel_key} during processing to cancel the current task or inference. Use Ctrl+X to interrupt running shell commands."
@@ -374,6 +382,10 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
374
382
 
375
383
  emit_warning(f"MOTD error: {e}")
376
384
 
385
+ # Print truecolor warning LAST so it's the most visible thing on startup
386
+ # Big ugly red box should be impossible to miss! 🔴
387
+ print_truecolor_warning(display_console)
388
+
377
389
  # Initialize the runtime agent manager
378
390
  if initial_command:
379
391
  from code_puppy.agents import get_current_agent
@@ -567,6 +579,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
567
579
 
568
580
  # Check for clear command (supports both `clear` and `/clear`)
569
581
  if task.strip().lower() in ("clear", "/clear"):
582
+ from code_puppy.command_line.clipboard import get_clipboard_manager
570
583
  from code_puppy.messaging import (
571
584
  emit_info,
572
585
  emit_system_message,
@@ -579,6 +592,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
579
592
  emit_warning("Conversation history cleared!")
580
593
  emit_system_message("The agent will not remember previous interactions.")
581
594
  emit_info(f"Auto-save session rotated to: {new_session_id}")
595
+
596
+ # Also clear pending clipboard images
597
+ clipboard_manager = get_clipboard_manager()
598
+ clipboard_count = clipboard_manager.get_pending_count()
599
+ clipboard_manager.clear_pending()
600
+ if clipboard_count > 0:
601
+ emit_info(f"Cleared {clipboard_count} pending clipboard image(s)")
582
602
  continue
583
603
 
584
604
  # Parse attachments first so leading paths aren't misread as commands
@@ -679,8 +699,6 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
679
699
  save_command_to_history(task)
680
700
 
681
701
  try:
682
- prettier_code_blocks()
683
-
684
702
  # No need to get agent directly - use manager's run methods
685
703
 
686
704
  # Use our custom helper to enable attachment handling with spinner support
@@ -750,28 +768,6 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
750
768
  pass
751
769
 
752
770
 
753
- def prettier_code_blocks():
754
- """Configure Rich to use prettier code block rendering."""
755
-
756
- class SimpleCodeBlock(CodeBlock):
757
- def __rich_console__(
758
- self, console: Console, options: ConsoleOptions
759
- ) -> RenderResult:
760
- code = str(self.text).rstrip()
761
- yield Text(self.lexer_name, style="dim")
762
- syntax = Syntax(
763
- code,
764
- self.lexer_name,
765
- theme=self.theme,
766
- background_color="default",
767
- line_numbers=True,
768
- )
769
- yield syntax
770
- yield Text(f"/{self.lexer_name}", style="dim")
771
-
772
- Markdown.elements["fence"] = SimpleCodeBlock
773
-
774
-
775
771
  async def run_prompt_with_attachments(
776
772
  agent,
777
773
  raw_prompt: str,
@@ -785,6 +781,7 @@ async def run_prompt_with_attachments(
785
781
  tuple: (result, task) where result is the agent response and task is the asyncio task
786
782
  """
787
783
  import asyncio
784
+ import re
788
785
 
789
786
  from code_puppy.messaging import emit_system_message, emit_warning
790
787
 
@@ -793,33 +790,54 @@ async def run_prompt_with_attachments(
793
790
  for warning in processed_prompt.warnings:
794
791
  emit_warning(warning)
795
792
 
793
+ # Get clipboard images and merge with file attachments
794
+ clipboard_manager = get_clipboard_manager()
795
+ clipboard_images = clipboard_manager.get_pending_images()
796
+
797
+ # Clear pending clipboard images after retrieval
798
+ clipboard_manager.clear_pending()
799
+
800
+ # Build summary of all attachments
796
801
  summary_parts = []
797
802
  if processed_prompt.attachments:
798
- summary_parts.append(f"binary files: {len(processed_prompt.attachments)}")
803
+ summary_parts.append(f"files: {len(processed_prompt.attachments)}")
804
+ if clipboard_images:
805
+ summary_parts.append(f"clipboard images: {len(clipboard_images)}")
799
806
  if processed_prompt.link_attachments:
800
807
  summary_parts.append(f"urls: {len(processed_prompt.link_attachments)}")
801
808
  if summary_parts:
802
809
  emit_system_message("Attachments detected -> " + ", ".join(summary_parts))
803
810
 
804
- if not processed_prompt.prompt:
811
+ # Clean up clipboard placeholders from the prompt text
812
+ cleaned_prompt = processed_prompt.prompt
813
+ if clipboard_images and cleaned_prompt:
814
+ cleaned_prompt = re.sub(
815
+ r"\[📋 clipboard image \d+\]\s*", "", cleaned_prompt
816
+ ).strip()
817
+
818
+ if not cleaned_prompt:
805
819
  emit_warning(
806
820
  "Prompt is empty after removing attachments; add instructions and retry."
807
821
  )
808
822
  return None, None
809
823
 
824
+ # Combine file attachments with clipboard images
810
825
  attachments = [attachment.content for attachment in processed_prompt.attachments]
826
+ attachments.extend(clipboard_images) # Add clipboard images
827
+
811
828
  link_attachments = [link.url_part for link in processed_prompt.link_attachments]
812
829
 
813
- # IMPORTANT: Set the shared console on the agent so that streaming output
830
+ # IMPORTANT: Set the shared console for streaming output so it
814
831
  # uses the same console as the spinner. This prevents Live display conflicts
815
832
  # that cause line duplication during markdown streaming.
816
- if spinner_console is not None:
817
- agent._console = spinner_console
833
+ from code_puppy.agents.event_stream_handler import set_streaming_console
834
+
835
+ set_streaming_console(spinner_console)
818
836
 
819
837
  # Create the agent task first so we can track and cancel it
820
838
  agent_task = asyncio.create_task(
821
839
  agent.run_with_mcp(
822
- processed_prompt.prompt,
840
+ cleaned_prompt, # Use cleaned prompt (clipboard placeholders removed)
823
841
  attachments=attachments,
824
842
  link_attachments=link_attachments,
825
843
  )
@@ -17,6 +17,7 @@ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
17
17
  from prompt_toolkit.layout.controls import FormattedTextControl
18
18
  from prompt_toolkit.widgets import Frame
19
19
 
20
+ from code_puppy.command_line.utils import safe_input
20
21
  from code_puppy.config import EXTRA_MODELS_FILE, set_config_value
21
22
  from code_puppy.messaging import emit_error, emit_info, emit_warning
22
23
  from code_puppy.models_dev_parser import ModelInfo, ModelsDevRegistry, ProviderInfo
@@ -724,8 +725,8 @@ class AddModelMenu:
724
725
  emit_info(f" {hint}")
725
726
 
726
727
  try:
727
- # Use regular input - simpler and works in threaded context
728
- value = input(f" Enter {env_var} (or press Enter to skip): ").strip()
728
+ # Use safe_input for cross-platform compatibility (Windows fix)
729
+ value = safe_input(f" Enter {env_var} (or press Enter to skip): ")
729
730
 
730
731
  if not value:
731
732
  emit_warning(
@@ -785,7 +786,7 @@ class AddModelMenu:
785
786
  )
786
787
 
787
788
  try:
788
- model_name = input(" Model ID: ").strip()
789
+ model_name = safe_input(" Model ID: ")
789
790
 
790
791
  if not model_name:
791
792
  emit_warning("No model name provided, cancelled.")
@@ -795,7 +796,7 @@ class AddModelMenu:
795
796
  emit_info("\n Enter the context window size (in tokens).")
796
797
  emit_info(" Common sizes: 8192, 32768, 128000, 200000, 1000000\n")
797
798
 
798
- context_input = input(" Context size [128000]: ").strip()
799
+ context_input = safe_input(" Context size [128000]: ")
799
800
 
800
801
  if not context_input:
801
802
  context_length = 128000 # Default
@@ -1045,11 +1046,9 @@ class AddModelMenu:
1045
1046
  f" It will be very limited for coding tasks."
1046
1047
  )
1047
1048
  try:
1048
- confirm = (
1049
- input("\n Are you sure you want to add this model? (y/N): ")
1050
- .strip()
1051
- .lower()
1052
- )
1049
+ confirm = safe_input(
1050
+ "\n Are you sure you want to add this model? (y/N): "
1051
+ ).lower()
1053
1052
  if confirm not in ("y", "yes"):
1054
1053
  emit_info("Model addition cancelled.")
1055
1054
  return False
@@ -69,12 +69,21 @@ def _get_session_entries(base_dir: Path) -> List[Tuple[str, dict]]:
69
69
 
70
70
 
71
71
  def _extract_last_user_message(history: list) -> str:
72
- """Extract the most recent user message from history."""
72
+ """Extract the most recent user message from history.
73
+
74
+ Joins all content parts from the message since messages can have
75
+ multiple parts (e.g., text + attachments, multi-part prompts).
76
+ """
73
77
  # Walk backwards through history to find last user message
74
78
  for msg in reversed(history):
79
+ content_parts = []
75
80
  for part in msg.parts:
76
81
  if hasattr(part, "content"):
77
- return part.content
82
+ content = part.content
83
+ if isinstance(content, str) and content.strip():
84
+ content_parts.append(content)
85
+ if content_parts:
86
+ return "\n\n".join(content_parts)
78
87
  return "[No messages found]"
79
88
 
80
89
 
@@ -298,19 +307,13 @@ def _render_message_browser_panel(
298
307
  # Don't override Rich's ANSI styling - use empty style
299
308
  text_color = ""
300
309
 
301
- # Truncate if too long (max 35 lines)
302
- message_lines = rendered.split("\n")[:35]
303
- is_truncated = len(rendered.split("\n")) > 35
310
+ # Show full message without truncation
311
+ message_lines = rendered.split("\n")
304
312
 
305
313
  for line in message_lines:
306
314
  lines.append((text_color, f" {line}"))
307
315
  lines.append(("", "\n"))
308
316
 
309
- if is_truncated:
310
- lines.append(("", "\n"))
311
- lines.append(("fg:yellow", " ... truncated (message too long)"))
312
- lines.append(("", "\n"))
313
-
314
317
  except Exception as e:
315
318
  lines.append(("fg:red", f" Error rendering message: {e}"))
316
319
  lines.append(("", "\n"))
@@ -359,7 +362,7 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
359
362
  lines.append(("", "\n\n"))
360
363
 
361
364
  lines.append(("bold", " Last Message:"))
362
- lines.append(("fg:ansibrightblack", " (press 'e' to browse all)"))
365
+ lines.append(("fg:ansibrightblack", " (press 'e' to browse full history)"))
363
366
  lines.append(("", "\n"))
364
367
 
365
368
  # Try to load and preview the last message
@@ -367,15 +370,11 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
367
370
  history = load_session(session_name, base_dir)
368
371
  last_message = _extract_last_user_message(history)
369
372
 
370
- # Check if original message is long (before Rich processing)
371
- original_lines = last_message.split("\n") if last_message else []
372
- is_long = len(original_lines) > 30
373
-
374
- # Render markdown with rich but strip ANSI codes
373
+ # Render markdown with rich
375
374
  console = Console(
376
375
  file=StringIO(),
377
376
  legacy_windows=False,
378
- no_color=False, # Disable ANSI color codes
377
+ no_color=False,
379
378
  force_terminal=False,
380
379
  width=76,
381
380
  )
@@ -383,19 +382,14 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
383
382
  console.print(md)
384
383
  rendered = console.file.getvalue()
385
384
 
386
- # Truncate if too long (max 30 lines for bigger preview)
387
- message_lines = rendered.split("\n")[:30]
385
+ # Show full message without truncation
386
+ message_lines = rendered.split("\n")
388
387
 
389
388
  for line in message_lines:
390
389
  # Rich already rendered the markdown, just display it dimmed
391
390
  lines.append(("fg:ansibrightblack", f" {line}"))
392
391
  lines.append(("", "\n"))
393
392
 
394
- if is_long:
395
- lines.append(("", "\n"))
396
- lines.append(("fg:yellow", " ... truncated"))
397
- lines.append(("", "\n"))
398
-
399
393
  except Exception as e:
400
394
  lines.append(("fg:red", f" Error loading preview: {e}"))
401
395
  lines.append(("", "\n"))