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
@@ -1,163 +1,615 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import shutil
4
- from collections.abc import AsyncIterator, Callable
5
+ from collections.abc import AsyncIterator, Awaitable, Callable
5
6
  from pathlib import Path
6
7
  from typing import NamedTuple, override
7
8
 
9
+ import prompt_toolkit.layout.menus as pt_menus
8
10
  from prompt_toolkit import PromptSession
11
+ from prompt_toolkit.application.current import get_app
9
12
  from prompt_toolkit.buffer import Buffer
10
- from prompt_toolkit.completion import ThreadedCompleter
13
+ from prompt_toolkit.completion import Completion, ThreadedCompleter
14
+ from prompt_toolkit.cursor_shapes import CursorShape
15
+ from prompt_toolkit.data_structures import Point
11
16
  from prompt_toolkit.filters import Condition
12
- from prompt_toolkit.formatted_text import FormattedText
17
+ from prompt_toolkit.formatted_text import FormattedText, StyleAndTextTuples, to_formatted_text
13
18
  from prompt_toolkit.history import FileHistory
19
+ from prompt_toolkit.key_binding import merge_key_bindings
20
+ from prompt_toolkit.layout import Float
21
+ from prompt_toolkit.layout.containers import Container, FloatContainer, Window
22
+ from prompt_toolkit.layout.controls import BufferControl, UIContent
23
+ from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu
14
24
  from prompt_toolkit.patch_stdout import patch_stdout
15
25
  from prompt_toolkit.styles import Style
16
-
26
+ from prompt_toolkit.utils import get_cwidth
27
+
28
+ from klaude_code.config import load_config
29
+ from klaude_code.config.config import ModelEntry
30
+ from klaude_code.config.thinking import (
31
+ format_current_thinking,
32
+ get_thinking_picker_data,
33
+ parse_thinking_value,
34
+ )
35
+ from klaude_code.protocol import llm_param
36
+ from klaude_code.protocol.commands import CommandInfo
17
37
  from klaude_code.protocol.model import UserInputPayload
18
38
  from klaude_code.ui.core.input import InputProviderABC
19
39
  from klaude_code.ui.modes.repl.clipboard import capture_clipboard_tag, copy_to_clipboard, extract_images_from_text
20
40
  from klaude_code.ui.modes.repl.completers import AT_TOKEN_PATTERN, create_repl_completer
21
41
  from klaude_code.ui.modes.repl.key_bindings import create_key_bindings
22
- from klaude_code.ui.utils.common import get_current_git_branch, show_path_with_tilde
42
+ from klaude_code.ui.renderers.user_input import USER_MESSAGE_MARK
43
+ from klaude_code.ui.terminal.color import is_light_terminal_background
44
+ from klaude_code.ui.terminal.selector import SelectItem, SelectOverlay, build_model_select_items
23
45
 
24
46
 
25
47
  class REPLStatusSnapshot(NamedTuple):
26
48
  """Snapshot of REPL status for bottom toolbar display."""
27
49
 
28
- model_name: str
29
- context_usage_percent: float | None
30
- llm_calls: int
31
- tool_calls: int
32
50
  update_message: str | None = None
33
51
 
34
52
 
35
- COMPLETION_SELECTED = "#5869f7"
53
+ COMPLETION_SELECTED_DARK_BG = "ansigreen"
54
+ COMPLETION_SELECTED_LIGHT_BG = "ansigreen"
55
+ COMPLETION_SELECTED_UNKNOWN_BG = "ansigreen"
36
56
  COMPLETION_MENU = "ansibrightblack"
