code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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 (243) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +8 -8
  3. code_puppy/agents/agent_c_reviewer.py +155 -0
  4. code_puppy/agents/agent_code_puppy.py +9 -2
  5. code_puppy/agents/agent_code_reviewer.py +90 -0
  6. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  7. code_puppy/agents/agent_creator_agent.py +48 -9
  8. code_puppy/agents/agent_golang_reviewer.py +151 -0
  9. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  10. code_puppy/agents/agent_manager.py +146 -199
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +90 -0
  15. code_puppy/agents/agent_qa_expert.py +163 -0
  16. code_puppy/agents/agent_qa_kitten.py +208 -0
  17. code_puppy/agents/agent_security_auditor.py +181 -0
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  20. code_puppy/agents/base_agent.py +1713 -1
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/json_agent.py +12 -1
  23. code_puppy/agents/pack/__init__.py +34 -0
  24. code_puppy/agents/pack/bloodhound.py +304 -0
  25. code_puppy/agents/pack/husky.py +321 -0
  26. code_puppy/agents/pack/retriever.py +393 -0
  27. code_puppy/agents/pack/shepherd.py +348 -0
  28. code_puppy/agents/pack/terrier.py +287 -0
  29. code_puppy/agents/pack/watchdog.py +367 -0
  30. code_puppy/agents/prompt_reviewer.py +145 -0
  31. code_puppy/agents/subagent_stream_handler.py +276 -0
  32. code_puppy/api/__init__.py +13 -0
  33. code_puppy/api/app.py +169 -0
  34. code_puppy/api/main.py +21 -0
  35. code_puppy/api/pty_manager.py +446 -0
  36. code_puppy/api/routers/__init__.py +12 -0
  37. code_puppy/api/routers/agents.py +36 -0
  38. code_puppy/api/routers/commands.py +217 -0
  39. code_puppy/api/routers/config.py +74 -0
  40. code_puppy/api/routers/sessions.py +232 -0
  41. code_puppy/api/templates/terminal.html +361 -0
  42. code_puppy/api/websocket.py +154 -0
  43. code_puppy/callbacks.py +174 -4
  44. code_puppy/chatgpt_codex_client.py +283 -0
  45. code_puppy/claude_cache_client.py +586 -0
  46. code_puppy/cli_runner.py +916 -0
  47. code_puppy/command_line/add_model_menu.py +1079 -0
  48. code_puppy/command_line/agent_menu.py +395 -0
  49. code_puppy/command_line/attachments.py +395 -0
  50. code_puppy/command_line/autosave_menu.py +605 -0
  51. code_puppy/command_line/clipboard.py +527 -0
  52. code_puppy/command_line/colors_menu.py +520 -0
  53. code_puppy/command_line/command_handler.py +233 -627
  54. code_puppy/command_line/command_registry.py +150 -0
  55. code_puppy/command_line/config_commands.py +715 -0
  56. code_puppy/command_line/core_commands.py +792 -0
  57. code_puppy/command_line/diff_menu.py +863 -0
  58. code_puppy/command_line/load_context_completion.py +15 -22
  59. code_puppy/command_line/mcp/base.py +1 -4
  60. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  61. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  62. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  63. code_puppy/command_line/mcp/edit_command.py +148 -0
  64. code_puppy/command_line/mcp/handler.py +9 -4
  65. code_puppy/command_line/mcp/help_command.py +6 -5
  66. code_puppy/command_line/mcp/install_command.py +16 -27
  67. code_puppy/command_line/mcp/install_menu.py +685 -0
  68. code_puppy/command_line/mcp/list_command.py +3 -3
  69. code_puppy/command_line/mcp/logs_command.py +174 -65
  70. code_puppy/command_line/mcp/remove_command.py +2 -2
  71. code_puppy/command_line/mcp/restart_command.py +12 -4
  72. code_puppy/command_line/mcp/search_command.py +17 -11
  73. code_puppy/command_line/mcp/start_all_command.py +22 -13
  74. code_puppy/command_line/mcp/start_command.py +50 -31
  75. code_puppy/command_line/mcp/status_command.py +6 -7
  76. code_puppy/command_line/mcp/stop_all_command.py +11 -8
  77. code_puppy/command_line/mcp/stop_command.py +11 -10
  78. code_puppy/command_line/mcp/test_command.py +2 -2
  79. code_puppy/command_line/mcp/utils.py +1 -1
  80. code_puppy/command_line/mcp/wizard_utils.py +22 -18
  81. code_puppy/command_line/mcp_completion.py +174 -0
  82. code_puppy/command_line/model_picker_completion.py +89 -30
  83. code_puppy/command_line/model_settings_menu.py +884 -0
  84. code_puppy/command_line/motd.py +14 -8
  85. code_puppy/command_line/onboarding_slides.py +179 -0
  86. code_puppy/command_line/onboarding_wizard.py +340 -0
  87. code_puppy/command_line/pin_command_completion.py +329 -0
  88. code_puppy/command_line/prompt_toolkit_completion.py +626 -75
  89. code_puppy/command_line/session_commands.py +296 -0
  90. code_puppy/command_line/utils.py +54 -0
  91. code_puppy/config.py +1181 -51
  92. code_puppy/error_logging.py +118 -0
  93. code_puppy/gemini_code_assist.py +385 -0
  94. code_puppy/gemini_model.py +602 -0
  95. code_puppy/http_utils.py +220 -104
  96. code_puppy/keymap.py +128 -0
  97. code_puppy/main.py +5 -594
  98. code_puppy/{mcp → mcp_}/__init__.py +17 -0
  99. code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
  100. code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
  101. code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
  102. code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
  103. code_puppy/{mcp → mcp_}/dashboard.py +15 -6
  104. code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
  105. code_puppy/{mcp → mcp_}/managed_server.py +66 -39
  106. code_puppy/{mcp → mcp_}/manager.py +146 -52
  107. code_puppy/mcp_/mcp_logs.py +224 -0
  108. code_puppy/{mcp → mcp_}/registry.py +6 -6
  109. code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
  110. code_puppy/messaging/__init__.py +199 -2
  111. code_puppy/messaging/bus.py +610 -0
  112. code_puppy/messaging/commands.py +167 -0
  113. code_puppy/messaging/markdown_patches.py +57 -0
  114. code_puppy/messaging/message_queue.py +17 -48
  115. code_puppy/messaging/messages.py +500 -0
  116. code_puppy/messaging/queue_console.py +1 -24
  117. code_puppy/messaging/renderers.py +43 -146
  118. code_puppy/messaging/rich_renderer.py +1027 -0
  119. code_puppy/messaging/spinner/__init__.py +33 -5
  120. code_puppy/messaging/spinner/console_spinner.py +92 -52
  121. code_puppy/messaging/spinner/spinner_base.py +29 -0
  122. code_puppy/messaging/subagent_console.py +461 -0
  123. code_puppy/model_factory.py +686 -80
  124. code_puppy/model_utils.py +167 -0
  125. code_puppy/models.json +86 -104
  126. code_puppy/models_dev_api.json +1 -0
  127. code_puppy/models_dev_parser.py +592 -0
  128. code_puppy/plugins/__init__.py +164 -10
  129. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  130. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  131. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  132. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  133. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  134. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  135. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  136. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  137. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  138. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  139. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  140. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  141. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  142. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  143. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  144. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  145. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  146. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  147. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  148. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  149. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  150. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  151. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  152. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  153. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  154. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  155. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  156. code_puppy/plugins/example_custom_command/README.md +280 -0
  157. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  158. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  159. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  160. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  161. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  162. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  163. code_puppy/plugins/oauth_puppy_html.py +228 -0
  164. code_puppy/plugins/shell_safety/__init__.py +6 -0
  165. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  166. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  167. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  168. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  169. code_puppy/prompts/codex_system_prompt.md +310 -0
  170. code_puppy/pydantic_patches.py +131 -0
  171. code_puppy/reopenable_async_client.py +8 -8
  172. code_puppy/round_robin_model.py +10 -15
  173. code_puppy/session_storage.py +294 -0
  174. code_puppy/status_display.py +21 -4
  175. code_puppy/summarization_agent.py +52 -14
  176. code_puppy/terminal_utils.py +418 -0
  177. code_puppy/tools/__init__.py +139 -6
  178. code_puppy/tools/agent_tools.py +548 -49
  179. code_puppy/tools/browser/__init__.py +37 -0
  180. code_puppy/tools/browser/browser_control.py +289 -0
  181. code_puppy/tools/browser/browser_interactions.py +545 -0
  182. code_puppy/tools/browser/browser_locators.py +640 -0
  183. code_puppy/tools/browser/browser_manager.py +316 -0
  184. code_puppy/tools/browser/browser_navigation.py +251 -0
  185. code_puppy/tools/browser/browser_screenshot.py +179 -0
  186. code_puppy/tools/browser/browser_scripts.py +462 -0
  187. code_puppy/tools/browser/browser_workflows.py +221 -0
  188. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  189. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  190. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  191. code_puppy/tools/browser/terminal_tools.py +525 -0
  192. code_puppy/tools/command_runner.py +941 -153
  193. code_puppy/tools/common.py +1146 -6
  194. code_puppy/tools/display.py +84 -0
  195. code_puppy/tools/file_modifications.py +288 -89
  196. code_puppy/tools/file_operations.py +352 -266
  197. code_puppy/tools/subagent_context.py +158 -0
  198. code_puppy/uvx_detection.py +242 -0
  199. code_puppy/version_checker.py +30 -11
  200. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  201. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  202. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
  203. code_puppy-0.0.366.dist-info/RECORD +217 -0
  204. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  205. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
  206. code_puppy/agent.py +0 -231
  207. code_puppy/agents/agent_orchestrator.json +0 -26
  208. code_puppy/agents/runtime_manager.py +0 -272
  209. code_puppy/command_line/mcp/add_command.py +0 -183
  210. code_puppy/command_line/meta_command_handler.py +0 -153
  211. code_puppy/message_history_processor.py +0 -490
  212. code_puppy/messaging/spinner/textual_spinner.py +0 -101
  213. code_puppy/state_management.py +0 -200
  214. code_puppy/tui/__init__.py +0 -10
  215. code_puppy/tui/app.py +0 -986
  216. code_puppy/tui/components/__init__.py +0 -21
  217. code_puppy/tui/components/chat_view.py +0 -550
  218. code_puppy/tui/components/command_history_modal.py +0 -218
  219. code_puppy/tui/components/copy_button.py +0 -139
  220. code_puppy/tui/components/custom_widgets.py +0 -63
  221. code_puppy/tui/components/human_input_modal.py +0 -175
  222. code_puppy/tui/components/input_area.py +0 -167
  223. code_puppy/tui/components/sidebar.py +0 -309
  224. code_puppy/tui/components/status_bar.py +0 -182
  225. code_puppy/tui/messages.py +0 -27
  226. code_puppy/tui/models/__init__.py +0 -8
  227. code_puppy/tui/models/chat_message.py +0 -25
  228. code_puppy/tui/models/command_history.py +0 -89
  229. code_puppy/tui/models/enums.py +0 -24
  230. code_puppy/tui/screens/__init__.py +0 -15
  231. code_puppy/tui/screens/help.py +0 -130
  232. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  233. code_puppy/tui/screens/settings.py +0 -290
  234. code_puppy/tui/screens/tools.py +0 -74
  235. code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
  236. code_puppy-0.0.169.dist-info/RECORD +0 -112
  237. /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
  238. /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
  239. /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
  240. /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
  241. /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
  242. /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
  243. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,605 @@
