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.
- klaude_code/auth/__init__.py +24 -0
- klaude_code/auth/codex/__init__.py +20 -0
- klaude_code/auth/codex/exceptions.py +17 -0
- klaude_code/auth/codex/jwt_utils.py +45 -0
- klaude_code/auth/codex/oauth.py +229 -0
- klaude_code/auth/codex/token_manager.py +84 -0
- klaude_code/cli/auth_cmd.py +73 -0
- klaude_code/cli/config_cmd.py +91 -0
- klaude_code/cli/cost_cmd.py +338 -0
- klaude_code/cli/debug.py +78 -0
- klaude_code/cli/list_model.py +307 -0
- klaude_code/cli/main.py +233 -134
- klaude_code/cli/runtime.py +309 -117
- klaude_code/{version.py → cli/self_update.py} +114 -5
- klaude_code/cli/session_cmd.py +37 -21
- klaude_code/command/__init__.py +88 -27
- klaude_code/command/clear_cmd.py +8 -7
- klaude_code/command/command_abc.py +31 -31
- klaude_code/command/debug_cmd.py +79 -0
- klaude_code/command/export_cmd.py +19 -53
- klaude_code/command/export_online_cmd.py +154 -0
- klaude_code/command/fork_session_cmd.py +267 -0
- klaude_code/command/help_cmd.py +7 -8
- klaude_code/command/model_cmd.py +60 -10
- klaude_code/command/model_select.py +84 -0
- klaude_code/command/prompt-jj-describe.md +32 -0
- klaude_code/command/prompt_command.py +19 -11
- klaude_code/command/refresh_cmd.py +8 -10
- klaude_code/command/registry.py +139 -40
- klaude_code/command/release_notes_cmd.py +84 -0
- klaude_code/command/resume_cmd.py +111 -0
- klaude_code/command/status_cmd.py +104 -60
- klaude_code/command/terminal_setup_cmd.py +7 -9
- klaude_code/command/thinking_cmd.py +98 -0
- klaude_code/config/__init__.py +14 -6
- klaude_code/config/assets/__init__.py +1 -0
- klaude_code/config/assets/builtin_config.yaml +303 -0
- klaude_code/config/builtin_config.py +38 -0
- klaude_code/config/config.py +378 -109
- klaude_code/config/select_model.py +117 -53
- klaude_code/config/thinking.py +269 -0
- klaude_code/{const/__init__.py → const.py} +50 -19
- klaude_code/core/agent.py +20 -28
- klaude_code/core/executor.py +327 -112
- klaude_code/core/manager/__init__.py +2 -4
- klaude_code/core/manager/llm_clients.py +1 -15
- klaude_code/core/manager/llm_clients_builder.py +10 -11
- klaude_code/core/manager/sub_agent_manager.py +37 -6
- klaude_code/core/prompt.py +63 -44
- klaude_code/core/prompts/prompt-claude-code.md +2 -13
- klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
- klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
- klaude_code/core/prompts/prompt-codex.md +9 -42
- klaude_code/core/prompts/prompt-minimal.md +12 -0
- klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
- klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
- klaude_code/core/reminders.py +283 -95
- klaude_code/core/task.py +113 -75
- klaude_code/core/tool/__init__.py +24 -31
- klaude_code/core/tool/file/_utils.py +36 -0
- klaude_code/core/tool/file/apply_patch.py +17 -25
- klaude_code/core/tool/file/apply_patch_tool.py +57 -77
- klaude_code/core/tool/file/diff_builder.py +151 -0
- klaude_code/core/tool/file/edit_tool.py +50 -63
- klaude_code/core/tool/file/move_tool.md +41 -0
- klaude_code/core/tool/file/move_tool.py +435 -0
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +86 -86
- klaude_code/core/tool/file/write_tool.py +59 -69
- klaude_code/core/tool/report_back_tool.py +84 -0
- klaude_code/core/tool/shell/bash_tool.py +265 -22
- klaude_code/core/tool/shell/command_safety.py +3 -6
- klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
- klaude_code/core/tool/sub_agent_tool.py +13 -2
- klaude_code/core/tool/todo/todo_write_tool.md +0 -157
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
- klaude_code/core/tool/todo/update_plan_tool.py +1 -1
- klaude_code/core/tool/tool_abc.py +18 -0
- klaude_code/core/tool/tool_context.py +27 -12
- klaude_code/core/tool/tool_registry.py +7 -7
- klaude_code/core/tool/tool_runner.py +44 -36
- klaude_code/core/tool/truncation.py +29 -14
- klaude_code/core/tool/web/mermaid_tool.md +43 -0
- klaude_code/core/tool/web/mermaid_tool.py +2 -5
- klaude_code/core/tool/web/web_fetch_tool.md +1 -1
- klaude_code/core/tool/web/web_fetch_tool.py +112 -22
- klaude_code/core/tool/web/web_search_tool.md +23 -0
- klaude_code/core/tool/web/web_search_tool.py +130 -0
- klaude_code/core/turn.py +168 -66
- klaude_code/llm/__init__.py +2 -10
- klaude_code/llm/anthropic/client.py +190 -178
- klaude_code/llm/anthropic/input.py +39 -15
- klaude_code/llm/bedrock/__init__.py +3 -0
- klaude_code/llm/bedrock/client.py +60 -0
- klaude_code/llm/client.py +7 -21
- klaude_code/llm/codex/__init__.py +5 -0
- klaude_code/llm/codex/client.py +149 -0
- klaude_code/llm/google/__init__.py +3 -0
- klaude_code/llm/google/client.py +309 -0
- klaude_code/llm/google/input.py +215 -0
- klaude_code/llm/input_common.py +3 -9
- klaude_code/llm/openai_compatible/client.py +72 -164
- klaude_code/llm/openai_compatible/input.py +6 -4
- klaude_code/llm/openai_compatible/stream.py +273 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
- klaude_code/llm/openrouter/client.py +89 -160
- klaude_code/llm/openrouter/input.py +18 -30
- klaude_code/llm/openrouter/reasoning.py +118 -0
- klaude_code/llm/registry.py +39 -7
- klaude_code/llm/responses/client.py +184 -171
- klaude_code/llm/responses/input.py +20 -1
- klaude_code/llm/usage.py +17 -12
- klaude_code/protocol/commands.py +17 -1
- klaude_code/protocol/events.py +31 -4
- klaude_code/protocol/llm_param.py +13 -10
- klaude_code/protocol/model.py +232 -29
- klaude_code/protocol/op.py +90 -1
- klaude_code/protocol/op_handler.py +35 -1
- klaude_code/protocol/sub_agent/__init__.py +117 -0
- klaude_code/protocol/sub_agent/explore.py +63 -0
- klaude_code/protocol/sub_agent/oracle.py +91 -0
- klaude_code/protocol/sub_agent/task.py +61 -0
- klaude_code/protocol/sub_agent/web.py +79 -0
- klaude_code/protocol/tools.py +4 -2
- klaude_code/session/__init__.py +2 -2
- klaude_code/session/codec.py +71 -0
- klaude_code/session/export.py +293 -86
- klaude_code/session/selector.py +89 -67
- klaude_code/session/session.py +320 -309
- klaude_code/session/store.py +220 -0
- klaude_code/session/templates/export_session.html +595 -83
- klaude_code/session/templates/mermaid_viewer.html +926 -0
- klaude_code/skill/__init__.py +27 -0
- klaude_code/skill/assets/deslop/SKILL.md +17 -0
- klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
- klaude_code/skill/assets/handoff/SKILL.md +39 -0
- klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
- klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
- klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
- klaude_code/skill/manager.py +70 -0
- klaude_code/skill/system_skills.py +192 -0
- klaude_code/trace/__init__.py +20 -2
- klaude_code/trace/log.py +150 -5
- klaude_code/ui/__init__.py +4 -9
- klaude_code/ui/core/input.py +1 -1
- klaude_code/ui/core/stage_manager.py +7 -7
- klaude_code/ui/modes/debug/display.py +2 -1
- klaude_code/ui/modes/repl/__init__.py +3 -48
- klaude_code/ui/modes/repl/clipboard.py +5 -5
- klaude_code/ui/modes/repl/completers.py +487 -123
- klaude_code/ui/modes/repl/display.py +5 -4
- klaude_code/ui/modes/repl/event_handler.py +370 -117
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
- klaude_code/ui/modes/repl/key_bindings.py +146 -23
- klaude_code/ui/modes/repl/renderer.py +189 -99
- klaude_code/ui/renderers/assistant.py +9 -2
- klaude_code/ui/renderers/bash_syntax.py +178 -0
- klaude_code/ui/renderers/common.py +78 -0
- klaude_code/ui/renderers/developer.py +104 -48
- klaude_code/ui/renderers/diffs.py +87 -6
- klaude_code/ui/renderers/errors.py +11 -6
- klaude_code/ui/renderers/mermaid_viewer.py +57 -0
- klaude_code/ui/renderers/metadata.py +112 -76
- klaude_code/ui/renderers/sub_agent.py +92 -7
- klaude_code/ui/renderers/thinking.py +40 -18
- klaude_code/ui/renderers/tools.py +405 -227
- klaude_code/ui/renderers/user_input.py +73 -13
- klaude_code/ui/rich/__init__.py +10 -1
- klaude_code/ui/rich/cjk_wrap.py +228 -0
- klaude_code/ui/rich/code_panel.py +131 -0
- klaude_code/ui/rich/live.py +17 -0
- klaude_code/ui/rich/markdown.py +305 -170
- klaude_code/ui/rich/searchable_text.py +10 -13
- klaude_code/ui/rich/status.py +190 -49
- klaude_code/ui/rich/theme.py +135 -39
- klaude_code/ui/terminal/__init__.py +55 -0
- klaude_code/ui/terminal/color.py +1 -1
- klaude_code/ui/terminal/control.py +13 -22
- klaude_code/ui/terminal/notifier.py +44 -4
- klaude_code/ui/terminal/selector.py +658 -0
- klaude_code/ui/utils/common.py +0 -18
- klaude_code-1.8.0.dist-info/METADATA +377 -0
- klaude_code-1.8.0.dist-info/RECORD +219 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
- klaude_code/command/diff_cmd.py +0 -138
- klaude_code/command/prompt-dev-docs-update.md +0 -56
- klaude_code/command/prompt-dev-docs.md +0 -46
- klaude_code/config/list_model.py +0 -162
- klaude_code/core/manager/agent_manager.py +0 -127
- klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
- klaude_code/core/tool/file/multi_edit_tool.md +0 -42
- klaude_code/core/tool/file/multi_edit_tool.py +0 -199
- klaude_code/core/tool/memory/memory_tool.md +0 -16
- klaude_code/core/tool/memory/memory_tool.py +0 -462
- klaude_code/llm/openrouter/reasoning_handler.py +0 -209
- klaude_code/protocol/sub_agent.py +0 -348
- klaude_code/ui/utils/debouncer.py +0 -42
- klaude_code-1.2.6.dist-info/METADATA +0 -178
- klaude_code-1.2.6.dist-info/RECORD +0 -167
- /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
- /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
- /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
- {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()
|
klaude_code/ui/utils/common.py
CHANGED
|
@@ -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)
|