37
- INPUT_PROMPT_STYLE = "ansimagenta"
57
+ INPUT_PROMPT_STYLE = "ansimagenta bold"
58
+ PLACEHOLDER_TEXT_STYLE_DARK_BG = "fg:#5a5a5a italic"
59
+ PLACEHOLDER_TEXT_STYLE_LIGHT_BG = "fg:#7a7a7a italic"
60
+ PLACEHOLDER_TEXT_STYLE_UNKNOWN_BG = "fg:#8a8a8a italic"
61
+ PLACEHOLDER_SYMBOL_STYLE_DARK_BG = "bg:#2a2a2a fg:#5a5a5a"
62
+ PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG = "bg:#e6e6e6 fg:#7a7a7a"
63
+ PLACEHOLDER_SYMBOL_STYLE_UNKNOWN_BG = "bg:#2a2a2a fg:#8a8a8a"
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Layout helpers
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _left_align_completion_menus(container: Container) -> None:
72
+ """Force completion menus to render at column 0.
73
+
74
+ prompt_toolkit's default completion menu floats are positioned relative to the
75
+ cursor (`xcursor=True`). That makes the popup indent as the caret moves.
76
+ We walk the layout tree and rewrite the Float positioning for completion menus
77
+ to keep them fixed at the left edge.
78
+ """
79
+ if isinstance(container, FloatContainer):
80
+ for flt in container.floats:
81
+ if isinstance(flt.content, (CompletionsMenu, MultiColumnCompletionsMenu)):
82
+ flt.xcursor = False
83
+ flt.left = 0
84
+
85
+ for child in container.get_children():
86
+ _left_align_completion_menus(child)
87
+
88
+
89
+ def _find_first_float_container(container: Container) -> FloatContainer | None:
90
+ if isinstance(container, FloatContainer):
91
+ return container
92
+ for child in container.get_children():
93
+ found = _find_first_float_container(child)
94
+ if found is not None:
95
+ return found
96
+ return None
97
+
98
+
99
+ def _find_window_for_buffer(container: Container, target_buffer: Buffer) -> Window | None:
100
+ if isinstance(container, Window):
101
+ content = container.content
102
+ if isinstance(content, BufferControl) and content.buffer is target_buffer:
103
+ return container
104
+
105
+ for child in container.get_children():
106
+ found = _find_window_for_buffer(child, target_buffer)
107
+ if found is not None:
108
+ return found
109
+ return None
110
+
111
+
112
+ def _patch_completion_menu_controls(container: Container) -> None:
113
+ """Replace prompt_toolkit completion menu controls with customized versions."""
114
+ if isinstance(container, Window):
115
+ content = container.content
116
+ if isinstance(content, pt_menus.CompletionsMenuControl) and not isinstance(
117
+ content, _KlaudeCompletionsMenuControl
118
+ ):
119
+ container.content = _KlaudeCompletionsMenuControl()
120
+
121
+ for child in container.get_children():
122
+ _patch_completion_menu_controls(child)
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Custom completion menu control
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ class _KlaudeCompletionsMenuControl(pt_menus.CompletionsMenuControl):
131
+ """CompletionsMenuControl with stable 2-char left prefix.
132
+
133
+ Requirements:
134
+ - Add a 2-character prefix for every row.
135
+ - Render "-> " for the selected row, and " " for non-selected rows.
136
+
137
+ Keep completion text unstyled so that the menu's current-row style can
138
+ override it entirely.
139
+ """
140
+
141
+ _PREFIX_WIDTH = 2
142
+
143
+ def _get_menu_width(self, max_width: int, complete_state: pt_menus.CompletionState) -> int: # pyright: ignore[reportPrivateImportUsage]
144
+ """Return the width of the main column.
145
+
146
+ This is prompt_toolkit's default implementation, except we reserve one
147
+ extra character for the 2-char prefix ("-> "/" ").
148
+ """
149
+ return min(
150
+ max_width,
151
+ max(
152
+ self.MIN_WIDTH,
153
+ max(get_cwidth(c.display_text) for c in complete_state.completions) + 3,
154
+ ),
155
+ )
156
+
157
+ def create_content(self, width: int, height: int) -> UIContent:
158
+ complete_state = get_app().current_buffer.complete_state
159
+ if complete_state:
160
+ completions = complete_state.completions
161
+ index = complete_state.complete_index
162
+
163
+ menu_width = self._get_menu_width(width, complete_state)
164
+ menu_meta_width = self._get_menu_meta_width(width - menu_width, complete_state)
165
+ show_meta = self._show_meta(complete_state)
166
+
167
+ def get_line(i: int) -> StyleAndTextTuples:
168
+ completion = completions[i]
169
+ is_current_completion = i == index
170
+
171
+ result = self._get_menu_item_fragments_with_cursor(
172
+ completion,
173
+ is_current_completion,
174
+ menu_width,
175
+ space_after=True,
176
+ )
177
+ if show_meta:
178
+ result += self._get_menu_item_meta_fragments(
179
+ completion,
180
+ is_current_completion,
181
+ menu_meta_width,
182
+ )
183
+ return result
184
+
185
+ return UIContent(
186
+ get_line=get_line,
187
+ cursor_position=Point(x=0, y=index or 0),
188
+ line_count=len(completions),
189
+ )
190
+
191
+ return UIContent()
192
+
193
+ def _get_menu_item_fragments_with_cursor(
194
+ self,
195
+ completion: Completion,
196
+ is_current_completion: bool,
197
+ width: int,
198
+ *,
199
+ space_after: bool = False,
200
+ ) -> StyleAndTextTuples:
201
+ if is_current_completion:
202
+ style_str = f"class:completion-menu.completion.current {completion.style} {completion.selected_style}"
203
+ prefix = "→ "
204
+ else:
205
+ style_str = "class:completion-menu.completion " + completion.style
206
+ prefix = " "
207
+
208
+ max_text_width = width - self._PREFIX_WIDTH - (1 if space_after else 0)
209
+ text, text_width = pt_menus._trim_formatted_text(completion.display, max_text_width) # pyright: ignore[reportPrivateUsage]
210
+ padding = " " * (width - self._PREFIX_WIDTH - text_width)
211
+
212
+ return to_formatted_text(
213
+ [("", prefix), *text, ("", padding)],
214
+ style=style_str,
215
+ )
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # PromptToolkitInput
220
+ # ---------------------------------------------------------------------------
38
221
 
