code-puppy 0.0.287__py3-none-any.whl → 0.0.323__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 (110) hide show
  1. code_puppy/__init__.py +3 -1
  2. code_puppy/agents/agent_code_puppy.py +5 -4
  3. code_puppy/agents/agent_creator_agent.py +22 -18
  4. code_puppy/agents/agent_manager.py +2 -2
  5. code_puppy/agents/base_agent.py +496 -102
  6. code_puppy/callbacks.py +8 -0
  7. code_puppy/chatgpt_codex_client.py +283 -0
  8. code_puppy/cli_runner.py +795 -0
  9. code_puppy/command_line/add_model_menu.py +19 -16
  10. code_puppy/command_line/attachments.py +10 -5
  11. code_puppy/command_line/autosave_menu.py +269 -41
  12. code_puppy/command_line/colors_menu.py +515 -0
  13. code_puppy/command_line/command_handler.py +10 -24
  14. code_puppy/command_line/config_commands.py +106 -25
  15. code_puppy/command_line/core_commands.py +32 -20
  16. code_puppy/command_line/mcp/add_command.py +3 -16
  17. code_puppy/command_line/mcp/base.py +0 -3
  18. code_puppy/command_line/mcp/catalog_server_installer.py +15 -15
  19. code_puppy/command_line/mcp/custom_server_form.py +66 -5
  20. code_puppy/command_line/mcp/custom_server_installer.py +17 -17
  21. code_puppy/command_line/mcp/edit_command.py +15 -22
  22. code_puppy/command_line/mcp/handler.py +7 -2
  23. code_puppy/command_line/mcp/help_command.py +2 -2
  24. code_puppy/command_line/mcp/install_command.py +10 -14
  25. code_puppy/command_line/mcp/install_menu.py +2 -6
  26. code_puppy/command_line/mcp/list_command.py +2 -2
  27. code_puppy/command_line/mcp/logs_command.py +174 -65
  28. code_puppy/command_line/mcp/remove_command.py +2 -2
  29. code_puppy/command_line/mcp/restart_command.py +7 -2
  30. code_puppy/command_line/mcp/search_command.py +16 -10
  31. code_puppy/command_line/mcp/start_all_command.py +16 -6
  32. code_puppy/command_line/mcp/start_command.py +12 -10
  33. code_puppy/command_line/mcp/status_command.py +4 -5
  34. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  35. code_puppy/command_line/mcp/stop_command.py +6 -4
  36. code_puppy/command_line/mcp/test_command.py +2 -2
  37. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  38. code_puppy/command_line/model_settings_menu.py +53 -7
  39. code_puppy/command_line/motd.py +1 -1
  40. code_puppy/command_line/pin_command_completion.py +82 -7
  41. code_puppy/command_line/prompt_toolkit_completion.py +32 -9
  42. code_puppy/command_line/session_commands.py +11 -4
  43. code_puppy/config.py +217 -53
  44. code_puppy/error_logging.py +118 -0
  45. code_puppy/gemini_code_assist.py +385 -0
  46. code_puppy/keymap.py +126 -0
  47. code_puppy/main.py +5 -745
  48. code_puppy/mcp_/__init__.py +17 -0
  49. code_puppy/mcp_/blocking_startup.py +63 -36
  50. code_puppy/mcp_/captured_stdio_server.py +1 -1
  51. code_puppy/mcp_/config_wizard.py +4 -4
  52. code_puppy/mcp_/dashboard.py +15 -6
  53. code_puppy/mcp_/managed_server.py +25 -5
  54. code_puppy/mcp_/manager.py +65 -0
  55. code_puppy/mcp_/mcp_logs.py +224 -0
  56. code_puppy/mcp_/registry.py +6 -6
  57. code_puppy/messaging/__init__.py +184 -2
  58. code_puppy/messaging/bus.py +610 -0
  59. code_puppy/messaging/commands.py +167 -0
  60. code_puppy/messaging/markdown_patches.py +57 -0
  61. code_puppy/messaging/message_queue.py +3 -3
  62. code_puppy/messaging/messages.py +470 -0
  63. code_puppy/messaging/renderers.py +43 -141
  64. code_puppy/messaging/rich_renderer.py +900 -0
  65. code_puppy/messaging/spinner/console_spinner.py +39 -2
  66. code_puppy/model_factory.py +292 -53
  67. code_puppy/model_utils.py +57 -48
  68. code_puppy/models.json +19 -5
  69. code_puppy/plugins/__init__.py +152 -10
  70. code_puppy/plugins/chatgpt_oauth/config.py +20 -12
  71. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  72. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  73. code_puppy/plugins/chatgpt_oauth/test_plugin.py +30 -13
  74. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  75. code_puppy/plugins/claude_code_oauth/config.py +15 -11
  76. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  77. code_puppy/plugins/claude_code_oauth/utils.py +6 -1
  78. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  79. code_puppy/plugins/oauth_puppy_html.py +3 -0
  80. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -134
  81. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  82. code_puppy/plugins/shell_safety/register_callbacks.py +77 -3
  83. code_puppy/prompts/codex_system_prompt.md +310 -0
  84. code_puppy/pydantic_patches.py +131 -0
  85. code_puppy/session_storage.py +2 -1
  86. code_puppy/status_display.py +7 -5
  87. code_puppy/terminal_utils.py +126 -0
  88. code_puppy/tools/agent_tools.py +131 -70
  89. code_puppy/tools/browser/browser_control.py +10 -14
  90. code_puppy/tools/browser/browser_interactions.py +20 -28
  91. code_puppy/tools/browser/browser_locators.py +27 -29
  92. code_puppy/tools/browser/browser_navigation.py +9 -9
  93. code_puppy/tools/browser/browser_screenshot.py +12 -14
  94. code_puppy/tools/browser/browser_scripts.py +17 -29
  95. code_puppy/tools/browser/browser_workflows.py +24 -25
  96. code_puppy/tools/browser/camoufox_manager.py +22 -26
  97. code_puppy/tools/command_runner.py +410 -88
  98. code_puppy/tools/common.py +51 -38
  99. code_puppy/tools/file_modifications.py +98 -24
  100. code_puppy/tools/file_operations.py +113 -202
  101. code_puppy/version_checker.py +28 -13
  102. {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  103. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/METADATA +3 -8
  104. code_puppy-0.0.323.dist-info/RECORD +168 -0
  105. code_puppy/tui_state.py +0 -55
  106. code_puppy-0.0.287.dist-info/RECORD +0 -153
  107. {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  108. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  109. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  110. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
@@ -576,9 +576,16 @@ class AddModelMenu:
576
576
  # Determine the model type
577
577
  model_type = type_mapping.get(provider.id, "custom_openai")
578
578
 
579
+ # Special case: kimi-for-coding provider uses "kimi-for-coding" as the model name
580
+ # instead of the model_id from models.dev (which is "kimi-k2-thinking")
581
+ if provider.id == "kimi-for-coding":
582
+ model_name = "kimi-for-coding"
583
+ else:
584
+ model_name = model.model_id
585
+
579
586
  config: dict = {
580
587
  "type": model_type,
581
- "name": model.model_id,
588
+ "name": model_name,
582
589
  }
583
590
 
584
591
  # Add custom endpoint for non-standard providers
@@ -697,13 +704,13 @@ class AddModelMenu:
697
704
  )
698
705
  return True
699
706
 
700
- print(f"\n🔑 {provider.name} requires the following credentials:\n")
707
+ emit_info(f"\n🔑 {provider.name} requires the following credentials:\n")
701
708
 
702
709
  for env_var in missing_vars:
703
710
  # Show helpful hints based on common env var patterns
704
711
  hint = self._get_env_var_hint(env_var)
705
712
  if hint:
706
- print(f" {hint}")
713
+ emit_info(f" {hint}")
707
714
 
708
715
  try:
709
716
  # Use regular input - simpler and works in threaded context
@@ -722,7 +729,7 @@ class AddModelMenu:
722
729
  emit_info(f"✅ Saved {env_var} to config")
723
730
 
724
731
  except (KeyboardInterrupt, EOFError):
725
- print("") # Clean newline
732
+ emit_info("") # Clean newline
726
733
  emit_warning("Credential input cancelled")
727
734
  return False
728
735
 
@@ -760,9 +767,9 @@ class AddModelMenu:
760
767
  if not provider:
761
768
  return None
762
769
 
763
- print(f"\n✨ Adding custom model for {provider.name}\n")
764
- print(" Enter the model ID exactly as the provider expects it.")
765
- print(
770
+ emit_info(f"\n✨ Adding custom model for {provider.name}\n")
771
+ emit_info(" Enter the model ID exactly as the provider expects it.")
772
+ emit_info(
766
773
  " Examples: gpt-4-turbo-preview, claude-3-opus-20240229, gemini-1.5-pro-latest\n"
767
774
  )
768
775
 
@@ -774,8 +781,8 @@ class AddModelMenu:
774
781
  return None
775
782
 
776
783
  # Ask for context size
777
- print("\n Enter the context window size (in tokens).")
778
- print(" Common sizes: 8192, 32768, 128000, 200000, 1000000\n")
784
+ emit_info("\n Enter the context window size (in tokens).")
785
+ emit_info(" Common sizes: 8192, 32768, 128000, 200000, 1000000\n")
779
786
 
780
787
  context_input = input(" Context size [128000]: ").strip()
781
788
 
@@ -806,7 +813,7 @@ class AddModelMenu:
806
813
  return (model_name, context_length)
807
814
 
808
815
  except (KeyboardInterrupt, EOFError):
809
- print("") # Clean newline
816
+ emit_info("") # Clean newline
810
817
  emit_warning("Custom model input cancelled")
811
818
  return None
812
819
 
@@ -840,11 +847,7 @@ class AddModelMenu:
840
847
  True if a model was added, False otherwise
841
848
  """
842
849
  if not self.registry or not self.providers:
843
- set_awaiting_user_input(True)
844
- try:
845
- print("No models data available.")
846
- finally:
847
- set_awaiting_user_input(False)
850
+ emit_warning("No models data available.")
848
851
  return False
849
852
 
850
853
  # Build UI
@@ -1036,7 +1039,7 @@ class AddModelMenu:
1036
1039
  emit_info("Model addition cancelled.")
1037
1040
  return False
1038
1041
  except (KeyboardInterrupt, EOFError):
1039
- print("")
1042
+ emit_info("")
1040
1043
  return False
1041
1044
 
1042
1045
  # Prompt for any missing credentials
@@ -261,17 +261,22 @@ def _detect_path_tokens(prompt: str) -> tuple[list[_DetectedPath], list[str]]:
261
261
  candidate_path_token = stripped_joined
262
262
  candidate_placeholder = joined
263
263
  consumed_until = end_index
264
+ if len(candidate_path_token) > MAX_PATH_LENGTH:
265
+ continue
264
266
  try:
265
267
  last_path = _normalise_path(candidate_path_token)
266
268
  except AttachmentParsingError:
267
269
  # Suppress warnings for non-file spans; just skip quietly
268
270
  found_span = False
269
271
  break
270
- if last_path.exists() and last_path.is_file():
271
- path = last_path
272
- found_span = True
273
- # We'll rebuild escaped placeholder after this block
274
- break
272
+ try:
273
+ if last_path.exists() and last_path.is_file():
274
+ path = last_path
275
+ found_span = True
276
+ # We'll rebuild escaped placeholder after this block
277
+ break
278
+ except OSError:
279
+ continue
275
280
  if not found_span:
276
281
  # Quietly skip tokens that are not files
277
282
  index += 1
@@ -5,7 +5,6 @@ autosave sessions with live preview of message content.
5
5
  """
6
6
 
7
7
  import json
8
- import re
9
8
  import sys
10
9
  import time
11
10
  from datetime import datetime
@@ -79,8 +78,77 @@ def _extract_last_user_message(history: list) -> str:
79
78
  return "[No messages found]"
80
79
 
81
80
 
81
+ def _extract_message_content(msg) -> Tuple[str, str]:
82
+ """Extract role and content from a message.
83
+
84
+ Returns:
85
+ Tuple of (role, content) where role is 'user', 'assistant', or 'tool'
86
+ """
87
+ # Determine role based on message kind AND part types
88
+ # tool-return comes in a 'request' message but it's not from the user
89
+ part_kinds = [getattr(p, "part_kind", "unknown") for p in msg.parts]
90
+
91
+ if msg.kind == "request":
92
+ # Check if this is a tool return (not actually user input)
93
+ if all(pk == "tool-return" for pk in part_kinds):
94
+ role = "tool"
95
+ else:
96
+ role = "user"
97
+ else:
98
+ # Response from assistant
99
+ if all(pk == "tool-call" for pk in part_kinds):
100
+ role = "tool" # Pure tool call, label as tool activity
101
+ else:
102
+ role = "assistant"
103
+
104
+ # Extract content from parts, handling different part types
105
+ content_parts = []
106
+ for part in msg.parts:
107
+ part_kind = getattr(part, "part_kind", "unknown")
108
+
109
+ if part_kind == "tool-call":
110
+ # Assistant is calling a tool - show tool name and args preview
111
+ tool_name = getattr(part, "tool_name", "unknown")
112
+ args = getattr(part, "args", {})
113
+ # Create a condensed args preview
114
+ if args:
115
+ args_preview = str(args)[:100]
116
+ if len(str(args)) > 100:
117
+ args_preview += "..."
118
+ content_parts.append(
119
+ f"🔧 Tool Call: {tool_name}\n Args: {args_preview}"
120
+ )
121
+ else:
122
+ content_parts.append(f"🔧 Tool Call: {tool_name}")
123
+
124
+ elif part_kind == "tool-return":
125
+ # Tool result being returned - show tool name and truncated result
126
+ tool_name = getattr(part, "tool_name", "unknown")
127
+ result = getattr(part, "content", "")
128
+ if isinstance(result, str) and result.strip():
129
+ # Truncate long results
130
+ preview = result[:200].replace("\n", " ")
131
+ if len(result) > 200:
132
+ preview += "..."
133
+ content_parts.append(f"📥 Tool Result: {tool_name}\n {preview}")
134
+ else:
135
+ content_parts.append(f"📥 Tool Result: {tool_name}")
136
+
137
+ elif hasattr(part, "content"):
138
+ # Regular text content (user-prompt, text, thinking, etc.)
139
+ content = part.content
140
+ if isinstance(content, str) and content.strip():
141
+ content_parts.append(content)
142
+
143
+ content = "\n\n".join(content_parts) if content_parts else "[No content]"
144
+ return role, content
145
+
146
+
82
147
  def _render_menu_panel(
83
- entries: List[Tuple[str, dict]], page: int, selected_idx: int
148
+ entries: List[Tuple[str, dict]],
149
+ page: int,
150
+ selected_idx: int,
151
+ browse_mode: bool = False,
84
152
  ) -> List:
85
153
  """Render the left menu panel with pagination."""
86
154
  lines = []
@@ -130,12 +198,20 @@ def _render_menu_panel(
130
198
 
131
199
  lines.append(("", "\n"))
132
200
 
133
- # Navigation hints
201
+ # Navigation hints - change based on browse mode
134
202
  lines.append(("", "\n"))
135
- lines.append(("fg:ansibrightblack", " ↑/↓ "))
136
- lines.append(("", "Navigate\n"))
137
- lines.append(("fg:ansibrightblack", " ←/→ "))
138
- lines.append(("", "Page\n"))
203
+ if browse_mode:
204
+ lines.append(("fg:ansicyan", " ↑/↓ "))
205
+ lines.append(("", "Browse msgs\n"))
206
+ lines.append(("fg:ansiyellow", " Esc "))
207
+ lines.append(("", "Exit browser\n"))
208
+ else:
209
+ lines.append(("fg:ansibrightblack", " ↑/↓ "))
210
+ lines.append(("", "Navigate\n"))
211
+ lines.append(("fg:ansibrightblack", " ←/→ "))
212
+ lines.append(("", "Page\n"))
213
+ lines.append(("fg:ansicyan", " e "))
214
+ lines.append(("", "Browse msgs\n"))
139
215
  lines.append(("fg:green", " Enter "))
140
216
  lines.append(("", "Load\n"))
141
217
  lines.append(("fg:ansibrightred", " Ctrl+C "))
@@ -144,6 +220,109 @@ def _render_menu_panel(
144
220
  return lines
145
221
 
146
222
 
223
+ def _render_message_browser_panel(
224
+ history: list,
225
+ message_idx: int,
226
+ session_name: str,
227
+ ) -> List:
228
+ """Render the message browser panel showing a single message.
229
+
230
+ Args:
231
+ history: Full message history list
232
+ message_idx: Index into history (0 = most recent)
233
+ session_name: Name of the session being browsed
234
+ """
235
+ lines = []
236
+
237
+ lines.append(("fg:ansicyan bold", " MESSAGE BROWSER"))
238
+ lines.append(("", "\n\n"))
239
+
240
+ total_messages = len(history)
241
+ if total_messages == 0:
242
+ lines.append(("fg:yellow", " No messages in this session."))
243
+ lines.append(("", "\n"))
244
+ return lines
245
+
246
+ # Clamp index to valid range
247
+ message_idx = max(0, min(message_idx, total_messages - 1))
248
+
249
+ # Get message (reverse index so 0 = most recent)
250
+ actual_idx = total_messages - 1 - message_idx
251
+ msg = history[actual_idx]
252
+
253
+ # Extract role and content
254
+ role, content = _extract_message_content(msg)
255
+
256
+ # Session info
257
+ lines.append(("fg:ansibrightblack", f" Session: {session_name}"))
258
+ lines.append(("", "\n"))
259
+
260
+ # Message position indicator
261
+ display_num = message_idx + 1 # 1-based for display
262
+ lines.append(("bold", f" Message {display_num} of {total_messages}"))
263
+ lines.append(("", "\n\n"))
264
+
265
+ # Role indicator with icon and color
266
+ if role == "user":
267
+ lines.append(("fg:ansicyan bold", " 🧑 USER"))
268
+ elif role == "tool":
269
+ lines.append(("fg:ansiyellow bold", " 🔧 TOOL"))
270
+ else:
271
+ lines.append(("fg:ansigreen bold", " 🤖 ASSISTANT"))
272
+ lines.append(("", "\n"))
273
+
274
+ # Separator line
275
+ lines.append(("fg:ansibrightblack", " " + "─" * 40))
276
+ lines.append(("", "\n"))
277
+
278
+ # Render content - use markdown for user/assistant, plain text for tool
279
+ try:
280
+ if role == "tool":
281
+ # Tool messages are already formatted, don't pass through markdown
282
+ # Use yellow color for tool output
283
+ rendered = content
284
+ text_color = "fg:ansiyellow"
285
+ else:
286
+ # User and assistant messages should be rendered as markdown
287
+ # Rich will handle the styling via ANSI codes
288
+ console = Console(
289
+ file=StringIO(),
290
+ legacy_windows=False,
291
+ no_color=False,
292
+ force_terminal=False,
293
+ width=72,
294
+ )
295
+ md = Markdown(content)
296
+ console.print(md)
297
+ rendered = console.file.getvalue()
298
+ # Don't override Rich's ANSI styling - use empty style
299
+ text_color = ""
300
+
301
+ # Truncate if too long (max 35 lines)
302
+ message_lines = rendered.split("\n")[:35]
303
+ is_truncated = len(rendered.split("\n")) > 35
304
+
305
+ for line in message_lines:
306
+ lines.append((text_color, f" {line}"))
307
+ lines.append(("", "\n"))
308
+
309
+ if is_truncated:
310
+ lines.append(("", "\n"))
311
+ lines.append(("fg:yellow", " ... truncated (message too long)"))
312
+ lines.append(("", "\n"))
313
+
314
+ except Exception as e:
315
+ lines.append(("fg:red", f" Error rendering message: {e}"))
316
+ lines.append(("", "\n"))
317
+
318
+ # Navigation hint at bottom
319
+ lines.append(("", "\n"))
320
+ lines.append(("fg:ansibrightblack", " ↑ older ↓ newer Esc exit"))
321
+ lines.append(("", "\n"))
322
+
323
+ return lines
324
+
325
+
147
326
  def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) -> List:
148
327
  """Render the right preview panel with message content using rich markdown."""
149
328
  lines = []
@@ -180,6 +359,7 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
180
359
  lines.append(("", "\n\n"))
181
360
 
182
361
  lines.append(("bold", " Last Message:"))
362
+ lines.append(("fg:ansibrightblack", " (press 'e' to browse all)"))
183
363
  lines.append(("", "\n"))
184
364
 
185
365
  # Try to load and preview the last message
@@ -207,22 +387,8 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
207
387
  message_lines = rendered.split("\n")[:30]
208
388
 
209
389
  for line in message_lines:
210
- # Apply basic styling based on markdown patterns
211
- styled_line = line
212
-
213
- # Headers - make cyan and bold (dimmed)
214
- if line.strip().startswith("#"):
215
- lines.append(("fg:cyan", f" {styled_line}"))
216
- # Code blocks - make them green (dimmed)
217
- elif line.strip().startswith("│"):
218
- lines.append(("fg:ansibrightblack", f" {styled_line}"))
219
- # List items - make them dimmed
220
- elif re.match(r"^\s*[•\-\*]", line):
221
- lines.append(("fg:ansibrightblack", f" {styled_line}"))
222
- # Regular text - dimmed
223
- else:
224
- lines.append(("fg:ansibrightblack", f" {styled_line}"))
225
-
390
+ # Rich already rendered the markdown, just display it dimmed
391
+ lines.append(("fg:ansibrightblack", f" {line}"))
226
392
  lines.append(("", "\n"))
227
393
 
228
394
  if is_long:
@@ -247,12 +413,9 @@ async def interactive_autosave_picker() -> Optional[str]:
247
413
  entries = _get_session_entries(base_dir)
248
414
 
249
415
  if not entries:
250
- # Still need to set/cleanup the awaiting input flag even if no entries
251
- set_awaiting_user_input(True)
252
- try:
253
- print("No autosave sessions found.")
254
- finally:
255
- set_awaiting_user_input(False)
416
+ from code_puppy.messaging import emit_info
417
+
418
+ emit_info("No autosave sessions found.")
256
419
  return None
257
420
 
258
421
  # State
@@ -260,6 +423,11 @@ async def interactive_autosave_picker() -> Optional[str]:
260
423
  current_page = [0] # Current page
261
424
  result = [None] # Selected session name
262
425
 
426
+ # Browse mode state
427
+ browse_mode = [False] # Are we browsing messages within a session?
428
+ message_idx = [0] # Current message index (0 = most recent)
429
+ cached_history = [None] # Cached history for current session in browse mode
430
+
263
431
  total_pages = (len(entries) + PAGE_SIZE - 1) // PAGE_SIZE
264
432
 
265
433
  def get_current_entry() -> Optional[Tuple[str, dict]]:
@@ -274,9 +442,17 @@ async def interactive_autosave_picker() -> Optional[str]:
274
442
  def update_display():
275
443
  """Update both panels."""
276
444
  menu_control.text = _render_menu_panel(
277
- entries, current_page[0], selected_idx[0]
445
+ entries, current_page[0], selected_idx[0], browse_mode[0]
278
446
  )
279
- preview_control.text = _render_preview_panel(base_dir, get_current_entry())
447
+ # Show message browser if in browse mode, otherwise show preview
448
+ if browse_mode[0] and cached_history[0] is not None:
449
+ entry = get_current_entry()
450
+ session_name = entry[0] if entry else "unknown"
451
+ preview_control.text = _render_message_browser_panel(
452
+ cached_history[0], message_idx[0], session_name
453
+ )
454
+ else:
455
+ preview_control.text = _render_preview_panel(base_dir, get_current_entry())
280
456
 
281
457
  menu_window = Window(
282
458
  content=menu_control, wrap_lines=True, width=Dimension(weight=30)
@@ -301,19 +477,33 @@ async def interactive_autosave_picker() -> Optional[str]:
301
477
 
302
478
  @kb.add("up")
303
479
  def _(event):
304
- if selected_idx[0] > 0:
305
- selected_idx[0] -= 1
306
- # Update page if needed
307
- current_page[0] = selected_idx[0] // PAGE_SIZE
308
- update_display()
480
+ if browse_mode[0]:
481
+ # In browse mode: go to older message
482
+ if cached_history[0] and message_idx[0] < len(cached_history[0]) - 1:
483
+ message_idx[0] += 1
484
+ update_display()
485
+ else:
486
+ # Normal mode: navigate sessions
487
+ if selected_idx[0] > 0:
488
+ selected_idx[0] -= 1
489
+ # Update page if needed
490
+ current_page[0] = selected_idx[0] // PAGE_SIZE
491
+ update_display()
309
492
 
310
493
  @kb.add("down")
311
494
  def _(event):
312
- if selected_idx[0] < len(entries) - 1:
313
- selected_idx[0] += 1
314
- # Update page if needed
315
- current_page[0] = selected_idx[0] // PAGE_SIZE
316
- update_display()
495
+ if browse_mode[0]:
496
+ # In browse mode: go to newer message
497
+ if message_idx[0] > 0:
498
+ message_idx[0] -= 1
499
+ update_display()
500
+ else:
501
+ # Normal mode: navigate sessions
502
+ if selected_idx[0] < len(entries) - 1:
503
+ selected_idx[0] += 1
504
+ # Update page if needed
505
+ current_page[0] = selected_idx[0] // PAGE_SIZE
506
+ update_display()
317
507
 
318
508
  @kb.add("left")
319
509
  def _(event):
@@ -329,6 +519,44 @@ async def interactive_autosave_picker() -> Optional[str]:
329
519
  selected_idx[0] = current_page[0] * PAGE_SIZE
330
520
  update_display()
331
521
 
522
+ @kb.add("e")
523
+ def _(event):
524
+ """Enter message browse mode."""
525
+ if browse_mode[0]:
526
+ return # Already in browse mode
527
+ entry = get_current_entry()
528
+ if entry:
529
+ session_name = entry[0]
530
+ try:
531
+ cached_history[0] = load_session(session_name, base_dir)
532
+ browse_mode[0] = True
533
+ message_idx[0] = 0 # Start at most recent
534
+ update_display()
535
+ except Exception:
536
+ pass # Silently fail if can't load
537
+
538
+ @kb.add("escape")
539
+ def _(event):
540
+ """Exit browse mode or cancel."""
541
+ if browse_mode[0]:
542
+ browse_mode[0] = False
543
+ cached_history[0] = None
544
+ message_idx[0] = 0
545
+ update_display()
546
+ else:
547
+ # Not in browse mode - treat as cancel
548
+ result[0] = None
549
+ event.app.exit()
550
+
551
+ @kb.add("q")
552
+ def _(event):
553
+ """Exit browse mode (only when in browse mode)."""
554
+ if browse_mode[0]:
555
+ browse_mode[0] = False
556
+ cached_history[0] = None
557
+ message_idx[0] = 0
558
+ update_display()
559
+
332
560
  @kb.add("enter")
333
561
  def _(event):
334
562
  entry = get_current_entry()