1
+ """Interactive terminal UI for loading autosave sessions.
2
+
3
+ Provides a beautiful split-panel interface for browsing and loading
4
+ autosave sessions with live preview of message content.
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ import time
10
+ from datetime import datetime
11
+ from io import StringIO
12
+ from pathlib import Path
13
+ from typing import List, Optional, Tuple
14
+
15
+ from prompt_toolkit.application import Application
16
+ from prompt_toolkit.key_binding import KeyBindings
17
+ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
18
+ from prompt_toolkit.layout.controls import FormattedTextControl
19
+ from prompt_toolkit.widgets import Frame
20
+ from rich.console import Console
21
+ from rich.markdown import Markdown
22
+
23
+ from code_puppy.config import AUTOSAVE_DIR
24
+ from code_puppy.session_storage import list_sessions, load_session
25
+ from code_puppy.tools.command_runner import set_awaiting_user_input
26
+
27
+ PAGE_SIZE = 15 # Sessions per page
28
+
29
+
30
+ def _get_session_metadata(base_dir: Path, session_name: str) -> dict:
31
+ """Load metadata for a session."""
32
+ meta_path = base_dir / f"{session_name}_meta.json"
33
+ try:
34
+ with meta_path.open("r", encoding="utf-8") as f:
35
+ return json.load(f)
36
+ except Exception:
37
+ return {}
38
+
39
+
40
+ def _get_session_entries(base_dir: Path) -> List[Tuple[str, dict]]:
41
+ """Get all sessions with their metadata, sorted by timestamp."""
42
+ try:
43
+ sessions = list_sessions(base_dir)
44
+ except (FileNotFoundError, PermissionError):
45
+ return []
46
+
47
+ entries = []
48
+
49
+ for name in sessions:
50
+ try:
51
+ metadata = _get_session_metadata(base_dir, name)
52
+ except (FileNotFoundError, PermissionError):
53
+ metadata = {}
54
+ entries.append((name, metadata))
55
+
56
+ # Sort by timestamp (most recent first)
57
+ def sort_key(entry):
58
+ _, metadata = entry
59
+ timestamp = metadata.get("timestamp")
60
+ if timestamp:
61
+ try:
62
+ return datetime.fromisoformat(timestamp)
63
+ except ValueError:
64
+ return datetime.min
65
+ return datetime.min
66
+
67
+ entries.sort(key=sort_key, reverse=True)
68
+ return entries
69
+
70
+
71
+ def _extract_last_user_message(history: list) -> str:
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
+ """
77
+ # Walk backwards through history to find last user message
78
+ for msg in reversed(history):
79
+ content_parts = []
80
+ for part in msg.parts:
81
+ if hasattr(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)
87
+ return "[No messages found]"
88
+
89
+
90
+ def _extract_message_content(msg) -> Tuple[str, str]:
91
+ """Extract role and content from a message.
92
+
93
+ Returns:
94
+ Tuple of (role, content) where role is 'user', 'assistant', or 'tool'
95
+ """
96
+ # Determine role based on message kind AND part types
97
+ # tool-return comes in a 'request' message but it's not from the user
98
+ part_kinds = [getattr(p, "part_kind", "unknown") for p in msg.parts]
99
+
100
+ if msg.kind == "request":
101
+ # Check if this is a tool return (not actually user input)
102
+ if all(pk == "tool-return" for pk in part_kinds):
103
+ role = "tool"
104
+ else:
105
+ role = "user"
106
+ else:
107
+ # Response from assistant
108
+ if all(pk == "tool-call" for pk in part_kinds):
109
+ role = "tool" # Pure tool call, label as tool activity
110
+ else:
111
+ role = "assistant"
112
+
113
+ # Extract content from parts, handling different part types
114
+ content_parts = []
115
+ for part in msg.parts:
116
+ part_kind = getattr(part, "part_kind", "unknown")
117
+
118
+ if part_kind == "tool-call":
119
+ # Assistant is calling a tool - show tool name and args preview
120
+ tool_name = getattr(part, "tool_name", "unknown")
121
+ args = getattr(part, "args", {})
122
+ # Create a condensed args preview
123
+ if args:
124
+ args_preview = str(args)[:100]
125
+ if len(str(args)) > 100:
126
+ args_preview += "..."
127
+ content_parts.append(
128
+ f"🔧 Tool Call: {tool_name}\n Args: {args_preview}"
129
+ )
130
+ else:
131
+ content_parts.append(f"🔧 Tool Call: {tool_name}")
132
+
133
+ elif part_kind == "tool-return":
134
+ # Tool result being returned - show tool name and truncated result
135
+ tool_name = getattr(part, "tool_name", "unknown")
136
+ result = getattr(part, "content", "")
137
+ if isinstance(result, str) and result.strip():
138
+ # Truncate long results
139
+ preview = result[:200].replace("\n", " ")
140
+ if len(result) > 200:
141
+ preview += "..."
142
+ content_parts.append(f"📥 Tool Result: {tool_name}\n {preview}")
143
+ else:
144
+ content_parts.append(f"📥 Tool Result: {tool_name}")
145
+
146
+ elif hasattr(part, "content"):
147
+ # Regular text content (user-prompt, text, thinking, etc.)
148
+ content = part.content
149
+ if isinstance(content, str) and content.strip():
150
+ content_parts.append(content)
151
+
152
+ content = "\n\n".join(content_parts) if content_parts else "[No content]"
153
+ return role, content
154
+
155
+
156
+ def _render_menu_panel(
157
+ entries: List[Tuple[str, dict]],
158
+ page: int,
159
+ selected_idx: int,
160
+ browse_mode: bool = False,
161
+ ) -> List:
162
+ """Render the left menu panel with pagination."""
163
+ lines = []
164
+ total_pages = (len(entries) + PAGE_SIZE - 1) // PAGE_SIZE if entries else 1
165
+ start_idx = page * PAGE_SIZE
166
+ end_idx = min(start_idx + PAGE_SIZE, len(entries))
167
+
168
+ lines.append(("", f" Session Page(s): ({page + 1}/{total_pages})"))
169
+ lines.append(("", "\n\n"))
170
+
171
+ if not entries:
172
+ lines.append(("fg:yellow", " No autosave sessions found."))
173
+ lines.append(("", "\n\n"))
174
+ # Navigation hints (always show)
175
+ lines.append(("", "\n"))
176
+ lines.append(("fg:ansibrightblack", " ↑/↓ "))
177
+ lines.append(("", "Navigate\n"))
178
+ lines.append(("fg:ansibrightblack", " ←/→ "))
179
+ lines.append(("", "Page\n"))
180
+ lines.append(("fg:green", " Enter "))
181
+ lines.append(("", "Load\n"))
182
+ lines.append(("fg:ansibrightred", " Ctrl+C "))
183
+ lines.append(("", "Cancel"))
184
+ return lines
185
+
186
+ # Show sessions for current page
187
+ for i in range(start_idx, end_idx):
188
+ session_name, metadata = entries[i]
189
+ is_selected = i == selected_idx
190
+
191
+ # Format timestamp
192
+ timestamp = metadata.get("timestamp", "unknown")
193
+ try:
194
+ dt = datetime.fromisoformat(timestamp)
195
+ time_str = dt.strftime("%Y-%m-%d %H:%M")
196
+ except Exception:
197
+ time_str = "unknown time"
198
+
199
+ # Format message count
200
+ msg_count = metadata.get("message_count", "?")
201
+
202
+ # Highlight selected item
203
+ if is_selected:
204
+ lines.append(("fg:ansibrightblack", f" > {time_str} • {msg_count} msgs"))
205
+ else:
206
+ lines.append(("fg:ansibrightblack", f" {time_str} • {msg_count} msgs"))
207
+
208
+ lines.append(("", "\n"))
209
+
210
+ # Navigation hints - change based on browse mode
211
+ lines.append(("", "\n"))
212
+ if browse_mode:
213
+ lines.append(("fg:ansicyan", " ↑/↓ "))
214
+ lines.append(("", "Browse msgs\n"))
215
+ lines.append(("fg:ansiyellow", " Esc "))
216
+ lines.append(("", "Exit browser\n"))
217
+ else:
218
+ lines.append(("fg:ansibrightblack", " ↑/↓ "))
219
+ lines.append(("", "Navigate\n"))
220
+ lines.append(("fg:ansibrightblack", " ←/→ "))
221
+ lines.append(("", "Page\n"))
222
+ lines.append(("fg:ansicyan", " e "))
223
+ lines.append(("", "Browse msgs\n"))
224
+ lines.append(("fg:green", " Enter "))
225
+ lines.append(("", "Load\n"))
226
+ lines.append(("fg:ansibrightred", " Ctrl+C "))
227
+ lines.append(("", "Cancel"))
228
+
229
+ return lines
230
+
231
+
232
+ def _render_message_browser_panel(
233
+ history: list,
234
+ message_idx: int,
235
+ session_name: str,
236
+ ) -> List:
237
+ """Render the message browser panel showing a single message.
238
+
239
+ Args:
240
+ history: Full message history list
241
+ message_idx: Index into history (0 = most recent)
242
+ session_name: Name of the session being browsed
243
+ """
244
+ lines = []
245
+
246
+ lines.append(("fg:ansicyan bold", " MESSAGE BROWSER"))
247
+ lines.append(("", "\n\n"))
248
+
249
+ total_messages = len(history)
250
+ if total_messages == 0:
251
+ lines.append(("fg:yellow", " No messages in this session."))
252
+ lines.append(("", "\n"))
253
+ return lines
254
+
255
+ # Clamp index to valid range
256
+ message_idx = max(0, min(message_idx, total_messages - 1))
257
+
258
+ # Get message (reverse index so 0 = most recent)
259
+ actual_idx = total_messages - 1 - message_idx
260
+ msg = history[actual_idx]
261
+
262
+ # Extract role and content
263
+ role, content = _extract_message_content(msg)
264
+
265
+ # Session info
266
+ lines.append(("fg:ansibrightblack", f" Session: {session_name}"))
267
+ lines.append(("", "\n"))
268
+
269
+ # Message position indicator
270
+ display_num = message_idx + 1 # 1-based for display
271
+ lines.append(("bold", f" Message {display_num} of {total_messages}"))
272
+ lines.append(("", "\n\n"))
273
+
274
+ # Role indicator with icon and color
275
+ if role == "user":
276
+ lines.append(("fg:ansicyan bold", " 🧑 USER"))
277
+ elif role == "tool":
278
+ lines.append(("fg:ansiyellow bold", " 🔧 TOOL"))
279
+ else:
280
+ lines.append(("fg:ansigreen bold", " 🤖 ASSISTANT"))
281
+ lines.append(("", "\n"))
282
+
283
+ # Separator line
284
+ lines.append(("fg:ansibrightblack", " " + "─" * 40))
285
+ lines.append(("", "\n"))
286
+
287
+ # Render content - use markdown for user/assistant, plain text for tool
288
+ try:
289
+ if role == "tool":
290
+ # Tool messages are already formatted, don't pass through markdown
291
+ # Use yellow color for tool output
292
+ rendered = content
293
+ text_color = "fg:ansiyellow"
294
+ else:
295
+ # User and assistant messages should be rendered as markdown
296
+ # Rich will handle the styling via ANSI codes
297
+ console = Console(
298
+ file=StringIO(),
299
+ legacy_windows=False,
300
+ no_color=False,
301
+ force_terminal=False,
302
+ width=72,
303
+ )
304
+ md = Markdown(content)
305
+ console.print(md)
306
+ rendered = console.file.getvalue()
307
+ # Don't override Rich's ANSI styling - use empty style
308
+ text_color = ""
309
+
310
+ # Show full message without truncation
311
+ message_lines = rendered.split("\n")
312
+
313
+ for line in message_lines:
314
+ lines.append((text_color, f" {line}"))
315
+ lines.append(("", "\n"))
316
+
317
+ except Exception as e:
318
+ lines.append(("fg:red", f" Error rendering message: {e}"))
319
+ lines.append(("", "\n"))
320
+
321
+ # Navigation hint at bottom
322
+ lines.append(("", "\n"))
323
+ lines.append(("fg:ansibrightblack", " ↑ older ↓ newer Esc exit"))
324
+ lines.append(("", "\n"))
325
+
326
+ return lines
327
+
328
+
329
+ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) -> List:
330
+ """Render the right preview panel with message content using rich markdown."""
331
+ lines = []
332
+
333
+ lines.append(("dim cyan", " PREVIEW"))
334
+ lines.append(("", "\n\n"))
335
+
336
+ if not entry:
337
+ lines.append(("fg:yellow", " No session selected."))
338
+ lines.append(("", "\n"))
339
+ return lines
340
+
341
+ session_name, metadata = entry
342
+
343
+ # Show metadata
344
+ lines.append(("bold", " Session: "))
345
+ lines.append(("", session_name))
346
+ lines.append(("", "\n"))
347
+
348
+ timestamp = metadata.get("timestamp", "unknown")
349
+ try:
350
+ dt = datetime.fromisoformat(timestamp)
351
+ time_str = dt.strftime("%Y-%m-%d %H:%M:%S")
352
+ except Exception:
353
+ time_str = timestamp
354
+ lines.append(("fg:ansibrightblack", f" Saved: {time_str}"))
355
+ lines.append(("", "\n"))
356
+
357
+ msg_count = metadata.get("message_count", 0)
358
+ tokens = metadata.get("total_tokens", 0)
359
+ lines.append(
360
+ ("fg:ansibrightblack", f" Messages: {msg_count} • Tokens: {tokens:,}")
361
+ )
362
+ lines.append(("", "\n\n"))
363
+
364
+ lines.append(("bold", " Last Message:"))
365
+ lines.append(("fg:ansibrightblack", " (press 'e' to browse full history)"))
366
+ lines.append(("", "\n"))
367
+
368
+ # Try to load and preview the last message
369
+ try:
370
+ history = load_session(session_name, base_dir)
371
+ last_message = _extract_last_user_message(history)
372
+
373
+ # Render markdown with rich
374
+ console = Console(
375
+ file=StringIO(),
376
+ legacy_windows=False,
377
+ no_color=False,
378
+ force_terminal=False,
379
+ width=76,
380
+ )
381
+ md = Markdown(last_message)
382
+ console.print(md)
383
+ rendered = console.file.getvalue()
384
+
385
+ # Show full message without truncation
386
+ message_lines = rendered.split("\n")
387
+
388
+ for line in message_lines:
389
+ # Rich already rendered the markdown, just display it dimmed
390
+ lines.append(("fg:ansibrightblack", f" {line}"))
391
+ lines.append(("", "\n"))
392
+
393
+ except Exception as e:
394
+ lines.append(("fg:red", f" Error loading preview: {e}"))
395
+ lines.append(("", "\n"))
396
+
397
+ return lines
398
+
399
+
400
+ async def interactive_autosave_picker() -> Optional[str]:
401
+ """Show interactive terminal UI to select an autosave session.
402
+
403
+ Returns:
404
+ Session name to load, or None if cancelled
405
+ """
406
+ base_dir = Path(AUTOSAVE_DIR)
407
+ entries = _get_session_entries(base_dir)
408
+
409
+ if not entries:
410
+ from code_puppy.messaging import emit_info
411
+
412
+ emit_info("No autosave sessions found.")
413
+ return None
414
+
415
+ # State
416
+ selected_idx = [0] # Current selection (global index)
417
+ current_page = [0] # Current page
418
+ result = [None] # Selected session name
419
+
420
+ # Browse mode state
421
+ browse_mode = [False] # Are we browsing messages within a session?
422
+ message_idx = [0] # Current message index (0 = most recent)
423
+ cached_history = [None] # Cached history for current session in browse mode
424
+
425
+ total_pages = (len(entries) + PAGE_SIZE - 1) // PAGE_SIZE
426
+
427
+ def get_current_entry() -> Optional[Tuple[str, dict]]:
428
+ if 0 <= selected_idx[0] < len(entries):
429
+ return entries[selected_idx[0]]
430
+ return None
431
+
432
+ # Build UI
433
+ menu_control = FormattedTextControl(text="")
434
+ preview_control = FormattedTextControl(text="")
435
+
436
+ def update_display():
437
+ """Update both panels."""
438
+ menu_control.text = _render_menu_panel(
439
+ entries, current_page[0], selected_idx[0], browse_mode[0]
440
+ )
441
+ # Show message browser if in browse mode, otherwise show preview
442
+ if browse_mode[0] and cached_history[0] is not None:
443
+ entry = get_current_entry()
444
+ session_name = entry[0] if entry else "unknown"
445
+ preview_control.text = _render_message_browser_panel(
446
+ cached_history[0], message_idx[0], session_name
447
+ )
448
+ else:
449
+ preview_control.text = _render_preview_panel(base_dir, get_current_entry())
450
+
451
+ menu_window = Window(
452
+ content=menu_control, wrap_lines=True, width=Dimension(weight=30)
453
+ )
454
+ preview_window = Window(
455
+ content=preview_control, wrap_lines=True, width=Dimension(weight=70)
456
+ )
457
+
458
+ menu_frame = Frame(menu_window, width=Dimension(weight=30), title="Sessions")
459
+ preview_frame = Frame(preview_window, width=Dimension(weight=70), title="Preview")
460
+
461
+ # Make left panel narrower (15% vs 85%)
462
+ root_container = VSplit(
463
+ [
464
+ menu_frame,
465
+ preview_frame,
466
+ ]
467
+ )
468
+
469
+ # Key bindings
470
+ kb = KeyBindings()
471
+
472
+ @kb.add("up")
473
+ def _(event):
474
+ if browse_mode[0]:
475
+ # In browse mode: go to older message
476
+ if cached_history[0] and message_idx[0] < len(cached_history[0]) - 1:
477
+ message_idx[0] += 1
478
+ update_display()
479
+ else:
480
+ # Normal mode: navigate sessions
481
+ if selected_idx[0] > 0:
482
+ selected_idx[0] -= 1
483
+ # Update page if needed
484
+ current_page[0] = selected_idx[0] // PAGE_SIZE
485
+ update_display()
486
+
487
+ @kb.add("down")
488
+ def _(event):
489
+ if browse_mode[0]:
490
+ # In browse mode: go to newer message
491
+ if message_idx[0] > 0:
492
+ message_idx[0] -= 1
493
+ update_display()
494
+ else:
495
+ # Normal mode: navigate sessions
496
+ if selected_idx[0] < len(entries) - 1:
497
+ selected_idx[0] += 1
498
+ # Update page if needed
499
+ current_page[0] = selected_idx[0] // PAGE_SIZE
500
+ update_display()
501
+
502
+ @kb.add("left")
503
+ def _(event):
504
+ if current_page[0] > 0:
505
+ current_page[0] -= 1
506
+ selected_idx[0] = current_page[0] * PAGE_SIZE
507
+ update_display()
508
+
509
+ @kb.add("right")
510
+ def _(event):
511
+ if current_page[0] < total_pages - 1:
512
+ current_page[0] += 1
513
+ selected_idx[0] = current_page[0] * PAGE_SIZE
514
+ update_display()
515
+
516
+ @kb.add("e")
517
+ def _(event):
518
+ """Enter message browse mode."""
519
+ if browse_mode[0]:
520
+ return # Already in browse mode
521
+ entry = get_current_entry()
522
+ if entry:
523
+ session_name = entry[0]
524
+ try:
525
+ cached_history[0] = load_session(session_name, base_dir)
526
+ browse_mode[0] = True
527
+ message_idx[0] = 0 # Start at most recent
528
+ update_display()
529
+ except Exception:
530
+ pass # Silently fail if can't load
531
+
532
+ @kb.add("escape")
533
+ def _(event):
534
+ """Exit browse mode or cancel."""
535
+ if browse_mode[0]:
536
+ browse_mode[0] = False
537
+ cached_history[0] = None
538
+ message_idx[0] = 0
539
+ update_display()
540
+ else:
541
+ # Not in browse mode - treat as cancel
542
+ result[0] = None
543
+ event.app.exit()
544
+
545
+ @kb.add("q")
546
+ def _(event):
547
+ """Exit browse mode (only when in browse mode)."""
548
+ if browse_mode[0]:
549
+ browse_mode[0] = False
550
+ cached_history[0] = None
551
+ message_idx[0] = 0
552
+ update_display()
553
+
554
+ @kb.add("enter")
555
+ def _(event):
556
+ entry = get_current_entry()
557
+ if entry:
558
+ result[0] = entry[0] # Store session name
559
+ event.app.exit()
560
+
561
+ @kb.add("c-c")
562
+ def _(event):
563
+ result[0] = None
564
+ event.app.exit()
565
+
566
+ layout = Layout(root_container)
567
+ app = Application(
568
+ layout=layout,
569
+ key_bindings=kb,
570
+ full_screen=False,
571
+ mouse_support=False,
572
+ )
573
+
574
+ set_awaiting_user_input(True)
575
+
576
+ # Enter alternate screen buffer once for entire session
577
+ sys.stdout.write("\033[?1049h") # Enter alternate buffer
578
+ sys.stdout.write("\033[2J\033[H") # Clear and home
579
+ sys.stdout.flush()
580
+ time.sleep(0.05)
581
+
582
+ try:
583
+ # Initial display
584
+ update_display()
585
+
586
+ # Just clear the current buffer (don't switch buffers)
587
+ sys.stdout.write("\033[2J\033[H") # Clear screen within current buffer
588
+ sys.stdout.flush()
589
+
590
+ # Run application (stays in same alternate buffer)
591
+ await app.run_async()
592
+
593
+ finally:
594
+ # Exit alternate screen buffer once at end
595
+ sys.stdout.write("\033[?1049l") # Exit alternate buffer
596
+ sys.stdout.flush()
597
+ # Reset awaiting input flag
598
+ set_awaiting_user_input(False)
599
+
600
+ # Clear exit message
601
+ from code_puppy.messaging import emit_info
602
+
603
+ emit_info("✓ Exited session browser")
604
+
605
+ return result[0]