39
222
 
40
223
  class PromptToolkitInput(InputProviderABC):
41
224
  def __init__(
42
225
  self,
43
- prompt: str = "❯ ",
226
+ prompt: str = USER_MESSAGE_MARK,
44
227
  status_provider: Callable[[], REPLStatusSnapshot] | None = None,
45
- ): #
228
+ pre_prompt: Callable[[], None] | None = None,
229
+ post_prompt: Callable[[], None] | None = None,
230
+ is_light_background: bool | None = None,
231
+ on_change_model: Callable[[str], Awaitable[None]] | None = None,
232
+ get_current_model_config_name: Callable[[], str | None] | None = None,
233
+ on_change_thinking: Callable[[llm_param.Thinking], Awaitable[None]] | None = None,
234
+ get_current_llm_config: Callable[[], llm_param.LLMConfigParameter | None] | None = None,
235
+ command_info_provider: Callable[[], list[CommandInfo]] | None = None,
236
+ ):
46
237
  self._status_provider = status_provider
238
+ self._pre_prompt = pre_prompt
239
+ self._post_prompt = post_prompt
240
+ self._on_change_model = on_change_model
241
+ self._get_current_model_config_name = get_current_model_config_name
242
+ self._on_change_thinking = on_change_thinking
243
+ self._get_current_llm_config = get_current_llm_config
244
+ self._command_info_provider = command_info_provider
245
+
246
+ # Use provided value if available to avoid redundant TTY queries that may interfere
247
+ # with prompt_toolkit's terminal state after interactive UIs have been used.
248
+ self._is_light_terminal_background = (
249
+ is_light_background if is_light_background is not None else is_light_terminal_background(timeout=0.2)
250
+ )
47
251
 
48
- # Mouse is disabled by default; only enabled when input becomes multi-line.
49
- self._mouse_enabled: bool = False
252
+ self._session = self._build_prompt_session(prompt)
253
+ self._setup_model_picker()
254
+ self._setup_thinking_picker()
255
+ self._apply_layout_customizations()
50
256
 
257
+ def _build_prompt_session(self, prompt: str) -> PromptSession[str]:
258
+ """Build the prompt_toolkit PromptSession with key bindings and styles."""
51
259
  project = str(Path.cwd()).strip("/").replace("/", "-")
