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,884 @@
1
+ """Interactive TUI for configuring per-model settings.
2
+
3
+ Provides a beautiful interface for viewing and modifying model-specific
4
+ settings like temperature and seed on a per-model basis.
5
+ """
6
+
7
+ import sys
8
+ import time
9
+ from typing import Dict, List, Optional
10
+
11
+ from prompt_toolkit import Application
12
+ from prompt_toolkit.key_binding import KeyBindings
13
+ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
14
+ from prompt_toolkit.layout.controls import FormattedTextControl
15
+ from prompt_toolkit.widgets import Frame
16
+
17
+ from code_puppy.config import (
18
+ get_all_model_settings,
19
+ get_global_model_name,
20
+ get_openai_reasoning_effort,
21
+ get_openai_verbosity,
22
+ model_supports_setting,
23
+ set_model_setting,
24
+ set_openai_reasoning_effort,
25
+ set_openai_verbosity,
26
+ )
27
+ from code_puppy.messaging import emit_info
28
+ from code_puppy.model_factory import ModelFactory
29
+ from code_puppy.tools.command_runner import set_awaiting_user_input
30
+
31
+ # Pagination config
32
+ MODELS_PER_PAGE = 15
33
+
34
+ # Setting definitions with metadata
35
+ # Numeric settings have min/max/step, choice settings have choices list
36
+ SETTING_DEFINITIONS: Dict[str, Dict] = {
37
+ "temperature": {
38
+ "name": "Temperature",
39
+ "description": "Controls randomness. Lower = more deterministic, higher = more creative.",
40
+ "type": "numeric",
41
+ "min": 0.0,
42
+ "max": 1.0, # Clamped to 0-1 per user request
43
+ "step": 0.1,
44
+ "default": None, # None means use model default
45
+ "format": "{:.1f}",
46
+ },
47
+ "seed": {
48
+ "name": "Seed",
49
+ "description": "Random seed for reproducible outputs. Set to same value for consistent results.",
50
+ "type": "numeric",
51
+ "min": 0,
52
+ "max": 999999,
53
+ "step": 1,
54
+ "default": None,
55
+ "format": "{:.0f}",
56
+ },
57
+ "reasoning_effort": {
58
+ "name": "Reasoning Effort",
59
+ "description": "Controls how much effort GPT-5 models spend on reasoning. Higher = more thorough but slower.",
60
+ "type": "choice",
61
+ "choices": ["minimal", "low", "medium", "high", "xhigh"],
62
+ "default": "medium",
63
+ },
64
+ "verbosity": {
65
+ "name": "Verbosity",
66
+ "description": "Controls response length. Low = concise, Medium = balanced, High = verbose.",
67
+ "type": "choice",
68
+ "choices": ["low", "medium", "high"],
69
+ "default": "medium",
70
+ },
71
+ "extended_thinking": {
72
+ "name": "Extended Thinking",
73
+ "description": "Enable Claude's extended thinking mode for complex reasoning tasks.",
74
+ "type": "boolean",
75
+ "default": True,
76
+ },
77
+ "budget_tokens": {
78
+ "name": "Thinking Budget (tokens)",
79
+ "description": "Max tokens for extended thinking. Only used when extended_thinking is enabled.",
80
+ "type": "numeric",
81
+ "min": 1024,
82
+ "max": 131072,
83
+ "step": 1024,
84
+ "default": 10000,
85
+ "format": "{:.0f}",
86
+ },
87
+ "interleaved_thinking": {
88
+ "name": "Interleaved Thinking",
89
+ "description": "Enable thinking between tool calls (Claude 4 only: Opus 4.5, Opus 4.1, Opus 4, Sonnet 4). Adds beta header. WARNING: On Vertex/Bedrock, this FAILS for non-Claude 4 models!",
90
+ "type": "boolean",
91
+ "default": False,
92
+ },
93
+ "clear_thinking": {
94
+ "name": "Clear Thinking",
95
+ "description": "False = Preserved Thinking (keep <think> blocks visible). True = strip thinking from responses.",
96
+ "type": "boolean",
97
+ "default": False,
98
+ },
99
+ }
100
+
101
+
102
+ def _load_all_model_names() -> List[str]:
103
+ """Load all available model names from config."""
104
+ models_config = ModelFactory.load_config()
105
+ return list(models_config.keys())
106
+
107
+
108
+ def _get_setting_choices(
109
+ setting_key: str, model_name: Optional[str] = None
110
+ ) -> List[str]:
111
+ """Get the available choices for a setting, filtered by model capabilities.
112
+
113
+ For reasoning_effort, only codex models support 'xhigh' - regular GPT-5.2
114
+ models are capped at 'high'.
115
+
116
+ Args:
117
+ setting_key: The setting name (e.g., 'reasoning_effort', 'verbosity')
118
+ model_name: Optional model name to filter choices for
119
+
120
+ Returns:
121
+ List of valid choices for this setting and model combination.
122
+ """
123
+ setting_def = SETTING_DEFINITIONS.get(setting_key, {})
124
+ if setting_def.get("type") != "choice":
125
+ return []
126
+
127
+ base_choices = setting_def.get("choices", [])
128
+
129
+ # For reasoning_effort, filter 'xhigh' based on model support
130
+ if setting_key == "reasoning_effort" and model_name:
131
+ models_config = ModelFactory.load_config()
132
+ model_config = models_config.get(model_name, {})
133
+
134
+ # Check if model supports xhigh reasoning
135
+ supports_xhigh = model_config.get("supports_xhigh_reasoning", False)
136
+
137
+ if not supports_xhigh:
138
+ # Remove xhigh from choices for non-codex models
139
+ return [c for c in base_choices if c != "xhigh"]
140
+
141
+ return base_choices
142
+
143
+
144
+ class ModelSettingsMenu:
145
+ """Interactive TUI for model settings configuration.
146
+
147
+ Two-level navigation:
148
+ - Level 1: List of all available models (paginated)
149
+ - Level 2: Settings for the selected model
150
+ """
151
+
152
+ def __init__(self):
153
+ """Initialize the settings menu."""
154
+ self.all_models = _load_all_model_names()
155
+ self.current_model_name = get_global_model_name()
156
+
157
+ # Navigation state
158
+ self.view_mode = "models" # "models" or "settings"
159
+ self.model_index = 0 # Index in model list (absolute)
160
+ self.setting_index = 0 # Index in settings list
161
+
162
+ # Pagination state
163
+ self.page = 0
164
+ self.page_size = MODELS_PER_PAGE
165
+
166
+ # Try to pre-select the current model and set correct page
167
+ if self.current_model_name in self.all_models:
168
+ self.model_index = self.all_models.index(self.current_model_name)
169
+ self.page = self.model_index // self.page_size
170
+
171
+ # Editing state
172
+ self.editing_mode = False
173
+ self.edit_value: Optional[float] = None
174
+ self.result_changed = False
175
+
176
+ # Cache for selected model's settings
177
+ self.selected_model: Optional[str] = None
178
+ self.supported_settings: List[str] = []
179
+ self.current_settings: Dict = {}
180
+
181
+ @property
182
+ def total_pages(self) -> int:
183
+ """Calculate total number of pages."""
184
+ if not self.all_models:
185
+ return 1
186
+ return (len(self.all_models) + self.page_size - 1) // self.page_size
187
+
188
+ @property
189
+ def page_start(self) -> int:
190
+ """Get the starting index for the current page."""
191
+ return self.page * self.page_size
192
+
193
+ @property
194
+ def page_end(self) -> int:
195
+ """Get the ending index (exclusive) for the current page."""
196
+ return min(self.page_start + self.page_size, len(self.all_models))
197
+
198
+ @property
199
+ def models_on_page(self) -> List[str]:
200
+ """Get the models visible on the current page."""
201
+ return self.all_models[self.page_start : self.page_end]
202
+
203
+ def _ensure_selection_visible(self):
204
+ """Ensure the current selection is on the visible page."""
205
+ if self.model_index < self.page_start:
206
+ self.page = self.model_index // self.page_size
207
+ elif self.model_index >= self.page_end:
208
+ self.page = self.model_index // self.page_size
209
+
210
+ def _get_supported_settings(self, model_name: str) -> List[str]:
211
+ """Get list of settings supported by a model."""
212
+ supported = []
213
+ for setting_key in SETTING_DEFINITIONS:
214
+ if model_supports_setting(model_name, setting_key):
215
+ supported.append(setting_key)
216
+ return supported
217
+
218
+ def _load_model_settings(self, model_name: str):
219
+ """Load settings for a specific model."""
220
+ self.selected_model = model_name
221
+ self.supported_settings = self._get_supported_settings(model_name)
222
+ self.current_settings = get_all_model_settings(model_name)
223
+
224
+ # Add global OpenAI settings if model supports them
225
+ if model_supports_setting(model_name, "reasoning_effort"):
226
+ self.current_settings["reasoning_effort"] = get_openai_reasoning_effort()
227
+ if model_supports_setting(model_name, "verbosity"):
228
+ self.current_settings["verbosity"] = get_openai_verbosity()
229
+
230
+ self.setting_index = 0
231
+
232
+ def _get_current_value(self, setting: str):
233
+ """Get the current value for a setting."""
234
+ return self.current_settings.get(setting)
235
+
236
+ def _format_value(self, setting: str, value) -> str:
237
+ """Format a setting value for display."""
238
+ setting_def = SETTING_DEFINITIONS[setting]
239
+ if value is None:
240
+ default = setting_def.get("default")
241
+ if default is not None:
242
+ return f"(default: {default})"
243
+ return "(model default)"
244
+
245
+ if setting_def.get("type") == "choice":
246
+ return str(value)
247
+
248
+ if setting_def.get("type") == "boolean":
249
+ return "Enabled" if value else "Disabled"
250
+
251
+ fmt = setting_def.get("format", "{:.2f}")
252
+ return fmt.format(value)
253
+
254
+ def _render_main_list(self) -> List:
255
+ """Render the main list panel (models or settings)."""
256
+ lines = []
257
+
258
+ if self.view_mode == "models":
259
+ # Header with page indicator
260
+ lines.append(("bold cyan", " 🐕 Select a Model to Configure"))
261
+ if self.total_pages > 1:
262
+ lines.append(
263
+ (
264
+ "fg:ansibrightblack",
265
+ f" (Page {self.page + 1}/{self.total_pages})",
266
+ )
267
+ )
268
+ lines.append(("", "\n\n"))
269
+
270
+ if not self.all_models:
271
+ lines.append(("fg:ansiyellow", " No models available."))
272
+ lines.append(("", "\n\n"))
273
+ self._add_model_nav_hints(lines)
274
+ return lines
275
+
276
+ # Only render models on the current page
277
+ for i, model_name in enumerate(self.models_on_page):
278
+ absolute_index = self.page_start + i
279
+ is_selected = absolute_index == self.model_index
280
+ is_current = model_name == self.current_model_name
281
+
282
+ prefix = " › " if is_selected else " "
283
+ style = "fg:ansiwhite bold" if is_selected else "fg:ansibrightblack"
284
+
285
+ # Check if model has any custom settings
286
+ model_settings = get_all_model_settings(model_name)
287
+ has_settings = len(model_settings) > 0
288
+
289
+ lines.append((style, f"{prefix}{model_name}"))
290
+
291
+ # Show indicators
292
+ if is_current:
293
+ lines.append(("fg:ansigreen", " (active)"))
294
+ if has_settings:
295
+ lines.append(("fg:ansicyan", " ⚙"))
296
+
297
+ lines.append(("", "\n"))
298
+
299
+ lines.append(("", "\n"))
300
+ self._add_model_nav_hints(lines)
301
+ else:
302
+ # Settings view
303
+ lines.append(("bold cyan", f" ⚙ Settings for {self.selected_model}"))
304
+ lines.append(("", "\n\n"))
305
+
306
+ if not self.supported_settings:
307
+ lines.append(
308
+ ("fg:ansiyellow", " No configurable settings for this model.")
309
+ )
310
+ lines.append(("", "\n\n"))
311
+ self._add_settings_nav_hints(lines)
312
+ return lines
313
+
314
+ for i, setting_key in enumerate(self.supported_settings):
315
+ setting_def = SETTING_DEFINITIONS[setting_key]
316
+ is_selected = i == self.setting_index
317
+ current_value = self._get_current_value(setting_key)
318
+
319
+ # Show editing state if in edit mode for this setting
320
+ if is_selected and self.editing_mode:
321
+ display_value = self._format_value(setting_key, self.edit_value)
322
+ prefix = " ✏️ "
323
+ style = "fg:ansigreen bold"
324
+ else:
325
+ display_value = self._format_value(setting_key, current_value)
326
+ prefix = " › " if is_selected else " "
327
+ style = "fg:ansiwhite" if is_selected else "fg:ansibrightblack"
328
+
329
+ # Setting name and value
330
+ lines.append((style, f"{prefix}{setting_def['name']}: "))
331
+ if current_value is not None or (is_selected and self.editing_mode):
332
+ lines.append(("fg:ansicyan", display_value))
333
+ else:
334
+ lines.append(("fg:ansibrightblack dim", display_value))
335
+ lines.append(("", "\n"))
336
+
337
+ lines.append(("", "\n"))
338
+ self._add_settings_nav_hints(lines)
339
+
340
+ return lines
341
+
342
+ def _add_model_nav_hints(self, lines: List):
343
+ """Add navigation hints for model list view."""
344
+ lines.append(("", "\n"))
345
+ lines.append(("fg:ansibrightblack", " ↑/↓ "))
346
+ lines.append(("", "Navigate models\n"))
347
+ if self.total_pages > 1:
348
+ lines.append(("fg:ansibrightblack", " PgUp/PgDn "))
349
+ lines.append(("", "Change page\n"))
350
+ lines.append(("fg:ansigreen", " Enter "))
351
+ lines.append(("", "Configure model\n"))
352
+ lines.append(("fg:ansiyellow", " Esc "))
353
+ lines.append(("", "Exit\n"))
354
+
355
+ def _add_settings_nav_hints(self, lines: List):
356
+ """Add navigation hints for settings view."""
357
+ lines.append(("", "\n"))
358
+
359
+ if self.editing_mode:
360
+ lines.append(("fg:ansibrightblack", " ←/→ "))
361
+ lines.append(("", "Adjust value\n"))
362
+ lines.append(("fg:ansigreen", " Enter "))
363
+ lines.append(("", "Save\n"))
364
+ lines.append(("fg:ansiyellow", " Esc "))
365
+ lines.append(("", "Cancel edit\n"))
366
+ lines.append(("fg:ansired", " d "))
367
+ lines.append(("", "Reset to default\n"))
368
+ else:
369
+ lines.append(("fg:ansibrightblack", " ↑/↓ "))
370
+ lines.append(("", "Navigate settings\n"))
371
+ lines.append(("fg:ansigreen", " Enter "))
372
+ lines.append(("", "Edit setting\n"))
373
+ lines.append(("fg:ansired", " d "))
374
+ lines.append(("", "Reset to default\n"))
375
+ lines.append(("fg:ansiyellow", " Esc "))
376
+ lines.append(("", "Back to models\n"))
377
+
378
+ def _render_details_panel(self) -> List:
379
+ """Render the details/help panel."""
380
+ lines = []
381
+
382
+ if self.view_mode == "models":
383
+ lines.append(("bold cyan", " Model Info"))
384
+ lines.append(("", "\n\n"))
385
+
386
+ if not self.all_models:
387
+ lines.append(("fg:ansibrightblack", " No models available."))
388
+ return lines
389
+
390
+ model_name = self.all_models[self.model_index]
391
+ is_current = model_name == self.current_model_name
392
+
393
+ lines.append(("bold", f" {model_name}"))
394
+ lines.append(("", "\n\n"))
395
+
396
+ if is_current:
397
+ lines.append(("fg:ansigreen", " ✓ Currently active model"))
398
+ lines.append(("", "\n\n"))
399
+
400
+ # Show current settings for this model
401
+ model_settings = get_all_model_settings(model_name)
402
+ if model_settings:
403
+ lines.append(("bold", " Custom Settings:"))
404
+ lines.append(("", "\n"))
405
+ for setting_key, value in model_settings.items():
406
+ setting_def = SETTING_DEFINITIONS.get(setting_key, {})
407
+ name = setting_def.get("name", setting_key)
408
+ fmt = setting_def.get("format", "{:.2f}")
409
+ lines.append(("fg:ansicyan", f" {name}: {fmt.format(value)}"))
410
+ lines.append(("", "\n"))
411
+ else:
412
+ lines.append(("fg:ansibrightblack", " Using all default settings"))
413
+ lines.append(("", "\n"))
414
+
415
+ # Show supported settings
416
+ supported = self._get_supported_settings(model_name)
417
+ lines.append(("", "\n"))
418
+ lines.append(("bold", " Configurable Settings:"))
419
+ lines.append(("", "\n"))
420
+ if supported:
421
+ for s in supported:
422
+ setting_def = SETTING_DEFINITIONS.get(s, {})
423
+ name = setting_def.get("name", s)
424
+ lines.append(("fg:ansibrightblack", f" • {name}"))
425
+ lines.append(("", "\n"))
426
+ else:
427
+ lines.append(("fg:ansibrightblack dim", " None"))
428
+ lines.append(("", "\n"))
429
+
430
+ # Show pagination info at the bottom of details
431
+ if self.total_pages > 1:
432
+ lines.append(("", "\n"))
433
+ lines.append(
434
+ (
435
+ "fg:ansibrightblack dim",
436
+ f" Model {self.model_index + 1} of {len(self.all_models)}",
437
+ )
438
+ )
439
+ lines.append(("", "\n"))
440
+
441
+ else:
442
+ # Settings detail view
443
+ lines.append(("bold cyan", " Setting Details"))
444
+ lines.append(("", "\n\n"))
445
+
446
+ if not self.supported_settings:
447
+ lines.append(
448
+ ("fg:ansibrightblack", " This model doesn't expose any settings.")
449
+ )
450
+ return lines
451
+
452
+ setting_key = self.supported_settings[self.setting_index]
453
+ setting_def = SETTING_DEFINITIONS[setting_key]
454
+ current_value = self._get_current_value(setting_key)
455
+
456
+ # Setting name
457
+ lines.append(("bold", f" {setting_def['name']}"))
458
+ lines.append(("", "\n"))
459
+
460
+ # Show if this is a global setting
461
+ if setting_key in ("reasoning_effort", "verbosity"):
462
+ lines.append(
463
+ (
464
+ "fg:ansiyellow",
465
+ " ⚠ Global setting (applies to all GPT-5 models)",
466
+ )
467
+ )
468
+ lines.append(("", "\n\n"))
469
+
470
+ # Description
471
+ lines.append(("fg:ansibrightblack", f" {setting_def['description']}"))
472
+ lines.append(("", "\n\n"))
473
+
474
+ # Range/choices info
475
+ if setting_def.get("type") == "choice":
476
+ lines.append(("bold", " Options:"))
477
+ lines.append(("", "\n"))
478
+ # Get filtered choices based on model capabilities
479
+ choices = _get_setting_choices(setting_key, self.selected_model)
480
+ lines.append(
481
+ (
482
+ "fg:ansibrightblack",
483
+ f" {' | '.join(choices)}",
484
+ )
485
+ )
486
+ elif setting_def.get("type") == "boolean":
487
+ lines.append(("bold", " Options:"))
488
+ lines.append(("", "\n"))
489
+ lines.append(
490
+ (
491
+ "fg:ansibrightblack",
492
+ " Enabled | Disabled",
493
+ )
494
+ )
495
+ else:
496
+ lines.append(("bold", " Range:"))
497
+ lines.append(("", "\n"))
498
+ lines.append(
499
+ (
500
+ "fg:ansibrightblack",
501
+ f" Min: {setting_def['min']} Max: {setting_def['max']} Step: {setting_def['step']}",
502
+ )
503
+ )
504
+ lines.append(("", "\n\n"))
505
+
506
+ # Current value
507
+ lines.append(("bold", " Current Value:"))
508
+ lines.append(("", "\n"))
509
+ if current_value is not None:
510
+ lines.append(
511
+ (
512
+ "fg:ansicyan",
513
+ f" {self._format_value(setting_key, current_value)}",
514
+ )
515
+ )
516
+ else:
517
+ lines.append(("fg:ansibrightblack dim", " (using model default)"))
518
+ lines.append(("", "\n\n"))
519
+
520
+ # Editing hint
521
+ if self.editing_mode:
522
+ lines.append(("fg:ansigreen bold", " ✏️ EDITING MODE"))
523
+ lines.append(("", "\n"))
524
+ if self.edit_value is not None:
525
+ lines.append(
526
+ (
527
+ "fg:ansicyan",
528
+ f" New value: {self._format_value(setting_key, self.edit_value)}",
529
+ )
530
+ )
531
+ else:
532
+ lines.append(
533
+ ("fg:ansibrightblack", " New value: (model default)")
534
+ )
535
+ lines.append(("", "\n"))
536
+
537
+ return lines
538
+
539
+ def _enter_settings_view(self):
540
+ """Enter settings view for the selected model."""
541
+ if not self.all_models:
542
+ return
543
+ model_name = self.all_models[self.model_index]
544
+ self._load_model_settings(model_name)
545
+ self.view_mode = "settings"
546
+
547
+ def _back_to_models(self):
548
+ """Go back to model list view."""
549
+ self.view_mode = "models"
550
+ self.editing_mode = False
551
+ self.edit_value = None
552
+
553
+ def _start_editing(self):
554
+ """Enter editing mode for the selected setting."""
555
+ if not self.supported_settings:
556
+ return
557
+
558
+ setting_key = self.supported_settings[self.setting_index]
559
+ setting_def = SETTING_DEFINITIONS[setting_key]
560
+ current = self._get_current_value(setting_key)
561
+
562
+ # Start with current value, or default if not set
563
+ if current is not None:
564
+ self.edit_value = current
565
+ elif setting_def.get("type") == "choice":
566
+ # For choice settings, start with the default (using filtered choices)
567
+ choices = _get_setting_choices(setting_key, self.selected_model)
568
+ self.edit_value = setting_def.get(
569
+ "default", choices[0] if choices else None
570
+ )
571
+ elif setting_def.get("type") == "boolean":
572
+ # For boolean settings, start with the default
573
+ self.edit_value = setting_def.get("default", False)
574
+ else:
575
+ # Default to a sensible starting point for numeric
576
+ if setting_key == "temperature":
577
+ self.edit_value = 0.7
578
+ elif setting_key == "seed":
579
+ self.edit_value = 42
580
+ elif setting_key == "budget_tokens":
581
+ self.edit_value = 10000
582
+ else:
583
+ self.edit_value = (setting_def["min"] + setting_def["max"]) / 2
584
+
585
+ self.editing_mode = True
586
+
587
+ def _adjust_value(self, direction: int):
588
+ """Adjust the current edit value."""
589
+ if not self.editing_mode or self.edit_value is None:
590
+ return
591
+
592
+ setting_key = self.supported_settings[self.setting_index]
593
+ setting_def = SETTING_DEFINITIONS[setting_key]
594
+
595
+ if setting_def.get("type") == "choice":
596
+ # Cycle through filtered choices based on model capabilities
597
+ choices = _get_setting_choices(setting_key, self.selected_model)
598
+ current_idx = (
599
+ choices.index(self.edit_value) if self.edit_value in choices else 0
600
+ )
601
+ new_idx = (current_idx + direction) % len(choices)
602
+ self.edit_value = choices[new_idx]
603
+ elif setting_def.get("type") == "boolean":
604
+ # Toggle boolean
605
+ self.edit_value = not self.edit_value
606
+ else:
607
+ # Numeric adjustment
608
+ step = setting_def["step"]
609
+ new_value = self.edit_value + (direction * step)
610
+ # Clamp to range
611
+ new_value = max(setting_def["min"], min(setting_def["max"], new_value))
612
+ self.edit_value = new_value
613
+
614
+ def _save_edit(self):
615
+ """Save the current edit value."""
616
+ if not self.editing_mode or self.selected_model is None:
617
+ return
618
+
619
+ setting_key = self.supported_settings[self.setting_index]
620
+
621
+ # Handle global OpenAI settings specially
622
+ if setting_key == "reasoning_effort":
623
+ if self.edit_value is not None:
624
+ set_openai_reasoning_effort(self.edit_value)
625
+ elif setting_key == "verbosity":
626
+ if self.edit_value is not None:
627
+ set_openai_verbosity(self.edit_value)
628
+ else:
629
+ # Standard per-model setting
630
+ set_model_setting(self.selected_model, setting_key, self.edit_value)
631
+
632
+ # Update local cache
633
+ if self.edit_value is not None:
634
+ self.current_settings[setting_key] = self.edit_value
635
+ elif setting_key in self.current_settings:
636
+ del self.current_settings[setting_key]
637
+
638
+ self.result_changed = True
639
+ self.editing_mode = False
640
+ self.edit_value = None
641
+
642
+ def _cancel_edit(self):
643
+ """Cancel the current edit."""
644
+ self.editing_mode = False
645
+ self.edit_value = None
646
+
647
+ def _reset_to_default(self):
648
+ """Reset the current setting to model default."""
649
+ if not self.supported_settings or self.selected_model is None:
650
+ return
651
+
652
+ setting_key = self.supported_settings[self.setting_index]
653
+ setting_def = SETTING_DEFINITIONS.get(setting_key, {})
654
+
655
+ if self.editing_mode:
656
+ # Reset edit value to default
657
+ default = setting_def.get("default")
658
+ self.edit_value = default
659
+ else:
660
+ # Handle global OpenAI settings - reset to their defaults
661
+ if setting_key == "reasoning_effort":
662
+ set_openai_reasoning_effort("medium") # Default
663
+ self.current_settings[setting_key] = "medium"
664
+ elif setting_key == "verbosity":
665
+ set_openai_verbosity("medium") # Default
666
+ self.current_settings[setting_key] = "medium"
667
+ else:
668
+ # Standard per-model setting
669
+ set_model_setting(self.selected_model, setting_key, None)
670
+ if setting_key in self.current_settings:
671
+ del self.current_settings[setting_key]
672
+ self.result_changed = True
673
+
674
+ def _page_up(self):
675
+ """Go to previous page."""
676
+ if self.page > 0:
677
+ self.page -= 1
678
+ # Move selection to first item on new page
679
+ self.model_index = self.page_start
680
+
681
+ def _page_down(self):
682
+ """Go to next page."""
683
+ if self.page < self.total_pages - 1:
684
+ self.page += 1
685
+ # Move selection to first item on new page
686
+ self.model_index = self.page_start
687
+
688
+ def update_display(self):
689
+ """Update the display."""
690
+ self.menu_control.text = self._render_main_list()
691
+ self.details_control.text = self._render_details_panel()
692
+
693
+ def run(self) -> bool:
694
+ """Run the interactive settings menu.
695
+
696
+ Returns:
697
+ True if settings were changed, False otherwise.
698
+ """
699
+ # Build UI
700
+ self.menu_control = FormattedTextControl(text="")
701
+ self.details_control = FormattedTextControl(text="")
702
+
703
+ menu_window = Window(
704
+ content=self.menu_control, wrap_lines=True, width=Dimension(weight=40)
705
+ )
706
+ details_window = Window(
707
+ content=self.details_control, wrap_lines=True, width=Dimension(weight=60)
708
+ )
709
+
710
+ menu_frame = Frame(menu_window, width=Dimension(weight=40), title="Models")
711
+ details_frame = Frame(
712
+ details_window, width=Dimension(weight=60), title="Details"
713
+ )
714
+
715
+ root_container = VSplit([menu_frame, details_frame])
716
+
717
+ # Key bindings
718
+ kb = KeyBindings()
719
+
720
+ @kb.add("up")
721
+ def _(event):
722
+ if self.view_mode == "models":
723
+ if self.model_index > 0:
724
+ self.model_index -= 1
725
+ self._ensure_selection_visible()
726
+ self.update_display()
727
+ else:
728
+ if not self.editing_mode and self.setting_index > 0:
729
+ self.setting_index -= 1
730
+ self.update_display()
731
+
732
+ @kb.add("down")
733
+ def _(event):
734
+ if self.view_mode == "models":
735
+ if self.model_index < len(self.all_models) - 1:
736
+ self.model_index += 1
737
+ self._ensure_selection_visible()
738
+ self.update_display()
739
+ else:
740
+ if (
741
+ not self.editing_mode
742
+ and self.setting_index < len(self.supported_settings) - 1
743
+ ):
744
+ self.setting_index += 1
745
+ self.update_display()
746
+
747
+ @kb.add("pageup")
748
+ def _(event):
749
+ if self.view_mode == "models":
750
+ self._page_up()
751
+ self.update_display()
752
+
753
+ @kb.add("pagedown")
754
+ def _(event):
755
+ if self.view_mode == "models":
756
+ self._page_down()
757
+ self.update_display()
758
+
759
+ @kb.add("left")
760
+ def _(event):
761
+ if self.view_mode == "settings" and self.editing_mode:
762
+ self._adjust_value(-1)
763
+ self.update_display()
764
+ elif self.view_mode == "models":
765
+ # Left arrow also goes to previous page
766
+ self._page_up()
767
+ self.update_display()
768
+
769
+ @kb.add("right")
770
+ def _(event):
771
+ if self.view_mode == "settings" and self.editing_mode:
772
+ self._adjust_value(1)
773
+ self.update_display()
774
+ elif self.view_mode == "models":
775
+ # Right arrow also goes to next page
776
+ self._page_down()
777
+ self.update_display()
778
+
779
+ @kb.add("enter")
780
+ def _(event):
781
+ if self.view_mode == "models":
782
+ self._enter_settings_view()
783
+ self.update_display()
784
+ else:
785
+ if self.editing_mode:
786
+ self._save_edit()
787
+ else:
788
+ self._start_editing()
789
+ self.update_display()
790
+
791
+ @kb.add("escape")
792
+ def _(event):
793
+ if self.view_mode == "settings":
794
+ if self.editing_mode:
795
+ self._cancel_edit()
796
+ self.update_display()
797
+ else:
798
+ self._back_to_models()
799
+ self.update_display()
800
+ else:
801
+ # At model list level, ESC closes the TUI
802
+ event.app.exit()
803
+
804
+ @kb.add("d")
805
+ def _(event):
806
+ if self.view_mode == "settings":
807
+ self._reset_to_default()
808
+ self.update_display()
809
+
810
+ @kb.add("c-c")
811
+ def _(event):
812
+ if self.editing_mode:
813
+ self._cancel_edit()
814
+ event.app.exit()
815
+
816
+ layout = Layout(root_container)
817
+ app = Application(
818
+ layout=layout,
819
+ key_bindings=kb,
820
+ full_screen=False,
821
+ mouse_support=False,
822
+ )
823
+
824
+ set_awaiting_user_input(True)
825
+
826
+ # Enter alternate screen buffer
827
+ sys.stdout.write("\033[?1049h")
828
+ sys.stdout.write("\033[2J\033[H")
829
+ sys.stdout.flush()
830
+ time.sleep(0.05)
831
+
832
+ try:
833
+ self.update_display()
834
+ sys.stdout.write("\033[2J\033[H")
835
+ sys.stdout.flush()
836
+
837
+ app.run(in_thread=True)
838
+
839
+ finally:
840
+ sys.stdout.write("\033[?1049l")
841
+ sys.stdout.flush()
842
+ set_awaiting_user_input(False)
843
+
844
+ # Clear exit message
845
+ from code_puppy.messaging import emit_info
846
+
847
+ emit_info("✓ Exited model settings")
848
+
849
+ return self.result_changed
850
+
851
+
852
+ def interactive_model_settings(model_name: Optional[str] = None) -> bool:
853
+ """Show interactive TUI to configure model settings.
854
+
855
+ Args:
856
+ model_name: Deprecated - the TUI now shows all models.
857
+ This parameter is ignored.
858
+
859
+ Returns:
860
+ True if settings were changed, False otherwise.
861
+ """
862
+ menu = ModelSettingsMenu()
863
+ return menu.run()
864
+
865
+
866
+ def show_model_settings_summary(model_name: Optional[str] = None) -> None:
867
+ """Print a summary of current model settings to the console.
868
+
869
+ Args:
870
+ model_name: Model to show settings for. If None, uses current global model.
871
+ """
872
+ model = model_name or get_global_model_name()
873
+ settings = get_all_model_settings(model)
874
+
875
+ if not settings:
876
+ emit_info(f"No custom settings configured for {model} (using model defaults)")
877
+ return
878
+
879
+ emit_info(f"Settings for {model}:")
880
+ for setting_key, value in settings.items():
881
+ setting_def = SETTING_DEFINITIONS.get(setting_key, {})
882
+ name = setting_def.get("name", setting_key)
883
+ fmt = setting_def.get("format", "{:.2f}")
884
+ emit_info(f" {name}: {fmt.format(value)}")