@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0

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 (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,764 @@
1
+ """Prelaunch screen renderer and terminal input adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import getpass
7
+ import os
8
+ import shutil
9
+ import sys
10
+ import time
11
+ import unicodedata
12
+ from pathlib import Path
13
+ from typing import Any, Callable
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class PrelaunchRenderBrand:
18
+ animated_ansi_text: Callable[..., Any]
19
+ credits: str
20
+ version: str
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class PrelaunchRenderText:
25
+ ansi: Callable[..., Any]
26
+ cell_width: Callable[..., Any]
27
+ fit_cells: Callable[..., Any]
28
+ pad_cells: Callable[..., Any]
29
+ ui_text: Callable[..., Any]
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class PrelaunchRenderData:
34
+ api_key_status_line: Callable[..., Any]
35
+ get_current_provider: Callable[..., Any]
36
+ llm_option_description_for_value: Callable[..., Any]
37
+ llm_option_panel_rows: Callable[..., Any]
38
+ load_config: Callable[..., Any]
39
+ main_menu_rows: Callable[..., Any]
40
+ provider_mode_label: Callable[..., Any]
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class PrelaunchRenderServices:
45
+ brand: PrelaunchRenderBrand
46
+ data: PrelaunchRenderData
47
+ text: PrelaunchRenderText
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class PrelaunchInputStyle:
52
+ ansi: Callable[..., Any]
53
+ log: Callable[..., Any]
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class TerminalSelectionServices:
58
+ enable_ansi: Callable[[], None]
59
+ ansi: Callable[[str, str], str]
60
+ intro_panel_lines: Callable[[int], list[str]]
61
+ status_lines: Callable[[], list[str]]
62
+ read_key: Callable[[int | None], str]
63
+
64
+
65
+ ANIMATED_TEXT_PALETTE = (203, 209, 215, 221, 229, 187, 151, 116, 111, 147, 183, 219)
66
+
67
+
68
+ def enable_ansi() -> None:
69
+ if os.name == "nt":
70
+ os.system("")
71
+
72
+
73
+ def ansi(text: str, code: str) -> str:
74
+ return f"\033[{code}m{text}\033[0m" if sys.stdout.isatty() else text
75
+
76
+
77
+ def animated_ansi_text(text: str, *, phase: int | None = None, bold: bool = True) -> str:
78
+ if not sys.stdout.isatty():
79
+ return text
80
+ current_phase = int(time.monotonic() * 8) if phase is None else phase
81
+ parts: list[str] = []
82
+ for index, character in enumerate(text):
83
+ if character.isspace():
84
+ parts.append(character)
85
+ continue
86
+ code = f"38;5;{ANIMATED_TEXT_PALETTE[(current_phase + index) % len(ANIMATED_TEXT_PALETTE)]}"
87
+ parts.append(ansi(character, f"1;{code}" if bold else code))
88
+ return "".join(parts)
89
+
90
+
91
+ def cell_width(text: str) -> int:
92
+ return sum(
93
+ 0
94
+ if unicodedata.combining(character)
95
+ else 2
96
+ if unicodedata.east_asian_width(character) in ("F", "W")
97
+ else 1
98
+ for character in text
99
+ )
100
+
101
+
102
+ def fit_cells(value: Any, width: int) -> str:
103
+ text = str(value if value is not None else "")
104
+ width = max(1, width)
105
+ if cell_width(text) <= width:
106
+ return text
107
+ suffix = "..." if width >= 4 else ""
108
+ limit = max(1, width - cell_width(suffix))
109
+ output: list[str] = []
110
+ used = 0
111
+ for character in text:
112
+ character_width = (
113
+ 0
114
+ if unicodedata.combining(character)
115
+ else 2
116
+ if unicodedata.east_asian_width(character) in ("F", "W")
117
+ else 1
118
+ )
119
+ if used + character_width > limit:
120
+ break
121
+ output.append(character)
122
+ used += character_width
123
+ return "".join(output) + suffix
124
+
125
+
126
+ def pad_cells(value: Any, width: int) -> str:
127
+ text = fit_cells(value, width)
128
+ return text + (" " * max(0, width - cell_width(text)))
129
+
130
+
131
+ def intro_panel_lines(width: int, app_name: str, credits: str) -> list[str]:
132
+ width = max(48, min(width, 120))
133
+ border = "-" * (width - 2)
134
+ lines = [f"+{border}+"]
135
+ title = f" {app_name} "
136
+ lines.append(f"|{title}{' ' * max(0, width - len(title) - 2)}|")
137
+ if width >= 92:
138
+ left_width = 39
139
+ right_width = width - left_width - 4
140
+ rows = [
141
+ ("Welcome back!", "Tips for getting started"),
142
+ ("", "Choose provider, model, base URL, and API key before launch."),
143
+ (" CLAUDE", "Routes Claude Code to Anthropic, Ollama, vLLM, Nvidia, or NIM."),
144
+ (" ANY", "Adds DuckDuckGo web search tooling for non-native providers."),
145
+ (credits, "Use --ca-* flags for headless runs; Claude flags pass through."),
146
+ ]
147
+ for left, right in rows:
148
+ lines.append(
149
+ f"| {left[:left_width].ljust(left_width)} | "
150
+ f"{right[:right_width].ljust(right_width)}|"
151
+ )
152
+ else:
153
+ rows = [
154
+ f"{app_name} routes Claude Code through selectable providers.",
155
+ "Anthropic, Ollama, vLLM, Nvidia Hosted, and self-hosted NIM.",
156
+ "DuckDuckGo web search is attached for non-native providers.",
157
+ "Headless setup uses --ca-* flags; Claude flags pass through.",
158
+ credits,
159
+ ]
160
+ lines.extend(f"| {row[: width - 4].ljust(width - 4)} |" for row in rows)
161
+ lines.append(f"+{border}+")
162
+ return lines
163
+
164
+
165
+ def append_menu_key_debug_log(path: Path, line: str) -> None:
166
+ try:
167
+ path.parent.mkdir(parents=True, exist_ok=True)
168
+ with path.open("a", encoding="utf-8") as stream:
169
+ stream.write(line)
170
+ except OSError:
171
+ pass
172
+
173
+
174
+ def read_menu_key(
175
+ fd: int | None = None,
176
+ *,
177
+ debug_log: Callable[[str], None] = lambda _line: None,
178
+ ) -> str:
179
+ if os.name == "nt":
180
+ import msvcrt
181
+
182
+ character = msvcrt.getwch()
183
+ if character in ("\x00", "\xe0"):
184
+ code = msvcrt.getwch()
185
+ return {"H": "up", "P": "down", "K": "left", "M": "right"}.get(code, "")
186
+ if character in ("\r", "\n"):
187
+ return "enter"
188
+ if character == "\x1b":
189
+ return "esc"
190
+ return character.lower()
191
+ descriptor = sys.stdin.fileno() if fd is None or fd < 0 else fd
192
+ character = os.read(descriptor, 1)
193
+ log = f"{time.time():.3f} first={character!r}"
194
+ if character == b"\x1b":
195
+ sequence = character.decode("latin-1")
196
+ following = os.read(descriptor, 1)
197
+ log += f" next={following!r}"
198
+ if not following:
199
+ debug_log(log + " result='esc'\n")
200
+ return "esc"
201
+ sequence += following.decode("latin-1")
202
+ if following == b"[":
203
+ while True:
204
+ following = os.read(descriptor, 1)
205
+ log += f" next={following!r}"
206
+ if not following:
207
+ break
208
+ sequence += following.decode("latin-1")
209
+ if 0x40 <= following[0] <= 0x7E:
210
+ break
211
+ elif following == b"O":
212
+ following = os.read(descriptor, 1)
213
+ log += f" next={following!r}"
214
+ if following:
215
+ sequence += following.decode("latin-1")
216
+ result = {
217
+ "\x1b[A": "up",
218
+ "\x1b[B": "down",
219
+ "\x1b[D": "left",
220
+ "\x1b[C": "right",
221
+ "\x1b[5~": "pageup",
222
+ "\x1b[6~": "pagedown",
223
+ "\x1b[H": "home",
224
+ "\x1b[F": "end",
225
+ }.get(sequence, "esc")
226
+ debug_log(log + f" seq={sequence!r} result={result!r}\n")
227
+ return result
228
+ result = "enter" if character in (b"\r", b"\n") else character.decode("latin-1").lower()
229
+ debug_log(log + f" result={result!r}\n")
230
+ return result
231
+
232
+
233
+ def portable_select(
234
+ title: str,
235
+ rows: list[str],
236
+ current: int = 0,
237
+ footer: str = "",
238
+ info_lines: list[str] | None = None,
239
+ show_intro: bool = False,
240
+ *,
241
+ services: TerminalSelectionServices,
242
+ ) -> int | None:
243
+ services.enable_ansi()
244
+ index = max(0, min(current, len(rows) - 1))
245
+ status_cache = services.status_lines()[:5]
246
+ output_is_tty = os.isatty(sys.stdout.fileno()) if os.name != "nt" else True
247
+ if output_is_tty:
248
+ sys.stdout.write("\033[?25l")
249
+ sys.stdout.flush()
250
+ fd = sys.stdin.fileno()
251
+ old_settings = None
252
+ if os.name != "nt" and os.isatty(fd):
253
+ try:
254
+ import termios
255
+
256
+ old_settings = termios.tcgetattr(fd)
257
+ new_settings = termios.tcgetattr(fd)
258
+ new_settings[3] &= ~(termios.ECHO | termios.ICANON)
259
+ new_settings[6][termios.VMIN] = 1
260
+ new_settings[6][termios.VTIME] = 0
261
+ termios.tcsetattr(fd, termios.TCSANOW, new_settings)
262
+ except Exception:
263
+ fd = -1
264
+ try:
265
+ while True:
266
+ columns = shutil.get_terminal_size((100, 24)).columns
267
+ screen = services.intro_panel_lines(columns) if show_intro else []
268
+ screen.append(services.ansi(title, "1"))
269
+ for line in status_cache:
270
+ color = "32" if line.startswith(("provider:", "model:")) else "2"
271
+ screen.append(" " + services.ansi(line, color))
272
+ screen.append("")
273
+ for row_index, row in enumerate(rows):
274
+ text = ("> " if row_index == index else " ") + row
275
+ if row_index == index:
276
+ screen.append(services.ansi(text, "7;1"))
277
+ elif row.startswith(("Quit", "종료", "終了", "退出")):
278
+ screen.append(services.ansi(text, "31"))
279
+ elif any(word in row for word in ("Launch", "실행", "起動", "启动")):
280
+ screen.append(services.ansi(text, "32;1"))
281
+ else:
282
+ screen.append(text)
283
+ if info_lines:
284
+ screen.extend(("", services.ansi("-" * min(120, max(72, columns - 4)), "38;5;208")))
285
+ screen.extend(services.ansi(line, "1;38;5;208") for line in info_lines)
286
+ screen.extend(("", services.ansi(footer or "Up/Down moves. Enter selects. Esc/q cancels.", "2")))
287
+ sys.stdout.write("\033[2J\033[H" + "\n".join(screen) + "\n")
288
+ sys.stdout.flush()
289
+ key = services.read_key(fd if fd >= 0 else None)
290
+ if key in ("up", "k"):
291
+ index = (index - 1) % len(rows)
292
+ elif key in ("down", "j"):
293
+ index = (index + 1) % len(rows)
294
+ elif key == "enter":
295
+ return index
296
+ elif key in ("esc", "q"):
297
+ return None
298
+ finally:
299
+ if old_settings is not None:
300
+ try:
301
+ import termios
302
+
303
+ termios.tcsetattr(fd, termios.TCSANOW, old_settings)
304
+ except Exception:
305
+ pass
306
+ sys.stdout.write("\033[?25h")
307
+ sys.stdout.flush()
308
+
309
+
310
+ def visible_rows(rows: list[str], selected: int, limit: int) -> list[tuple[int | None, str]]:
311
+ if len(rows) <= limit:
312
+ return [(i, row) for i, row in enumerate(rows)]
313
+ limit = max(4, limit)
314
+ start = max(0, min(selected - limit // 2, len(rows) - limit))
315
+ end = min(len(rows), start + limit)
316
+ visible: list[tuple[int | None, str]] = []
317
+ if start > 0:
318
+ visible.append((None, f"... {start} above"))
319
+ visible.extend((i, rows[i]) for i in range(start, end))
320
+ if end < len(rows):
321
+ visible.append((None, f"... {len(rows) - end} below"))
322
+ return visible
323
+
324
+
325
+ def render_prelaunch_screen(
326
+ main_idx: int,
327
+ panel: str | None,
328
+ panel_idx: int,
329
+ panel_rows: list[str],
330
+ checks: list[str],
331
+ messages: list[str],
332
+ first_render: bool,
333
+ *,
334
+ services: PrelaunchRenderServices,
335
+ ) -> bool:
336
+ brand = services.brand
337
+ data = services.data
338
+ text = services.text
339
+ CREDITS = brand.credits
340
+ VERSION = brand.version
341
+ animated_ansi_text = brand.animated_ansi_text
342
+ ansi = text.ansi
343
+ cell_width = text.cell_width
344
+ fit_cells = text.fit_cells
345
+ pad_cells = text.pad_cells
346
+ ui_text = text.ui_text
347
+ api_key_status_line = data.api_key_status_line
348
+ get_current_provider = data.get_current_provider
349
+ llm_option_description_for_value = data.llm_option_description_for_value
350
+ llm_option_panel_rows = data.llm_option_panel_rows
351
+ load_config = data.load_config
352
+ main_menu_rows = data.main_menu_rows
353
+ provider_mode_label = data.provider_mode_label
354
+ cfg = load_config()
355
+ provider, pcfg = get_current_provider(cfg)
356
+ lang = cfg.get("language", "en")
357
+ columns, height = shutil.get_terminal_size((110, 32))
358
+ render_width = max(40, columns - 1)
359
+ screen: list[str] = []
360
+ def add(text: str = "", code: str | None = None) -> None:
361
+ # Redraws start at cursor home. Each row must overwrite the full
362
+ # previous row; otherwise Windows cmd leaves stale text on the right.
363
+ fitted = pad_cells(text, render_width)
364
+ screen.append(ansi(fitted, code) if code else fitted)
365
+
366
+ def add_rendered(visible_text: str, rendered_text: str) -> None:
367
+ visible = fit_cells(visible_text, render_width)
368
+ padding = " " * max(0, render_width - cell_width(visible))
369
+ screen.append(rendered_text + padding)
370
+
371
+ mode_line = f"mode: {provider_mode_label(provider, pcfg)}"
372
+ title_text = f"Ciel Runtime v{VERSION}"
373
+ add_rendered(title_text, animated_ansi_text(title_text))
374
+ add(CREDITS, "2")
375
+ add("")
376
+ add(f"provider: {provider} language: {lang} {mode_line}", "32")
377
+ add(f"base_url: {pcfg.get('base_url')}", "2")
378
+ add(f"model: {pcfg.get('current_model')}", "32")
379
+ add(api_key_status_line(provider, pcfg), "2")
380
+ add("")
381
+ rows = main_menu_rows(cfg, provider, pcfg, lang)
382
+ for i, row in enumerate(rows):
383
+ line = ("> " if i == main_idx and panel is None else " ") + row
384
+ if i == main_idx and panel is None:
385
+ add(line, "7;1")
386
+ elif "disabled:" in row:
387
+ add(line, "2")
388
+ elif "Launch" in row or "실행" in row or "起動" in row or "启动" in row:
389
+ add(line, "32;1")
390
+ elif row == ui_text("quit", lang):
391
+ add(line, "31")
392
+ else:
393
+ add(line)
394
+ if panel:
395
+ titles = {
396
+ "language": "Language",
397
+ "provider": "Provider",
398
+ "api-key": "API key",
399
+ "base-url": "Base URL",
400
+ "model": "Model",
401
+ "advisor-model": "Advisor Model",
402
+ "test": "Compatibility test",
403
+ "options": ui_text("options", lang),
404
+ "channel-delivery": ui_text("channel_delivery", lang),
405
+ "log-level": ui_text("log_level", lang),
406
+ "channels": "Channels",
407
+ "context": ui_text("context_setup", lang),
408
+ "preset": ui_text("presets", lang),
409
+ "timeout": ui_text("timeout_preset", lang),
410
+ }
411
+ add("")
412
+ add("-" * render_width, "38;5;208")
413
+ panel_title = titles.get(panel, panel)
414
+ title_suffix = "" if panel_title.lower().endswith(("options", "presets", "setup", "설정", "옵션", "프리셋", "設定", "オプション", "プリセット", "设置", "选项", "预设")) else " options"
415
+ add(f"{panel_title}{title_suffix}", "1;38;5;208")
416
+ # Reserve an extra line for the per-row description when shown.
417
+ description_reserve = 2 if panel == "options" else 0
418
+ fixed = len(screen) + len(checks) + len(messages) + 5 + description_reserve
419
+ limit = max(5, height - fixed)
420
+ for actual, row in visible_rows(panel_rows, panel_idx, limit):
421
+ if actual is None:
422
+ add(" " + row, "2")
423
+ elif actual == panel_idx:
424
+ add(" > " + row, "7;1")
425
+ else:
426
+ add(" " + row)
427
+ if panel == "options" and panel_rows:
428
+ # Map panel_idx back to its option key, then show its localized
429
+ # description below the panel so the user always sees the meaning of
430
+ # the currently-highlighted row.
431
+ try:
432
+ _, panel_values = llm_option_panel_rows(provider, pcfg, lang)
433
+ except Exception:
434
+ panel_values = []
435
+ current_key = panel_values[panel_idx] if 0 <= panel_idx < len(panel_values) else ""
436
+ description = llm_option_description_for_value(provider, pcfg, current_key, lang) if current_key else ""
437
+ add("")
438
+ if description:
439
+ add(" " + description, "2")
440
+ else:
441
+ add("")
442
+ if messages:
443
+ add("")
444
+ for line in messages[-8:]:
445
+ add(" " + line, "36;1")
446
+ if checks:
447
+ add("")
448
+ add("-" * render_width, "38;5;208")
449
+ for line in checks[:2]:
450
+ add(" " + line, "1;38;5;208")
451
+ add("")
452
+ help_text = "Up/Down moves. PgUp/PgDn jumps. Home/End bounds. Enter selects. Esc/Left closes. q quits."
453
+ add(help_text, "2")
454
+ rendered = "\n".join(screen) + "\n"
455
+ if sys.stdout.isatty():
456
+ prefix = "\033[2J\033[H" if first_render else "\033[H"
457
+ sys.stdout.write(prefix + rendered + "\033[J")
458
+ sys.stdout.flush()
459
+ else:
460
+ print(rendered, end="")
461
+ return False
462
+
463
+
464
+ def _prompt_menu_value_raw(
465
+ label: str,
466
+ default: str = "",
467
+ secret: bool = False,
468
+ *,
469
+ style: PrelaunchInputStyle,
470
+ ) -> str | None:
471
+ ansi = style.ansi
472
+ if os.name == "nt" or not sys.stdin.isatty():
473
+ return None
474
+ try:
475
+ import codecs
476
+ import select
477
+ import termios
478
+ except Exception:
479
+ return None
480
+ fd = sys.stdin.fileno()
481
+ try:
482
+ old_settings = termios.tcgetattr(fd)
483
+ new_settings = termios.tcgetattr(fd)
484
+ new_settings[3] = new_settings[3] & ~(termios.ECHO | termios.ICANON)
485
+ new_settings[6][termios.VMIN] = 1
486
+ new_settings[6][termios.VTIME] = 0
487
+ termios.tcsetattr(fd, termios.TCSANOW, new_settings)
488
+ except Exception:
489
+ return None
490
+
491
+ chars: list[str] = []
492
+ decoder = codecs.getincrementaldecoder("utf-8")("replace")
493
+ try:
494
+ sys.stdout.write("\n" + ansi(label, "1;38;5;208"))
495
+ sys.stdout.flush()
496
+ while True:
497
+ first = os.read(fd, 1)
498
+ if not first:
499
+ continue
500
+ data = bytearray(first)
501
+ try:
502
+ while select.select([fd], [], [], 0)[0]:
503
+ more = os.read(fd, 4096)
504
+ if not more:
505
+ break
506
+ data.extend(more)
507
+ except Exception as exc:
508
+ style.log("WARN", f"prelaunch_terminal_io_failed error={type(exc).__name__}: {exc}")
509
+
510
+ display: list[str] = []
511
+ done = False
512
+ for byte in data:
513
+ b = bytes((byte,))
514
+ if b in (b"\r", b"\n"):
515
+ display.append("\n")
516
+ done = True
517
+ break
518
+ if b in (b"\x03",):
519
+ raise KeyboardInterrupt
520
+ if b in (b"\x04", b"\x1b"):
521
+ display.append("\n")
522
+ sys.stdout.write("".join(display))
523
+ sys.stdout.flush()
524
+ return default
525
+ if b in (b"\x7f", b"\x08"):
526
+ if chars:
527
+ chars.pop()
528
+ if not secret:
529
+ display.append("\b \b")
530
+ continue
531
+ if b == b"\x15":
532
+ if chars and not secret:
533
+ display.append("\b \b" * len(chars))
534
+ chars.clear()
535
+ continue
536
+ if byte < 0x20:
537
+ continue
538
+ text = decoder.decode(b)
539
+ if not text:
540
+ continue
541
+ for ch in text:
542
+ if ch in ("\ufffd", "\r", "\n"):
543
+ continue
544
+ chars.append(ch)
545
+ if not secret:
546
+ display.append(ch)
547
+ if display:
548
+ sys.stdout.write("".join(display))
549
+ sys.stdout.flush()
550
+ if done:
551
+ break
552
+ return "".join(chars).strip() or default
553
+ finally:
554
+ try:
555
+ termios.tcsetattr(fd, termios.TCSANOW, old_settings)
556
+ except Exception as exc:
557
+ style.log("WARN", f"prelaunch_terminal_io_failed error={type(exc).__name__}: {exc}")
558
+
559
+
560
+ def prompt_menu_value(
561
+ prompt: str,
562
+ default: str = "",
563
+ secret: bool = False,
564
+ restore_tty: Callable[[], None] | None = None,
565
+ raw_tty: Callable[[], None] | None = None,
566
+ *,
567
+ style: PrelaunchInputStyle,
568
+ ) -> str:
569
+ ansi = style.ansi
570
+ label = f"{prompt}"
571
+ if default:
572
+ label += f" [{default}]"
573
+ label += ": "
574
+ if restore_tty:
575
+ restore_tty()
576
+ if sys.stdout.isatty():
577
+ sys.stdout.write("\033[?25h")
578
+ sys.stdout.flush()
579
+ try:
580
+ raw_value = _prompt_menu_value_raw(label, default, secret, style=style)
581
+ if raw_value is not None:
582
+ value = raw_value
583
+ else:
584
+ sys.stdout.write("\n" + ansi(label, "1;38;5;208"))
585
+ sys.stdout.flush()
586
+ if secret:
587
+ value = getpass.getpass("")
588
+ else:
589
+ value = input()
590
+ finally:
591
+ if sys.stdout.isatty():
592
+ sys.stdout.write("\033[?25l")
593
+ sys.stdout.flush()
594
+ if raw_tty:
595
+ raw_tty()
596
+ value = value.strip()
597
+ return value or default
598
+
599
+
600
+ def _prompt_menu_multiline_value_raw(
601
+ label: str, secret: bool = False, *, style: PrelaunchInputStyle
602
+ ) -> str | None:
603
+ ansi = style.ansi
604
+ """Read a pasted or typed multi-line value from a TTY.
605
+
606
+ A blank line, Ctrl-D, or Esc finishes input. Do not auto-finish on a newline:
607
+ web terminals and SSH relays can deliver a paste one line at a time, so a
608
+ debounce-based finish can incorrectly store only the first line.
609
+ """
610
+ if not sys.stdin.isatty():
611
+ return None
612
+ chars: list[str] = []
613
+ if os.name == "nt":
614
+ try:
615
+ import msvcrt
616
+ except Exception:
617
+ return None
618
+ sys.stdout.write("\n" + ansi(label, "1;38;5;208"))
619
+ sys.stdout.flush()
620
+ while True:
621
+ ch = msvcrt.getwch()
622
+ batch = [ch]
623
+ time.sleep(0.01)
624
+ while msvcrt.kbhit():
625
+ batch.append(msvcrt.getwch())
626
+ for ch in batch:
627
+ if ch == "\x03":
628
+ raise KeyboardInterrupt
629
+ if ch in ("\x04", "\x1b"):
630
+ sys.stdout.write("\n")
631
+ sys.stdout.flush()
632
+ return "".join(chars).strip()
633
+ if ch in ("\r", "\n"):
634
+ chars.append("\n")
635
+ sys.stdout.write("\n")
636
+ continue
637
+ if ch in ("\x08", "\x7f"):
638
+ if chars:
639
+ chars.pop()
640
+ continue
641
+ if ch == "\x15":
642
+ chars.clear()
643
+ continue
644
+ if ch < " ":
645
+ continue
646
+ chars.append(ch)
647
+ if not secret:
648
+ sys.stdout.write(ch)
649
+ sys.stdout.flush()
650
+ text = "".join(chars)
651
+ if text.endswith("\n\n"):
652
+ return text.strip()
653
+ try:
654
+ import codecs
655
+ import select
656
+ import termios
657
+ except Exception:
658
+ return None
659
+ fd = sys.stdin.fileno()
660
+ try:
661
+ old_settings = termios.tcgetattr(fd)
662
+ new_settings = termios.tcgetattr(fd)
663
+ new_settings[3] = new_settings[3] & ~(termios.ECHO | termios.ICANON)
664
+ new_settings[6][termios.VMIN] = 1
665
+ new_settings[6][termios.VTIME] = 0
666
+ termios.tcsetattr(fd, termios.TCSANOW, new_settings)
667
+ except Exception:
668
+ return None
669
+ decoder = codecs.getincrementaldecoder("utf-8")("replace")
670
+ try:
671
+ sys.stdout.write("\n" + ansi(label, "1;38;5;208"))
672
+ sys.stdout.flush()
673
+ while True:
674
+ first = os.read(fd, 1)
675
+ if not first:
676
+ continue
677
+ data = bytearray(first)
678
+ try:
679
+ while select.select([fd], [], [], 0)[0]:
680
+ more = os.read(fd, 4096)
681
+ if not more:
682
+ break
683
+ data.extend(more)
684
+ except Exception as exc:
685
+ style.log("WARN", f"prelaunch_terminal_io_failed error={type(exc).__name__}: {exc}")
686
+ display: list[str] = []
687
+ for byte in data:
688
+ b = bytes((byte,))
689
+ if b == b"\x03":
690
+ raise KeyboardInterrupt
691
+ if b in (b"\x04", b"\x1b"):
692
+ display.append("\n")
693
+ sys.stdout.write("".join(display))
694
+ sys.stdout.flush()
695
+ return "".join(chars).strip()
696
+ if b in (b"\x7f", b"\x08"):
697
+ if chars:
698
+ chars.pop()
699
+ continue
700
+ if b == b"\x15":
701
+ chars.clear()
702
+ continue
703
+ text = decoder.decode(b)
704
+ if not text:
705
+ continue
706
+ for ch in text:
707
+ if ch == "\ufffd":
708
+ continue
709
+ if ch in ("\r", "\n"):
710
+ chars.append("\n")
711
+ display.append("\n")
712
+ elif ch >= " ":
713
+ chars.append(ch)
714
+ if not secret:
715
+ display.append(ch)
716
+ if display:
717
+ sys.stdout.write("".join(display))
718
+ sys.stdout.flush()
719
+ text = "".join(chars)
720
+ if text.endswith("\n\n"):
721
+ return text.strip()
722
+ finally:
723
+ try:
724
+ termios.tcsetattr(fd, termios.TCSANOW, old_settings)
725
+ except Exception as exc:
726
+ style.log("WARN", f"prelaunch_terminal_io_failed error={type(exc).__name__}: {exc}")
727
+
728
+
729
+ def prompt_menu_multiline_value(
730
+ prompt: str,
731
+ restore_tty: Callable[[], None] | None = None,
732
+ raw_tty: Callable[[], None] | None = None,
733
+ secret: bool = True,
734
+ *,
735
+ style: PrelaunchInputStyle,
736
+ ) -> str:
737
+ ansi = style.ansi
738
+ label = f"{prompt} (finish with a blank line): "
739
+ if restore_tty:
740
+ restore_tty()
741
+ if sys.stdout.isatty():
742
+ sys.stdout.write("\033[?25h")
743
+ sys.stdout.flush()
744
+ try:
745
+ raw_value = _prompt_menu_multiline_value_raw(label, secret=secret, style=style)
746
+ if raw_value is not None:
747
+ value = raw_value
748
+ else:
749
+ sys.stdout.write("\n" + ansi(label, "1;38;5;208"))
750
+ sys.stdout.flush()
751
+ lines: list[str] = []
752
+ while True:
753
+ line = input()
754
+ if not line.strip():
755
+ break
756
+ lines.append(line)
757
+ value = "\n".join(lines)
758
+ finally:
759
+ if sys.stdout.isatty():
760
+ sys.stdout.write("\033[?25l")
761
+ sys.stdout.flush()
762
+ if raw_tty:
763
+ raw_tty()
764
+ return value.strip()