52
- history_path = Path.home() / ".klaude" / "projects" / f"{project}" / "input_history.txt"
53
-
54
- if not history_path.parent.exists():
55
- history_path.parent.mkdir(parents=True, exist_ok=True)
56
- if not history_path.exists():
57
- history_path.touch()
58
-
59
- mouse_support_filter = Condition(lambda: self._mouse_enabled)
260
+ history_path = Path.home() / ".klaude" / "projects" / project / "input" / "input_history.txt"
261
+ history_path.parent.mkdir(parents=True, exist_ok=True)
262
+ history_path.touch(exist_ok=True)
263
+
264
+ # Model and thinking pickers will be set up later; create placeholder condition
265
+ self._model_picker: SelectOverlay[str] | None = None
266
+ self._thinking_picker: SelectOverlay[str] | None = None
267
+ input_enabled = Condition(
268
+ lambda: (self._model_picker is None or not self._model_picker.is_open)
269
+ and (self._thinking_picker is None or not self._thinking_picker.is_open)
270
+ )
60
271
 
61
- # Create key bindings with injected dependencies
62
272
  kb = create_key_bindings(
63
273
  capture_clipboard_tag=capture_clipboard_tag,
64
274
  copy_to_clipboard=copy_to_clipboard,
65
275
  at_token_pattern=AT_TOKEN_PATTERN,
276
+ input_enabled=input_enabled,
277
+ open_model_picker=self._open_model_picker,
278
+ open_thinking_picker=self._open_thinking_picker,
66
279
  )
67
280
 
