klaude-code 1.2.6__py3-none-any.whl → 1.8.0__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 (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,658 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import sys
5
+ from collections.abc import Callable, Coroutine
6
+ from dataclasses import dataclass
7
+ from functools import partial
8
+ from typing import Any, cast
9
+
10
+ from prompt_toolkit.application import Application
11
+ from prompt_toolkit.application.current import get_app
12
+ from prompt_toolkit.buffer import Buffer
13
+ from prompt_toolkit.filters import Always, Condition
14
+ from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent, merge_key_bindings
15
+ from prompt_toolkit.key_binding.defaults import load_key_bindings
16
+ from prompt_toolkit.keys import Keys
17
+ from prompt_toolkit.layout import ConditionalContainer, Float, FloatContainer, HSplit, Layout, VSplit, Window
18
+ from prompt_toolkit.layout.containers import Container, ScrollOffsets
19
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
20
+ from prompt_toolkit.styles import Style, merge_styles
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class SelectItem[T]:
25
+ """One selectable item for terminal selection UI."""
26
+
27
+ title: list[tuple[str, str]]
28
+ value: T
29
+ search_text: str
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Model selection items builder
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ def build_model_select_items(models: list[Any]) -> list[SelectItem[str]]:
38
+ """Build SelectItem list from ModelEntry objects.
39
+
40
+ Args:
41
+ models: List of ModelEntry objects (from config.iter_model_entries).
42
+
43
+ Returns:
44
+ List of SelectItem[str] with model_name as the value.
45
+ """
46
+ if not models:
47
+ return []
48
+
49
+ max_model_name_length = max(len(m.model_name) for m in models)
50
+ num_width = len(str(len(models)))
51
+
52
+ def _thinking_info(m: Any) -> str:
53
+ thinking = m.model_params.thinking
54
+ if not thinking:
55
+ return ""
56
+ if thinking.reasoning_effort:
57
+ return f"reasoning {thinking.reasoning_effort}"
58
+ if thinking.budget_tokens:
59
+ return f"thinking budget {thinking.budget_tokens}"
60
+ return "thinking (configured)"
61
+
62
+ items: list[SelectItem[str]] = []
63
+ for idx, m in enumerate(models, 1):
64
+ model_id = m.model_params.model or "N/A"
65
+ first_line_prefix = f"{m.model_name:<{max_model_name_length}} → "
66
+ thinking_info = _thinking_info(m)
67
+ meta_parts: list[str] = [m.provider]
68
+ if thinking_info:
69
+ meta_parts.append(thinking_info)
70
+ if m.model_params.verbosity:
71
+ meta_parts.append(f"verbosity {m.model_params.verbosity}")
72
+ meta_str = " · ".join(meta_parts)
73
+ title = [
74
+ ("class:meta", f"{idx:>{num_width}}. "),
75
+ ("class:msg", first_line_prefix),
76
+ ("class:msg bold", model_id),
77
+ ("class:meta", f" {meta_str}\n"),
78
+ ]
79
+ search_text = f"{m.model_name} {model_id} {m.provider}"
80
+ items.append(SelectItem(title=title, value=m.model_name, search_text=search_text))
81
+
82
+ return items
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Shared helpers for select_one() and SelectOverlay
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def _restyle_title(title: list[tuple[str, str]], cls: str) -> list[tuple[str, str]]:
91
+ """Re-apply a style class while keeping text attributes like bold/italic."""
92
+ keep_attrs = {"bold", "italic", "underline", "reverse", "blink", "strike"}
93
+ restyled: list[tuple[str, str]] = []
94
+ for old_style, text in title:
95
+ attrs = [tok for tok in old_style.split() if tok in keep_attrs]
96
+ style = f"{cls} {' '.join(attrs)}".strip()
97
+ restyled.append((style, text))
98
+ return restyled
99
+
100
+
101
+ def _indent_multiline_tokens(
102
+ tokens: list[tuple[str, str]],
103
+ indent: str,
104
+ *,
105
+ indent_style: str = "class:text",
106
+ ) -> list[tuple[str, str]]:
107
+ """Indent continuation lines inside formatted tokens.
108
+
109
+ This is needed when an item's title contains embedded newlines. The selector
110
+ prefixes each *item* with the pointer padding, but continuation lines inside
111
+ a single item would otherwise start at column 0.
112
+ """
113
+ if not tokens or all("\n" not in text for _style, text in tokens):
114
+ return tokens
115
+
116
+ def _has_non_newline_text(s: str) -> bool:
117
+ return bool(s.replace("\n", ""))
118
+
119
+ has_text_after_token: list[bool] = [False] * len(tokens)
120
+ remaining = False
121
+ for i in range(len(tokens) - 1, -1, -1):
122
+ has_text_after_token[i] = remaining
123
+ remaining = remaining or _has_non_newline_text(tokens[i][1])
124
+
125
+ out: list[tuple[str, str]] = []
126
+ for token_index, (style, text) in enumerate(tokens):
127
+ if "\n" not in text:
128
+ out.append((style, text))
129
+ continue
130
+
131
+ parts = text.split("\n")
132
+ for part_index, part in enumerate(parts):
133
+ if part:
134
+ out.append((style, part))
135
+
136
+ # If this was a newline, re-add it.
137
+ if part_index < len(parts) - 1:
138
+ out.append((style, "\n"))
139
+
140
+ # Only indent when there is more text remaining within this item.
141
+ has_text_later_in_token = any(p for p in parts[part_index + 1 :])
142
+ if has_text_later_in_token or has_text_after_token[token_index]:
143
+ out.append((indent_style, indent))
144
+
145
+ return out
146
+
147
+
148
+ def _normalize_search_key(value: str) -> str:
149
+ """Normalize a search key for loose matching.
150
+
151
+ This enables aliases like:
152
+ - gpt52 -> gpt-5.2
153
+ - gpt5.2 -> gpt-5.2
154
+
155
+ Strategy: case-fold + keep only alphanumeric characters.
156
+ """
157
+
158
+ return "".join(ch for ch in value.casefold() if ch.isalnum())
159
+
160
+
161
+ def _filter_items[T](
162
+ items: list[SelectItem[T]],
163
+ filter_text: str,
164
+ ) -> tuple[list[int], bool]:
165
+ """Return visible item indices and whether any matched the filter."""
166
+ if not items:
167
+ return [], True
168
+ if not filter_text:
169
+ return list(range(len(items))), True
170
+
171
+ needle = filter_text.casefold()
172
+ needle_norm = _normalize_search_key(filter_text)
173
+
174
+ def _is_match(it: SelectItem[T]) -> bool:
175
+ haystack = it.search_text.casefold()
176
+ if needle in haystack:
177
+ return True
178
+ if needle_norm:
179
+ return needle_norm in _normalize_search_key(it.search_text)
180
+ return False
181
+
182
+ matched = [i for i, it in enumerate(items) if _is_match(it)]
183
+ if matched:
184
+ return matched, True
185
+ return list(range(len(items))), False
186
+
187
+
188
+ def _build_choices_tokens[T](
189
+ items: list[SelectItem[T]],
190
+ visible_indices: list[int],
191
+ pointed_at: int,
192
+ pointer: str,
193
+ *,
194
+ highlight_pointed_item: bool = True,
195
+ ) -> list[tuple[str, str]]:
196
+ """Build formatted tokens for the choice list."""
197
+ if not visible_indices:
198
+ return [("class:text", "(no items)\n")]
199
+
200
+ tokens: list[tuple[str, str]] = []
201
+ pointer_pad = " " * (2 + len(pointer))
202
+ pointed_prefix = f" {pointer} "
203
+
204
+ for pos, idx in enumerate(visible_indices):
205
+ is_pointed = pos == pointed_at
206
+ if is_pointed:
207
+ tokens.append(("class:pointer", pointed_prefix))
208
+ tokens.append(("[SetCursorPosition]", ""))
209
+ else:
210
+ tokens.append(("class:text", pointer_pad))
211
+
212
+ if is_pointed and highlight_pointed_item:
213
+ title_tokens = _restyle_title(items[idx].title, "class:highlighted")
214
+ else:
215
+ title_tokens = items[idx].title
216
+
217
+ title_tokens = _indent_multiline_tokens(title_tokens, pointer_pad)
218
+ tokens.extend(title_tokens)
219
+
220
+ return tokens
221
+
222
+
223
+ def _build_rounded_frame(body: Container) -> HSplit:
224
+ """Build a rounded border frame around the given container."""
225
+ border = partial(Window, style="class:frame.border", height=1)
226
+ top = VSplit(
227
+ [
228
+ border(width=1, char="╭"),
229
+ border(char="─"),
230
+ border(width=1, char="╮"),
231
+ ],
232
+ height=1,
233
+ padding=0,
234
+ )
235
+ middle = VSplit(
236
+ [
237
+ border(width=1, char="│"),
238
+ body,
239
+ border(width=1, char="│"),
240
+ ],
241
+ padding=0,
242
+ )
243
+ bottom = VSplit(
244
+ [
245
+ border(width=1, char="╰"),
246
+ border(char="─"),
247
+ border(width=1, char="╯"),
248
+ ],
249
+ height=1,
250
+ padding=0,
251
+ )
252
+ return HSplit([top, middle, bottom], padding=0, style="class:frame")
253
+
254
+
255
+ def _build_search_container(
256
+ search_buffer: Buffer,
257
+ search_placeholder: str,
258
+ ) -> tuple[Window, Container]:
259
+ """Build the search input container with placeholder."""
260
+ placeholder_text = f"{search_placeholder} · ↑↓ to select · esc to quit"
261
+
262
+ search_prefix_window = Window(
263
+ FormattedTextControl([("class:search_prefix", "/ ")]),
264
+ width=2,
265
+ height=1,
266
+ dont_extend_height=Always(),
267
+ always_hide_cursor=Always(),
268
+ )
269
+ input_window = Window(
270
+ BufferControl(buffer=search_buffer),
271
+ height=1,
272
+ dont_extend_height=Always(),
273
+ style="class:search_input",
274
+ )
275
+ placeholder_window = ConditionalContainer(
276
+ content=Window(
277
+ FormattedTextControl([("class:search_placeholder", placeholder_text)]),
278
+ height=1,
279
+ dont_extend_height=Always(),
280
+ always_hide_cursor=Always(),
281
+ ),
282
+ filter=Condition(lambda: search_buffer.text == ""),
283
+ )
284
+ search_input_container = FloatContainer(
285
+ content=input_window,
286
+ floats=[Float(content=placeholder_window, top=0, left=0)],
287
+ )
288
+ framed = _build_rounded_frame(VSplit([search_prefix_window, search_input_container], padding=0))
289
+ return input_window, framed
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # select_one: standalone single-choice selector
294
+ # ---------------------------------------------------------------------------
295
+
296
+
297
+ def select_one[T](
298
+ *,
299
+ message: str,
300
+ items: list[SelectItem[T]],
301
+ pointer: str = "→",
302
+ style: Style | None = None,
303
+ use_search_filter: bool = True,
304
+ initial_value: T | None = None,
305
+ search_placeholder: str = "type to search",
306
+ highlight_pointed_item: bool = True,
307
+ ) -> T | None:
308
+ """Terminal single-choice selector based on prompt_toolkit."""
309
+ if not items:
310
+ return None
311
+
312
+ # Non-interactive environments should not enter an interactive prompt.
313
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
314
+ return None
315
+
316
+ pointed_at = 0
317
+
318
+ search_buffer: Buffer | None = Buffer() if use_search_filter else None
319
+
320
+ def get_filter_text() -> str:
321
+ return search_buffer.text if (use_search_filter and search_buffer is not None) else ""
322
+
323
+ def get_header_tokens() -> list[tuple[str, str]]:
324
+ return [("class:question", message + " ")]
325
+
326
+ def get_choices_tokens() -> list[tuple[str, str]]:
327
+ nonlocal pointed_at
328
+ indices, _ = _filter_items(items, get_filter_text())
329
+ if indices:
330
+ pointed_at %= len(indices)
331
+ return _build_choices_tokens(
332
+ items,
333
+ indices,
334
+ pointed_at,
335
+ pointer,
336
+ highlight_pointed_item=highlight_pointed_item,
337
+ )
338
+
339
+ def on_search_changed(_buf: Buffer) -> None:
340
+ nonlocal pointed_at
341
+ pointed_at = 0
342
+ with contextlib.suppress(Exception):
343
+ get_app().invalidate()
344
+
345
+ kb = KeyBindings()
346
+
347
+ @kb.add(Keys.ControlC, eager=True)
348
+ @kb.add(Keys.ControlQ, eager=True)
349
+ def _(event: KeyPressEvent) -> None:
350
+ event.app.exit(result=None)
351
+
352
+ @kb.add(Keys.Down, eager=True)
353
+ def _(event: KeyPressEvent) -> None:
354
+ nonlocal pointed_at
355
+ pointed_at += 1
356
+ event.app.invalidate()
357
+
358
+ @kb.add(Keys.Up, eager=True)
359
+ def _(event: KeyPressEvent) -> None:
360
+ nonlocal pointed_at
361
+ pointed_at -= 1
362
+ event.app.invalidate()
363
+
364
+ @kb.add(Keys.Enter, eager=True)
365
+ def _(event: KeyPressEvent) -> None:
366
+ indices, _ = _filter_items(items, get_filter_text())
367
+ if not indices:
368
+ event.app.exit(result=None)
369
+ return
370
+ idx = indices[pointed_at % len(indices)]
371
+ event.app.exit(result=items[idx].value)
372
+
373
+ @kb.add(Keys.Escape, eager=True)
374
+ def _(event: KeyPressEvent) -> None:
375
+ nonlocal pointed_at
376
+ if use_search_filter and search_buffer is not None and search_buffer.text:
377
+ search_buffer.reset(append_to_history=False)
378
+ pointed_at = 0
379
+ event.app.invalidate()
380
+ return
381
+ event.app.exit(result=None)
382
+
383
+ if use_search_filter and search_buffer is not None:
384
+ search_buffer.on_text_changed += on_search_changed
385
+
386
+ if initial_value is not None:
387
+ try:
388
+ full_index = next(i for i, it in enumerate(items) if it.value == initial_value)
389
+ indices, _ = _filter_items(items, get_filter_text()) # pyright: ignore[reportAssignmentType]
390
+ pointed_at = indices.index(full_index) if full_index in indices else 0
391
+ except StopIteration:
392
+ pointed_at = 0
393
+
394
+ header_window = Window(
395
+ FormattedTextControl(get_header_tokens),
396
+ height=1,
397
+ dont_extend_height=Always(),
398
+ always_hide_cursor=Always(),
399
+ )
400
+ spacer_window = Window(
401
+ FormattedTextControl([("", "")]),
402
+ height=1,
403
+ dont_extend_height=Always(),
404
+ always_hide_cursor=Always(),
405
+ )
406
+ list_window = Window(
407
+ FormattedTextControl(get_choices_tokens),
408
+ scroll_offsets=ScrollOffsets(top=0, bottom=2),
409
+ allow_scroll_beyond_bottom=True,
410
+ dont_extend_height=Always(),
411
+ always_hide_cursor=Always(),
412
+ )
413
+
414
+ search_container: Container | None = None
415
+ search_input_window: Window | None = None
416
+ if use_search_filter and search_buffer is not None:
417
+ search_input_window, search_container = _build_search_container(search_buffer, search_placeholder)
418
+
419
+ base_style = Style(
420
+ [
421
+ ("frame.border", "fg:ansibrightblack"),
422
+ ("frame.label", "fg:ansibrightblack italic"),
423
+ ("search_prefix", "fg:ansibrightblack"),
424
+ ("search_placeholder", "fg:ansibrightblack italic"),
425
+ ]
426
+ )
427
+ merged_style = merge_styles([base_style, style] if style is not None else [base_style])
428
+
429
+ root_children: list[Container] = [header_window, spacer_window, list_window]
430
+ if search_container is not None:
431
+ root_children.append(search_container)
432
+
433
+ app: Application[T | None] = Application(
434
+ layout=Layout(HSplit(root_children), focused_element=search_input_window or list_window),
435
+ key_bindings=merge_key_bindings([load_key_bindings(), kb]),
436
+ style=merged_style,
437
+ mouse_support=False,
438
+ full_screen=False,
439
+ erase_when_done=True,
440
+ )
441
+ return app.run()
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # SelectOverlay: embedded overlay for existing prompt_toolkit Application
446
+ # ---------------------------------------------------------------------------
447
+
448
+
449
+ class SelectOverlay[T]:
450
+ """Embedded single-choice selector overlay for an existing prompt_toolkit Application.
451
+
452
+ Unlike `select_one()`, this does not create or run a new Application.
453
+ It is designed for use inside an already-running PromptSession.app.
454
+ """
455
+
456
+ def __init__(
457
+ self,
458
+ *,
459
+ pointer: str = "→",
460
+ use_search_filter: bool = True,
461
+ search_placeholder: str = "type to search",
462
+ list_height: int = 8,
463
+ highlight_pointed_item: bool = True,
464
+ on_select: Callable[[T], Coroutine[Any, Any, None] | None] | None = None,
465
+ on_cancel: Callable[[], Coroutine[Any, Any, None] | None] | None = None,
466
+ ) -> None:
467
+ self._pointer = pointer
468
+ self._use_search_filter = use_search_filter
469
+ self._search_placeholder = search_placeholder
470
+ self._list_height = max(1, list_height)
471
+ self._highlight_pointed_item = highlight_pointed_item
472
+ self._on_select = on_select
473
+ self._on_cancel = on_cancel
474
+
475
+ self._is_open = False
476
+ self._message: str = ""
477
+ self._items: list[SelectItem[T]] = []
478
+ self._pointed_at = 0
479
+
480
+ self._prev_focus: Window | None = None
481
+ self._search_buffer: Buffer | None = Buffer() if use_search_filter else None
482
+
483
+ self._list_window: Window | None = None
484
+ self._search_input_window: Window | None = None
485
+
486
+ self.key_bindings = self._build_key_bindings()
487
+ self.container = self._build_layout()
488
+
489
+ if self._use_search_filter and self._search_buffer is not None:
490
+ self._search_buffer.on_text_changed += self._on_search_changed
491
+
492
+ def _get_filter_text(self) -> str:
493
+ if self._use_search_filter and self._search_buffer is not None:
494
+ return self._search_buffer.text
495
+ return ""
496
+
497
+ def _get_visible_indices(self) -> tuple[list[int], bool]:
498
+ return _filter_items(self._items, self._get_filter_text())
499
+
500
+ def _on_search_changed(self, _buf: Buffer) -> None:
501
+ self._pointed_at = 0
502
+ with contextlib.suppress(Exception):
503
+ get_app().invalidate()
504
+
505
+ def _build_key_bindings(self) -> KeyBindings:
506
+ kb = KeyBindings()
507
+ is_open_filter = Condition(lambda: self._is_open)
508
+
509
+ @kb.add(Keys.Down, filter=is_open_filter, eager=True)
510
+ def _(event: KeyPressEvent) -> None:
511
+ self._pointed_at += 1
512
+ event.app.invalidate()
513
+
514
+ @kb.add(Keys.Up, filter=is_open_filter, eager=True)
515
+ def _(event: KeyPressEvent) -> None:
516
+ self._pointed_at -= 1
517
+ event.app.invalidate()
518
+
519
+ @kb.add(Keys.Enter, filter=is_open_filter, eager=True)
520
+ def _(event: KeyPressEvent) -> None:
521
+ indices, _ = self._get_visible_indices()
522
+ if not indices:
523
+ self.close()
524
+ return
525
+ idx = indices[self._pointed_at % len(indices)]
526
+ value = self._items[idx].value
527
+ self.close()
528
+
529
+ if self._on_select is None:
530
+ return
531
+
532
+ result = self._on_select(value)
533
+ if hasattr(result, "__await__"):
534
+ event.app.create_background_task(cast(Coroutine[Any, Any, None], result))
535
+
536
+ @kb.add(Keys.Escape, filter=is_open_filter, eager=True)
537
+ def _(event: KeyPressEvent) -> None:
538
+ if self._use_search_filter and self._search_buffer is not None and self._search_buffer.text:
539
+ self._search_buffer.reset(append_to_history=False)
540
+ self._pointed_at = 0
541
+ event.app.invalidate()
542
+ return
543
+ self._close_and_invoke_cancel(event)
544
+
545
+ @kb.add(Keys.ControlL, filter=is_open_filter, eager=True)
546
+ def _(event: KeyPressEvent) -> None:
547
+ self.close()
548
+ event.app.invalidate()
549
+
550
+ @kb.add(Keys.ControlC, filter=is_open_filter, eager=True)
551
+ def _(event: KeyPressEvent) -> None:
552
+ self._close_and_invoke_cancel(event)
553
+
554
+ return kb
555
+
556
+ def _close_and_invoke_cancel(self, event: KeyPressEvent) -> None:
557
+ self.close()
558
+ if self._on_cancel is not None:
559
+ result = self._on_cancel()
560
+ if hasattr(result, "__await__"):
561
+ event.app.create_background_task(cast(Coroutine[Any, Any, None], result))
562
+
563
+ def _build_layout(self) -> ConditionalContainer:
564
+ def get_header_tokens() -> list[tuple[str, str]]:
565
+ return [("class:question", self._message + " ")]
566
+
567
+ def get_choices_tokens() -> list[tuple[str, str]]:
568
+ indices, _ = self._get_visible_indices()
569
+ if indices:
570
+ self._pointed_at %= len(indices)
571
+ return _build_choices_tokens(
572
+ self._items,
573
+ indices,
574
+ self._pointed_at,
575
+ self._pointer,
576
+ highlight_pointed_item=self._highlight_pointed_item,
577
+ )
578
+
579
+ header_window = Window(
580
+ FormattedTextControl(get_header_tokens),
581
+ height=1,
582
+ dont_extend_height=Always(),
583
+ always_hide_cursor=Always(),
584
+ )
585
+ spacer_window = Window(
586
+ FormattedTextControl([("", "")]),
587
+ height=1,
588
+ dont_extend_height=Always(),
589
+ always_hide_cursor=Always(),
590
+ )
591
+ list_window = Window(
592
+ FormattedTextControl(get_choices_tokens),
593
+ height=self._list_height,
594
+ scroll_offsets=ScrollOffsets(top=0, bottom=2),
595
+ allow_scroll_beyond_bottom=True,
596
+ dont_extend_height=Always(),
597
+ always_hide_cursor=Always(),
598
+ )
599
+ self._list_window = list_window
600
+
601
+ search_container: Container | None = None
602
+ if self._use_search_filter and self._search_buffer is not None:
603
+ self._search_input_window, search_container = _build_search_container(
604
+ self._search_buffer, self._search_placeholder
605
+ )
606
+
607
+ root_children: list[Container] = [header_window, spacer_window, list_window]
608
+ if search_container is not None:
609
+ root_children.append(search_container)
610
+
611
+ return ConditionalContainer(
612
+ content=HSplit(root_children, padding=0),
613
+ filter=Condition(lambda: self._is_open),
614
+ )
615
+
616
+ @property
617
+ def is_open(self) -> bool:
618
+ return self._is_open
619
+
620
+ def set_content(self, *, message: str, items: list[SelectItem[T]], initial_value: T | None = None) -> None:
621
+ self._message = message
622
+ self._items = items
623
+
624
+ self._pointed_at = 0
625
+ if initial_value is not None:
626
+ try:
627
+ full_index = next(i for i, it in enumerate(items) if it.value == initial_value)
628
+ self._pointed_at = full_index
629
+ except StopIteration:
630
+ self._pointed_at = 0
631
+
632
+ if self._use_search_filter and self._search_buffer is not None:
633
+ self._search_buffer.reset(append_to_history=False)
634
+
635
+ def open(self) -> None:
636
+ if self._is_open:
637
+ return
638
+ self._is_open = True
639
+ app = get_app()
640
+ self._prev_focus = cast(Window | None, getattr(app.layout, "current_window", None))
641
+ with contextlib.suppress(Exception):
642
+ if self._search_input_window is not None:
643
+ app.layout.focus(self._search_input_window)
644
+ elif self._list_window is not None:
645
+ app.layout.focus(self._list_window)
646
+ app.invalidate()
647
+
648
+ def close(self) -> None:
649
+ if not self._is_open:
650
+ return
651
+ self._is_open = False
652
+ app = get_app()
653
+ prev = self._prev_focus
654
+ self._prev_focus = None
655
+ if prev is not None:
656
+ with contextlib.suppress(Exception):
657
+ app.layout.focus(prev)
658
+ app.invalidate()
@@ -2,8 +2,6 @@ import re
2
2
  import subprocess
3
3
  from pathlib import Path
4
4
 
5
- from klaude_code import const
6
-
7
5
  LEADING_NEWLINES_REGEX = re.compile(r"^\n{2,}")
8
6
 
9
7
 
@@ -90,19 +88,3 @@ def show_path_with_tilde(path: Path | None = None):
90
88
  return f"~/{relative_path}"
91
89
  except ValueError:
92
90
  return str(path)
93
-
94
-
95
- def truncate_display(
96
- text: str,
97
- max_lines: int = const.TRUNCATE_DISPLAY_MAX_LINES,
98
- max_line_length: int = const.TRUNCATE_DISPLAY_MAX_LINE_LENGTH,
99
- ) -> str:
100
- lines = text.split("\n")
101
- if len(lines) > max_lines:
102
- lines = lines[:max_lines] + ["… (more " + str(len(lines) - max_lines) + " lines)"]
103
- for i, line in enumerate(lines):
104
- if len(line) > max_line_length:
105
- lines[i] = (
106
- line[:max_line_length] + "… (more " + str(len(line) - max_line_length) + " characters in this line)"
107
- )
108
- return "\n".join(lines)