68
- self._session: PromptSession[str] = PromptSession(
281
+ # Select completion selected color based on terminal background
282
+ if self._is_light_terminal_background is True:
283
+ completion_selected = COMPLETION_SELECTED_LIGHT_BG
284
+ elif self._is_light_terminal_background is False:
285
+ completion_selected = COMPLETION_SELECTED_DARK_BG
286
+ else:
287
+ completion_selected = COMPLETION_SELECTED_UNKNOWN_BG
288
+
289
+ return PromptSession(
69
290
  [(INPUT_PROMPT_STYLE, prompt)],
70
- history=FileHistory(history_path),
291
+ history=FileHistory(str(history_path)),
71
292
  multiline=True,
293
+ cursor=CursorShape.BLINKING_BEAM,
72
294
  prompt_continuation=[(INPUT_PROMPT_STYLE, " ")],
73
295
  key_bindings=kb,
74
- completer=ThreadedCompleter(create_repl_completer()),
296
+ completer=ThreadedCompleter(create_repl_completer(command_info_provider=self._command_info_provider)),
75
297
  complete_while_typing=True,
76
298
  erase_when_done=True,
77
- bottom_toolbar=self._render_bottom_toolbar,
78
- mouse_support=mouse_support_filter,
299
+ mouse_support=False,
79
300
  style=Style.from_dict(
80
301
  {
81
302
  "completion-menu": "bg:default",
82
303
  "completion-menu.border": "bg:default",
83
304
  "scrollbar.background": "bg:default",
84
305
  "scrollbar.button": "bg:default",
85
- "completion-menu.completion": f"bg:default fg:{COMPLETION_MENU}",
306
+ "completion-menu.completion": "bg:default fg:default",
86
307
  "completion-menu.meta.completion": f"bg:default fg:{COMPLETION_MENU}",
87
- "completion-menu.completion.current": f"noreverse bg:default fg:{COMPLETION_SELECTED} bold",
88
- "completion-menu.meta.completion.current": f"bg:default fg:{COMPLETION_SELECTED} bold",
308
+ "completion-menu.completion.current": f"noreverse bg:default fg:{completion_selected}",
309
+ "completion-menu.meta.completion.current": f"bg:default fg:{completion_selected}",
310
+ # Embedded selector overlay styles
311
+ "pointer": "ansigreen",
312
+ "highlighted": "ansigreen",
313
+ "text": "ansibrightblack",
314
+ "question": "bold",
315
+ "msg": "",
316
+ "meta": "fg:ansibrightblack",
317
+ "frame.border": "fg:ansibrightblack",
318
+ "search_prefix": "fg:ansibrightblack",
319
+ "search_placeholder": "fg:ansibrightblack italic",
320
+ "search_input": "",
321
+ # Empty bottom-toolbar style
322
+ "bottom-toolbar": "bg:default fg:default noreverse",
323
+ "bottom-toolbar.text": "bg:default fg:default noreverse",
89
324
  }
90
325
  ),
91
326
  )
92
327
 
328
+ def _setup_model_picker(self) -> None:
329
+ """Initialize the model picker overlay and attach it to the layout."""
330
+ model_picker = SelectOverlay[str](
331
+ pointer="→",
332
+ use_search_filter=True,
333
+ search_placeholder="type to search",
334
+ list_height=10,
335
+ on_select=self._handle_model_selected,
336
+ )
337
+ self._model_picker = model_picker
338
+
339
+ # Merge overlay key bindings with existing session key bindings
340
+ existing_kb = self._session.key_bindings
341
+ if existing_kb is not None:
342
+ merged_kb = merge_key_bindings([existing_kb, model_picker.key_bindings])
343
+ self._session.key_bindings = merged_kb
344
+
345
+ # Attach overlay as a float above the prompt
346
+ with contextlib.suppress(Exception):
347
+ root = self._session.app.layout.container
348
+ overlay_float = Float(content=model_picker.container, bottom=1, left=0)
349
+
350
+ # Always attach this overlay at the top level so it is not clipped by
351
+ # small nested FloatContainers (e.g. the completion-menu container).
352
+ if isinstance(root, FloatContainer):
353
+ root.floats.append(overlay_float)
354
+ else:
355
+ self._session.app.layout.container = FloatContainer(content=root, floats=[overlay_float])
356
+
357
+ def _setup_thinking_picker(self) -> None:
358
+ """Initialize the thinking picker overlay and attach it to the layout."""
359
+ thinking_picker = SelectOverlay[str](
360
+ pointer="→",
361
+ use_search_filter=False,
362
+ list_height=6,
363
+ on_select=self._handle_thinking_selected,
364
+ )
365
+ self._thinking_picker = thinking_picker
366
+
367
+ # Merge overlay key bindings with existing session key bindings
368
+ existing_kb = self._session.key_bindings
369
+ if existing_kb is not None:
370
+ merged_kb = merge_key_bindings([existing_kb, thinking_picker.key_bindings])
371
+ self._session.key_bindings = merged_kb
372
+
373
+ # Attach overlay as a float above the prompt
374
+ with contextlib.suppress(Exception):
375
+ root = self._session.app.layout.container
376
+ overlay_float = Float(content=thinking_picker.container, bottom=1, left=0)
377
+
378
+ if isinstance(root, FloatContainer):
379
+ root.floats.append(overlay_float)
380
+ else:
381
+ self._session.app.layout.container = FloatContainer(content=root, floats=[overlay_float])
382
+
383
+ def _apply_layout_customizations(self) -> None:
384
+ """Apply layout customizations after session is created."""
385
+ # Make the Escape key feel responsive
386
+ with contextlib.suppress(Exception):
387
+ self._session.app.ttimeoutlen = 0.05
388
+
389
+ # Keep completion popups left-aligned
390
+ with contextlib.suppress(Exception):
391
+ _left_align_completion_menus(self._session.app.layout.container)
392
+
393
+ # Customize completion rendering
394
+ with contextlib.suppress(Exception):
395
+ _patch_completion_menu_controls(self._session.app.layout.container)
396
+
397
+ # Reserve more vertical space while the model picker overlay is open.
398
+ # prompt_toolkit's default multiline prompt caps out at ~9 lines.
399
+ self._patch_prompt_height_for_model_picker()
400
+
401
+ # Ensure completion menu has default selection
402
+ self._session.default_buffer.on_completions_changed += self._select_first_completion_on_open # pyright: ignore[reportUnknownMemberType]
403
+
404
+ def _patch_prompt_height_for_model_picker(self) -> None:
405
+ if self._model_picker is None and self._thinking_picker is None:
406
+ return
407
+
408
+ with contextlib.suppress(Exception):
409
+ root = self._session.app.layout.container
410
+ input_window = _find_window_for_buffer(root, self._session.default_buffer)
411
+ if input_window is None:
412
+ return
413
+
414
+ original_height = input_window.height
415
+
416
+ def _height(): # type: ignore[no-untyped-def]
417
+ picker_open = (self._model_picker is not None and self._model_picker.is_open) or (
418
+ self._thinking_picker is not None and self._thinking_picker.is_open
419
+ )
420
+ if picker_open:
421
+ # Target 20 rows, but cap to the current terminal size.
422
+ # Leave a small buffer to avoid triggering "Window too small".
423
+ try:
424
+ rows = get_app().output.get_size().rows
425
+ except Exception:
426
+ rows = 0
427
+ return max(3, min(20, rows - 2))
428
+
429
+ if callable(original_height):
430
+ return original_height()
431
+ return original_height
432
+
433
+ input_window.height = _height
434
+
435
+ def _select_first_completion_on_open(self, buf) -> None: # type: ignore[no-untyped-def]
436
+ """Default to selecting the first completion without inserting it."""
93
437
  try:
94
- self._session.default_buffer.on_text_changed += self._on_buffer_text_changed
438
+ state = buf.complete_state # type: ignore[reportUnknownMemberType]
439
+ if state is None:
440
+ return
441
+ if not state.completions: # type: ignore[reportUnknownMemberType]
442
+ return
443
+ if state.complete_index is None: # type: ignore[reportUnknownMemberType]
444
+ state.complete_index = 0 # type: ignore[reportUnknownMemberType]
445
+ with contextlib.suppress(Exception):
446
+ self._session.app.invalidate()
95
447
  except Exception:
96
- # If we can't hook the buffer events for any reason, fall back to static behavior.
97
- pass
448
+ return
449
+
450
+ # -------------------------------------------------------------------------
451
+ # Model picker
452
+ # -------------------------------------------------------------------------
453
+
454
+ def _build_model_picker_items(self) -> tuple[list[SelectItem[str]], str | None]:
455
+ config = load_config()
456
+ models: list[ModelEntry] = sorted(
457
+ config.iter_model_entries(only_available=True),
458
+ key=lambda m: m.model_name.lower(),
459
+ )
460
+ if not models:
461
+ return [], None
462
+
463
+ items = build_model_select_items(models)
464
+
465
+ initial = None
466
+ if self._get_current_model_config_name is not None:
467
+ with contextlib.suppress(Exception):
468
+ initial = self._get_current_model_config_name()
469
+ if initial is None:
470
+ initial = config.main_model
471
+ return items, initial
98
472
 
99
- def _render_bottom_toolbar(self) -> FormattedText:
100
- """Render bottom toolbar with working directory, git branch on left, model name and context usage on right.
473
+ def _open_model_picker(self) -> None:
474
+ if self._model_picker is None:
475
+ return
476
+ items, initial = self._build_model_picker_items()
477
+ if not items:
478
+ return
479
+ self._model_picker.set_content(message="Select a model:", items=items, initial_value=initial)
480
+ self._model_picker.open()
481
+
482
+ async def _handle_model_selected(self, model_name: str) -> None:
483
+ current = None
484
+ if self._get_current_model_config_name is not None:
485
+ with contextlib.suppress(Exception):
486
+ current = self._get_current_model_config_name()
487
+ if current is not None and model_name == current:
488
+ return
489
+ if self._on_change_model is None:
490
+ return
491
+ await self._on_change_model(model_name)
492
+
493
+ # -------------------------------------------------------------------------
494
+ # Thinking picker
495
+ # -------------------------------------------------------------------------
496
+
497
+ def _build_thinking_picker_items(
498
+ self, config: llm_param.LLMConfigParameter
499
+ ) -> tuple[list[SelectItem[str]], str | None]:
500
+ data = get_thinking_picker_data(config)
501
+ if data is None:
502
+ return [], None
503
+
504
+ items: list[SelectItem[str]] = [
505
+ SelectItem(title=[("class:text", opt.label + "\n")], value=opt.value, search_text=opt.label)
506
+ for opt in data.options
507
+ ]
508
+ return items, data.current_value
509
+
510
+ def _open_thinking_picker(self) -> None:
511
+ if self._thinking_picker is None:
512
+ return
513
+ if self._get_current_llm_config is None:
514
+ return
515
+ config = self._get_current_llm_config()
516
+ if config is None:
517
+ return
518
+ items, initial = self._build_thinking_picker_items(config)
519
+ if not items:
520
+ return
521
+ current = format_current_thinking(config)
522
+ self._thinking_picker.set_content(
523
+ message=f"Select thinking level (current: {current}):", items=items, initial_value=initial
524
+ )
525
+ self._thinking_picker.open()
101
526
 
102
- If an update is available, only show the update message on the left side.
527
+ async def _handle_thinking_selected(self, value: str) -> None:
528
+ if self._on_change_thinking is None:
529
+ return
530
+
531
+ new_thinking = parse_thinking_value(value)
532
+ if new_thinking is None:
533
+ return
534
+ await self._on_change_thinking(new_thinking)
535
+
536
+ # -------------------------------------------------------------------------
537
+ # Bottom toolbar
538
+ # -------------------------------------------------------------------------
539
+
540
+ def _get_bottom_toolbar(self) -> FormattedText | None:
541
+ """Return bottom toolbar content.
542
+
543
+ This is used inside the prompt_toolkit Application, so avoid printing or
544
+ doing any blocking IO here.
103
545
  """
104
- # Check for update message first
105
546
  update_message: str | None = None
106
- if self._status_provider:
547
+ if self._status_provider is not None:
107
548
  try:
108
549
  status = self._status_provider()
109
550
  update_message = status.update_message
110
- except Exception:
111
- pass
551
+ except (AttributeError, RuntimeError):
552
+ update_message = None
112
553
 
113
- # If update available, show only the update message
114
- if update_message:
115
- left_text = " " + update_message
554
+ # If nothing to show, return a blank line to actively clear any previously
555
+ # rendered content. (When `bottom_toolbar` is a callable, prompt_toolkit
556
+ # will still reserve the toolbar line.)
557
+ if not update_message:
116
558
  try:
117
559
  terminal_width = shutil.get_terminal_size().columns
118
- padding = " " * max(0, terminal_width - len(left_text))
119
- except Exception:
120
- padding = ""
121
- toolbar_text = left_text + padding
122
- return FormattedText([("#ansiyellow", toolbar_text)])
123
-
124
- # Normal mode: Left side: path and git branch
125
- left_parts: list[str] = []
126
- left_parts.append(show_path_with_tilde())
127
-
128
- git_branch = get_current_git_branch()
129
- if git_branch:
130
- left_parts.append(git_branch)
131
-
132
- # Right side: status info
133
- right_parts: list[str] = []
134
- if self._status_provider:
135
- try:
136
- status = self._status_provider()
137
- model_name = status.model_name or "N/A"
138
- right_parts.append(model_name)
139
-
140
- # Add context if available
141
- if status.context_usage_percent is not None:
142
- right_parts.append(f"context {status.context_usage_percent:.1f}%")
143
- except Exception:
144
- pass
560
+ except (OSError, ValueError):
561
+ terminal_width = 0
562
+ return FormattedText([("", " " * max(0, terminal_width))])
145
563
 
146
- # Build left and right text with borders
147
- left_text = " " + " · ".join(left_parts)
148
- right_text = (" · ".join(right_parts) + " ") if right_parts else " "
149
-
150
- # Calculate padding
564
+ left_text = " " + update_message
151
565
  try:
152
566
  terminal_width = shutil.get_terminal_size().columns
153
- used_width = len(left_text) + len(right_text)
154
- padding = " " * max(0, terminal_width - used_width)
155
- except Exception:
567
+ padding = " " * max(0, terminal_width - len(left_text))
568
+ except (OSError, ValueError):
156
569
  padding = ""
157
570
 
158
- # Build result with style
159
- toolbar_text = left_text + padding + right_text
160
- return FormattedText([("#ansiblue", toolbar_text)])
571
+ toolbar_text = left_text + padding
572
+ return FormattedText([("#ansiyellow", toolbar_text)])
573
+
574
+ # -------------------------------------------------------------------------
575
+ # Placeholder
576
+ # -------------------------------------------------------------------------
577
+
578
+ def _render_input_placeholder(self) -> FormattedText:
579
+ if self._is_light_terminal_background is True:
580
+ text_style = PLACEHOLDER_TEXT_STYLE_LIGHT_BG
581
+ symbol_style = PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG
582
+ elif self._is_light_terminal_background is False:
583
+ text_style = PLACEHOLDER_TEXT_STYLE_DARK_BG
584
+ symbol_style = PLACEHOLDER_SYMBOL_STYLE_DARK_BG
585
+ else:
586
+ text_style = PLACEHOLDER_TEXT_STYLE_UNKNOWN_BG
587
+ symbol_style = PLACEHOLDER_SYMBOL_STYLE_UNKNOWN_BG
588
+
589
+ return FormattedText(
590
+ [
591
+ (text_style, " " * 10),
592
+ (symbol_style, " @ "),
593
+ (text_style, " "),
594
+ (text_style, "files"),
595
+ (text_style, " "),
596
+ (symbol_style, " $ "),
597
+ (text_style, " "),
598
+ (text_style, "skills"),
599
+ (text_style, " "),
600
+ (symbol_style, " / "),
601
+ (text_style, " "),
602
+ (text_style, "commands"),
603
+ (text_style, " "),
604
+ (symbol_style, " ctrl-l "),
605
+ (text_style, " "),
606
+ (text_style, "models"),
607
+ ]
608
+ )
609
+
610
+ # -------------------------------------------------------------------------
611
+ # InputProviderABC implementation
612
+ # -------------------------------------------------------------------------
161
613
 
162
614
  async def start(self) -> None:
163
615
  pass
@@ -168,31 +620,26 @@ class PromptToolkitInput(InputProviderABC):
168
620
  @override
169
621
  async def iter_inputs(self) -> AsyncIterator[UserInputPayload]:
170
622
  while True:
171
- # For each new prompt, start with mouse disabled so users can select history.
172
- self._mouse_enabled = False
173
- with patch_stdout():
174
- line: str = await self._session.prompt_async()
623
+ if self._pre_prompt is not None:
624
+ with contextlib.suppress(Exception):
625
+ self._pre_prompt()
626
+
627
+ # Keep ANSI escape sequences intact while prompt_toolkit is active.
628
+ # This allows Rich-rendered panels (e.g. WelcomeEvent) to display with
629
+ # proper styling instead of showing raw escape codes.
630
+ with patch_stdout(raw=True):
631
+ line: str = await self._session.prompt_async(
632
+ placeholder=self._render_input_placeholder(),
633
+ bottom_toolbar=self._get_bottom_toolbar,
634
+ )
635
+ if self._post_prompt is not None:
636
+ with contextlib.suppress(Exception):
637
+ self._post_prompt()
175
638
 
176
639
  # Extract images referenced in the input text
177
640
  images = extract_images_from_text(line)
178
641
 
179
642
  yield UserInputPayload(text=line, images=images if images else None)
180
643
 
181
- def _on_buffer_text_changed(self, buf: Buffer) -> None:
182
- """Toggle mouse support based on current buffer content.
183
-
184
- Mouse stays disabled when input is empty. It is enabled only when
185
- the user has entered more than one line of text.
186
- """
187
- try:
188
- text = buf.text
189
- except Exception:
190
- return
191
- self._mouse_enabled = self._should_enable_mouse(text)
192
-
193
- def _should_enable_mouse(self, text: str) -> bool:
194
- """Return True when mouse support should be enabled for current input."""
195
- if not text.strip():
196
- return False
197
- # Enable mouse only when input spans multiple lines.
198
- return "\n" in text
644
+ # Note: Mouse support is intentionally disabled at the PromptSession
645
+ # level so that terminals retain their native scrollback behavior.