klaude-code 2.5.3__py3-none-any.whl → 2.7.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/app/runtime.py +1 -1
- klaude_code/auth/__init__.py +10 -0
- klaude_code/auth/env.py +81 -0
- klaude_code/cli/auth_cmd.py +87 -8
- klaude_code/cli/config_cmd.py +5 -5
- klaude_code/cli/cost_cmd.py +159 -60
- klaude_code/cli/main.py +146 -65
- klaude_code/cli/self_update.py +7 -7
- klaude_code/config/builtin_config.py +23 -9
- klaude_code/config/config.py +19 -9
- klaude_code/const.py +10 -1
- klaude_code/core/reminders.py +4 -5
- klaude_code/core/turn.py +8 -9
- klaude_code/llm/google/client.py +12 -0
- klaude_code/llm/openai_compatible/stream.py +5 -1
- klaude_code/llm/openrouter/client.py +1 -0
- klaude_code/protocol/commands.py +0 -1
- klaude_code/protocol/events.py +214 -0
- klaude_code/protocol/sub_agent/image_gen.py +0 -4
- klaude_code/session/session.py +51 -18
- klaude_code/skill/loader.py +12 -13
- klaude_code/skill/manager.py +3 -3
- klaude_code/tui/command/__init__.py +1 -4
- klaude_code/tui/command/copy_cmd.py +1 -1
- klaude_code/tui/command/fork_session_cmd.py +4 -4
- klaude_code/tui/commands.py +0 -5
- klaude_code/tui/components/command_output.py +1 -1
- klaude_code/tui/components/metadata.py +4 -5
- klaude_code/tui/components/rich/markdown.py +60 -0
- klaude_code/tui/components/rich/theme.py +8 -0
- klaude_code/tui/components/sub_agent.py +6 -0
- klaude_code/tui/components/user_input.py +38 -27
- klaude_code/tui/display.py +11 -1
- klaude_code/tui/input/AGENTS.md +44 -0
- klaude_code/tui/input/completers.py +21 -21
- klaude_code/tui/input/drag_drop.py +197 -0
- klaude_code/tui/input/images.py +227 -0
- klaude_code/tui/input/key_bindings.py +173 -19
- klaude_code/tui/input/paste.py +71 -0
- klaude_code/tui/input/prompt_toolkit.py +13 -3
- klaude_code/tui/machine.py +90 -56
- klaude_code/tui/renderer.py +1 -62
- klaude_code/tui/runner.py +1 -1
- klaude_code/tui/terminal/image.py +40 -9
- klaude_code/tui/terminal/selector.py +52 -2
- {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/METADATA +32 -40
- {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/RECORD +49 -54
- klaude_code/cli/session_cmd.py +0 -87
- klaude_code/protocol/events/__init__.py +0 -63
- klaude_code/protocol/events/base.py +0 -18
- klaude_code/protocol/events/chat.py +0 -30
- klaude_code/protocol/events/lifecycle.py +0 -23
- klaude_code/protocol/events/metadata.py +0 -16
- klaude_code/protocol/events/streaming.py +0 -43
- klaude_code/protocol/events/system.py +0 -56
- klaude_code/protocol/events/tools.py +0 -27
- klaude_code/tui/command/terminal_setup_cmd.py +0 -248
- klaude_code/tui/input/clipboard.py +0 -152
- {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from .base import ResponseEvent
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class ThinkingStartEvent(ResponseEvent):
|
|
7
|
-
pass
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class ThinkingDeltaEvent(ResponseEvent):
|
|
11
|
-
content: str
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class ThinkingEndEvent(ResponseEvent):
|
|
15
|
-
pass
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class AssistantTextStartEvent(ResponseEvent):
|
|
19
|
-
pass
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class AssistantTextDeltaEvent(ResponseEvent):
|
|
23
|
-
content: str
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class AssistantTextEndEvent(ResponseEvent):
|
|
27
|
-
pass
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class AssistantImageDeltaEvent(ResponseEvent):
|
|
31
|
-
file_path: str
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class ToolCallStartEvent(ResponseEvent):
|
|
35
|
-
tool_call_id: str
|
|
36
|
-
tool_name: str
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class ResponseCompleteEvent(ResponseEvent):
|
|
40
|
-
"""Final snapshot of the model response."""
|
|
41
|
-
|
|
42
|
-
content: str
|
|
43
|
-
thinking_text: str | None = None
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pydantic import Field
|
|
4
|
-
|
|
5
|
-
from klaude_code.protocol import llm_param
|
|
6
|
-
from klaude_code.protocol.events.chat import DeveloperMessageEvent, UserMessageEvent
|
|
7
|
-
from klaude_code.protocol.events.lifecycle import TaskFinishEvent, TaskStartEvent, TurnStartEvent
|
|
8
|
-
from klaude_code.protocol.events.metadata import TaskMetadataEvent
|
|
9
|
-
from klaude_code.protocol.events.streaming import AssistantImageDeltaEvent, ResponseCompleteEvent
|
|
10
|
-
from klaude_code.protocol.events.tools import ToolCallEvent, ToolResultEvent
|
|
11
|
-
|
|
12
|
-
from .base import Event
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class WelcomeEvent(Event):
|
|
16
|
-
work_dir: str
|
|
17
|
-
llm_config: llm_param.LLMConfigParameter
|
|
18
|
-
show_klaude_code_info: bool = True
|
|
19
|
-
loaded_skills: dict[str, list[str]] = Field(default_factory=dict)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class ErrorEvent(Event):
|
|
23
|
-
error_message: str
|
|
24
|
-
can_retry: bool = False
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class InterruptEvent(Event):
|
|
28
|
-
pass
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class EndEvent(Event):
|
|
32
|
-
"""Global display shutdown."""
|
|
33
|
-
|
|
34
|
-
session_id: str = "__app__"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
type ReplayEventUnion = (
|
|
38
|
-
TaskStartEvent
|
|
39
|
-
| TaskFinishEvent
|
|
40
|
-
| TurnStartEvent
|
|
41
|
-
| AssistantImageDeltaEvent
|
|
42
|
-
| ResponseCompleteEvent
|
|
43
|
-
| ToolCallEvent
|
|
44
|
-
| ToolResultEvent
|
|
45
|
-
| UserMessageEvent
|
|
46
|
-
| TaskMetadataEvent
|
|
47
|
-
| InterruptEvent
|
|
48
|
-
| DeveloperMessageEvent
|
|
49
|
-
| ErrorEvent
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class ReplayHistoryEvent(Event):
|
|
54
|
-
events: list[ReplayEventUnion]
|
|
55
|
-
updated_at: float
|
|
56
|
-
is_load: bool = True
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Literal
|
|
4
|
-
|
|
5
|
-
from klaude_code.protocol import model
|
|
6
|
-
|
|
7
|
-
from .base import ResponseEvent
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class ToolCallEvent(ResponseEvent):
|
|
11
|
-
tool_call_id: str
|
|
12
|
-
tool_name: str
|
|
13
|
-
arguments: str
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class ToolResultEvent(ResponseEvent):
|
|
17
|
-
tool_call_id: str
|
|
18
|
-
tool_name: str
|
|
19
|
-
result: str
|
|
20
|
-
ui_extra: model.ToolResultUIExtra | None = None
|
|
21
|
-
status: Literal["success", "error", "aborted"]
|
|
22
|
-
task_metadata: model.TaskMetadata | None = None
|
|
23
|
-
is_last_in_turn: bool = True
|
|
24
|
-
|
|
25
|
-
@property
|
|
26
|
-
def is_error(self) -> bool:
|
|
27
|
-
return self.status in ("error", "aborted")
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import subprocess
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from klaude_code.protocol import commands, events, message
|
|
6
|
-
|
|
7
|
-
from .command_abc import Agent, CommandABC, CommandResult
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class TerminalSetupCommand(CommandABC):
|
|
11
|
-
"""Setup shift+enter newline functionality in terminal"""
|
|
12
|
-
|
|
13
|
-
@property
|
|
14
|
-
def name(self) -> commands.CommandName:
|
|
15
|
-
return commands.CommandName.TERMINAL_SETUP
|
|
16
|
-
|
|
17
|
-
@property
|
|
18
|
-
def summary(self) -> str:
|
|
19
|
-
return "Install shift+enter key binding for newlines"
|
|
20
|
-
|
|
21
|
-
@property
|
|
22
|
-
def is_interactive(self) -> bool:
|
|
23
|
-
return False
|
|
24
|
-
|
|
25
|
-
async def run(self, agent: Agent, user_input: message.UserInputPayload) -> CommandResult:
|
|
26
|
-
del user_input # unused
|
|
27
|
-
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
|
28
|
-
|
|
29
|
-
try:
|
|
30
|
-
if term_program == "ghostty":
|
|
31
|
-
message = self._setup_ghostty()
|
|
32
|
-
elif term_program == "iterm.app":
|
|
33
|
-
message = self._setup_iterm()
|
|
34
|
-
elif term_program == "vscode":
|
|
35
|
-
# VS Code family terminals (VS Code, Windsurf, Cursor) all report TERM_PROGRAM=vscode
|
|
36
|
-
message = self._setup_vscode_family()
|
|
37
|
-
else:
|
|
38
|
-
# Provide generic manual configuration guide for unknown or unsupported terminals
|
|
39
|
-
message = self._setup_generic(term_program)
|
|
40
|
-
|
|
41
|
-
return self._create_success_result(agent, message)
|
|
42
|
-
|
|
43
|
-
except Exception as e:
|
|
44
|
-
return self._create_error_result(agent, f"Error configuring terminal: {e!s}")
|
|
45
|
-
|
|
46
|
-
def _setup_ghostty(self) -> str:
|
|
47
|
-
"""Configure shift+enter newline for Ghostty terminal"""
|
|
48
|
-
config_dir = Path.home() / ".config" / "ghostty"
|
|
49
|
-
config_file = config_dir / "config"
|
|
50
|
-
|
|
51
|
-
keybind_line = 'keybind="shift+enter=text:\\n"'
|
|
52
|
-
|
|
53
|
-
# Ensure config directory exists
|
|
54
|
-
config_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
-
|
|
56
|
-
# Check if configuration already exists in config file
|
|
57
|
-
if config_file.exists():
|
|
58
|
-
content = config_file.read_text()
|
|
59
|
-
if keybind_line in content or 'keybind="shift+enter=' in content:
|
|
60
|
-
return "Ghostty terminal shift+enter newline configuration already exists"
|
|
61
|
-
|
|
62
|
-
# Add configuration
|
|
63
|
-
with config_file.open("a", encoding="utf-8") as f:
|
|
64
|
-
if config_file.exists() and not config_file.read_text().endswith("\n"):
|
|
65
|
-
f.write("\n")
|
|
66
|
-
f.write(f"{keybind_line}\n")
|
|
67
|
-
|
|
68
|
-
return f"Added shift+enter newline configuration for Ghostty terminal to {config_file}"
|
|
69
|
-
|
|
70
|
-
def _setup_iterm(self) -> str:
|
|
71
|
-
"""Configure shift+enter newline for iTerm terminal using defaults command"""
|
|
72
|
-
try:
|
|
73
|
-
# First check if iTerm preferences exist
|
|
74
|
-
prefs_path = Path.home() / "Library" / "Preferences" / "com.googlecode.iterm2.plist"
|
|
75
|
-
if not prefs_path.exists():
|
|
76
|
-
return "iTerm preferences file not found. Please open iTerm first to create initial preferences."
|
|
77
|
-
|
|
78
|
-
# Check if the key binding already exists
|
|
79
|
-
check_cmd = ["defaults", "read", "com.googlecode.iterm2", "New Bookmarks"]
|
|
80
|
-
|
|
81
|
-
try:
|
|
82
|
-
result = subprocess.run(check_cmd, capture_output=True, text=True, check=True)
|
|
83
|
-
# If we can read bookmarks, iTerm is properly configured
|
|
84
|
-
except subprocess.CalledProcessError:
|
|
85
|
-
return "Unable to read iTerm configuration. Please ensure iTerm is properly installed and has been opened at least once."
|
|
86
|
-
|
|
87
|
-
# Add to the default profile's keyboard map
|
|
88
|
-
add_keymap_cmd = [
|
|
89
|
-
"defaults",
|
|
90
|
-
"write",
|
|
91
|
-
"com.googlecode.iterm2",
|
|
92
|
-
"GlobalKeyMap",
|
|
93
|
-
"-dict-add",
|
|
94
|
-
# Do not include quotes when passing args as a list (no shell)
|
|
95
|
-
"0x0d-0x20000",
|
|
96
|
-
# Pass Property List dict directly; \n should be literal backslash-n so iTerm parses newline
|
|
97
|
-
'{Action=12;Text="\\\\n";}',
|
|
98
|
-
]
|
|
99
|
-
# Execute without shell so arguments are passed correctly
|
|
100
|
-
result = subprocess.run(add_keymap_cmd, capture_output=True, text=True)
|
|
101
|
-
print(result.stdout, result.stderr)
|
|
102
|
-
if result.returncode == 0:
|
|
103
|
-
return "Successfully configured Shift+Enter for newline in iTerm. Please restart iTerm for changes to take effect."
|
|
104
|
-
else:
|
|
105
|
-
# Fallback to manual instructions if defaults command fails
|
|
106
|
-
return (
|
|
107
|
-
"Automatic configuration failed. Please manually configure:\n"
|
|
108
|
-
"1. Open iTerm -> Preferences (⌘,)\n"
|
|
109
|
-
"2. Go to Profiles -> Keys -> Key Mappings\n"
|
|
110
|
-
"3. Click '+' to add: Shift+Enter -> Send Text -> \\n"
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
except Exception as e:
|
|
114
|
-
raise Exception(f"Error configuring iTerm: {e!s}") from e
|
|
115
|
-
|
|
116
|
-
def _setup_vscode_family(self) -> str:
|
|
117
|
-
"""Configure shift+enter newline for VS Code family terminals (VS Code, Windsurf, Cursor).
|
|
118
|
-
|
|
119
|
-
These editors share TERM_PROGRAM=vscode and use keybindings.json under their respective
|
|
120
|
-
Application Support folders. We ensure the required keybinding exists; if not, we append it.
|
|
121
|
-
"""
|
|
122
|
-
base_dir = Path.home() / "Library" / "Application Support"
|
|
123
|
-
targets = [
|
|
124
|
-
("VS Code", base_dir / "Code" / "User" / "keybindings.json"),
|
|
125
|
-
("Windsurf", base_dir / "Windsurf" / "User" / "keybindings.json"),
|
|
126
|
-
("Cursor", base_dir / "Cursor" / "User" / "keybindings.json"),
|
|
127
|
-
]
|
|
128
|
-
|
|
129
|
-
mapping_block = r""" {
|
|
130
|
-
"key": "shift+enter",
|
|
131
|
-
"command": "workbench.action.terminal.sendSequence",
|
|
132
|
-
"args": {
|
|
133
|
-
"text": "\\\r\n"
|
|
134
|
-
},
|
|
135
|
-
"when": "terminalFocus"
|
|
136
|
-
}"""
|
|
137
|
-
|
|
138
|
-
results: list[str] = []
|
|
139
|
-
|
|
140
|
-
for name, file_path in targets:
|
|
141
|
-
try:
|
|
142
|
-
_, msg = self._ensure_vscode_keybinding(file_path, mapping_block)
|
|
143
|
-
results.append(f"{name}: {msg}")
|
|
144
|
-
except Exception as e: # pragma: no cover - protect against any unexpected FS issue
|
|
145
|
-
results.append(f"{name}: failed to update keybindings ({e})")
|
|
146
|
-
|
|
147
|
-
return "\n".join(results)
|
|
148
|
-
|
|
149
|
-
def _ensure_vscode_keybinding(self, path: Path, mapping_block: str) -> tuple[bool, str]:
|
|
150
|
-
"""Ensure the VS Code-style keybinding exists in the given keybindings.json file.
|
|
151
|
-
|
|
152
|
-
Returns (added, message).
|
|
153
|
-
- added=True if we created or modified the file to include the mapping
|
|
154
|
-
- added=False if mapping already present or file couldn't be safely modified
|
|
155
|
-
"""
|
|
156
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
-
|
|
158
|
-
# If file does not exist, create with the mapping in an array
|
|
159
|
-
if not path.exists():
|
|
160
|
-
content = "[\n " + mapping_block + "\n]\n"
|
|
161
|
-
path.write_text(content, encoding="utf-8")
|
|
162
|
-
return True, f"created {path} with Shift+Enter mapping"
|
|
163
|
-
|
|
164
|
-
# Read existing content
|
|
165
|
-
raw = path.read_text(encoding="utf-8")
|
|
166
|
-
text = raw
|
|
167
|
-
|
|
168
|
-
# Quick detection: if both key and command exist together anywhere, assume configured
|
|
169
|
-
if '"key": "shift+enter"' in text and "workbench.action.terminal.sendSequence" in text:
|
|
170
|
-
return False, "already configured"
|
|
171
|
-
|
|
172
|
-
stripped = text.strip()
|
|
173
|
-
# If file is empty, write a fresh array
|
|
174
|
-
if stripped == "":
|
|
175
|
-
content = "[\n " + mapping_block + "\n]\n"
|
|
176
|
-
path.write_text(content, encoding="utf-8")
|
|
177
|
-
return True, "initialized empty keybindings.json with mapping"
|
|
178
|
-
|
|
179
|
-
# If the content contains a top-level array (allowing header comments), append before the final ]
|
|
180
|
-
open_idx = text.find("[")
|
|
181
|
-
close_idx = text.rfind("]")
|
|
182
|
-
if open_idx != -1 and close_idx != -1 and open_idx < close_idx:
|
|
183
|
-
before = text[:close_idx].rstrip()
|
|
184
|
-
after = text[close_idx:]
|
|
185
|
-
|
|
186
|
-
# Heuristic: treat as non-empty if there's an object marker between [ and ]
|
|
187
|
-
inner = text[open_idx + 1 : close_idx]
|
|
188
|
-
has_item = "{" in inner
|
|
189
|
-
|
|
190
|
-
# Construct new content by adding optional comma, newline, then our block
|
|
191
|
-
new_content = before + ("," if has_item else "") + "\n" + mapping_block + "\n" + after
|
|
192
|
-
|
|
193
|
-
path.write_text(new_content, encoding="utf-8")
|
|
194
|
-
return True, "appended mapping"
|
|
195
|
-
|
|
196
|
-
# Not an array – avoid modifying to prevent corrupting user config
|
|
197
|
-
return (
|
|
198
|
-
False,
|
|
199
|
-
"unsupported keybindings.json format (not an array); please add mapping manually",
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
def _setup_generic(self, term_program: str) -> str:
|
|
203
|
-
"""Provide generic manual configuration guide for unknown or unsupported terminals"""
|
|
204
|
-
if term_program:
|
|
205
|
-
intro = f"Terminal type '{term_program}' is not specifically supported, but you can manually configure shift+enter newline functionality."
|
|
206
|
-
else:
|
|
207
|
-
intro = "Unable to detect terminal type, but you can manually configure shift+enter newline functionality."
|
|
208
|
-
|
|
209
|
-
message = (
|
|
210
|
-
f"{intro}\n\n"
|
|
211
|
-
"General steps to configure shift+enter for newline:\n"
|
|
212
|
-
"1. Open your terminal's preferences/settings\n"
|
|
213
|
-
"2. Look for 'Key Bindings', 'Key Mappings', or 'Keyboard' section\n"
|
|
214
|
-
"3. Add a new key binding:\n"
|
|
215
|
-
" - Key combination: Shift+Enter\n"
|
|
216
|
-
" - Action: Send text or Insert text\n"
|
|
217
|
-
" - Text to send: \\n (literal newline character)\n"
|
|
218
|
-
"4. Save the configuration\n\n"
|
|
219
|
-
"Note: The exact steps may vary depending on your terminal application. "
|
|
220
|
-
"Currently supported terminals with automatic configuration: Ghostty, iTerm.app, VS Code family (VS Code, Windsurf, Cursor)"
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
return message
|
|
224
|
-
|
|
225
|
-
def _create_success_result(self, agent: "Agent", msg: str) -> CommandResult:
|
|
226
|
-
"""Create success result"""
|
|
227
|
-
return CommandResult(
|
|
228
|
-
events=[
|
|
229
|
-
events.CommandOutputEvent(
|
|
230
|
-
session_id=agent.session.id,
|
|
231
|
-
command_name=self.name,
|
|
232
|
-
content=msg,
|
|
233
|
-
)
|
|
234
|
-
]
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
def _create_error_result(self, agent: "Agent", msg: str) -> CommandResult:
|
|
238
|
-
"""Create error result"""
|
|
239
|
-
return CommandResult(
|
|
240
|
-
events=[
|
|
241
|
-
events.CommandOutputEvent(
|
|
242
|
-
session_id=agent.session.id,
|
|
243
|
-
command_name=self.name,
|
|
244
|
-
content=msg,
|
|
245
|
-
is_error=True,
|
|
246
|
-
)
|
|
247
|
-
]
|
|
248
|
-
)
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
"""Clipboard and image handling for REPL input.
|
|
2
|
-
|
|
3
|
-
This module provides:
|
|
4
|
-
- ClipboardCaptureState: Captures clipboard images and maps tags to file paths
|
|
5
|
-
- capture_clipboard_tag(): Capture clipboard image and return tag
|
|
6
|
-
- extract_images_from_text(): Parse tags and return ImageURLPart list
|
|
7
|
-
- copy_to_clipboard(): Copy text to system clipboard
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import re
|
|
13
|
-
import shutil
|
|
14
|
-
import subprocess
|
|
15
|
-
import sys
|
|
16
|
-
import uuid
|
|
17
|
-
from base64 import b64encode
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
|
|
20
|
-
from PIL import Image, ImageGrab
|
|
21
|
-
|
|
22
|
-
from klaude_code.protocol.message import ImageURLPart
|
|
23
|
-
|
|
24
|
-
# Directory for storing clipboard images
|
|
25
|
-
CLIPBOARD_IMAGES_DIR = Path.home() / ".klaude" / "clipboard" / "images"
|
|
26
|
-
|
|
27
|
-
# Pattern to match [Image #N] tags in user input
|
|
28
|
-
_IMAGE_TAG_RE = re.compile(r"\[Image #(\d+)\]")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class ClipboardCaptureState:
|
|
32
|
-
"""Captures clipboard images and maps tags to file paths in memory."""
|
|
33
|
-
|
|
34
|
-
def __init__(self, images_dir: Path | None = None):
|
|
35
|
-
self._images_dir = images_dir or CLIPBOARD_IMAGES_DIR
|
|
36
|
-
self._pending: dict[str, str] = {} # tag -> path mapping
|
|
37
|
-
self._counter = 1
|
|
38
|
-
|
|
39
|
-
def capture_from_clipboard(self) -> str | None:
|
|
40
|
-
"""Capture image from clipboard, save to disk, and return a tag like [Image #N]."""
|
|
41
|
-
try:
|
|
42
|
-
clipboard_data = ImageGrab.grabclipboard()
|
|
43
|
-
except OSError:
|
|
44
|
-
return None
|
|
45
|
-
if not isinstance(clipboard_data, Image.Image):
|
|
46
|
-
return None
|
|
47
|
-
try:
|
|
48
|
-
self._images_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
-
except OSError:
|
|
50
|
-
return None
|
|
51
|
-
filename = f"clipboard_{uuid.uuid4().hex[:8]}.png"
|
|
52
|
-
path = self._images_dir / filename
|
|
53
|
-
try:
|
|
54
|
-
clipboard_data.save(path, "PNG")
|
|
55
|
-
except OSError:
|
|
56
|
-
return None
|
|
57
|
-
tag = f"[Image #{self._counter}]"
|
|
58
|
-
self._counter += 1
|
|
59
|
-
self._pending[tag] = str(path)
|
|
60
|
-
return tag
|
|
61
|
-
|
|
62
|
-
def get_pending_images(self) -> dict[str, str]:
|
|
63
|
-
"""Return the current tag-to-path mapping for pending images."""
|
|
64
|
-
return dict(self._pending)
|
|
65
|
-
|
|
66
|
-
def flush(self) -> dict[str, str]:
|
|
67
|
-
"""Flush pending images and return tag-to-path mapping, then reset state."""
|
|
68
|
-
result = dict(self._pending)
|
|
69
|
-
self._pending = {}
|
|
70
|
-
self._counter = 1
|
|
71
|
-
return result
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
# Module-level singleton instance
|
|
75
|
-
clipboard_state = ClipboardCaptureState()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def capture_clipboard_tag() -> str | None:
|
|
79
|
-
"""Capture image from clipboard and return tag like [Image #N].
|
|
80
|
-
|
|
81
|
-
Uses the module-level clipboard_state singleton. Returns None if no image
|
|
82
|
-
is available in the clipboard or capture fails.
|
|
83
|
-
"""
|
|
84
|
-
return clipboard_state.capture_from_clipboard()
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def extract_images_from_text(text: str) -> list[ImageURLPart]:
|
|
88
|
-
"""Extract images from pending clipboard state based on tags in text.
|
|
89
|
-
|
|
90
|
-
Parses [Image #N] tags in the text, looks up corresponding image paths
|
|
91
|
-
in the clipboard state, and creates ImageURLPart objects from them.
|
|
92
|
-
Flushes the clipboard state after extraction.
|
|
93
|
-
"""
|
|
94
|
-
pending_images = clipboard_state.flush()
|
|
95
|
-
if not pending_images:
|
|
96
|
-
return []
|
|
97
|
-
|
|
98
|
-
# Find all [Image #N] tags in text
|
|
99
|
-
found_tags = set(_IMAGE_TAG_RE.findall(text))
|
|
100
|
-
if not found_tags:
|
|
101
|
-
return []
|
|
102
|
-
|
|
103
|
-
images: list[ImageURLPart] = []
|
|
104
|
-
for tag, path in pending_images.items():
|
|
105
|
-
# Extract the number from the tag and check if it's referenced
|
|
106
|
-
match = _IMAGE_TAG_RE.match(tag)
|
|
107
|
-
if match and match.group(1) in found_tags:
|
|
108
|
-
image_part = _encode_image_file(path)
|
|
109
|
-
if image_part:
|
|
110
|
-
images.append(image_part)
|
|
111
|
-
|
|
112
|
-
return images
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _encode_image_file(file_path: str) -> ImageURLPart | None:
|
|
116
|
-
"""Encode an image file as base64 data URL and create ImageURLPart."""
|
|
117
|
-
try:
|
|
118
|
-
path = Path(file_path)
|
|
119
|
-
if not path.exists():
|
|
120
|
-
return None
|
|
121
|
-
with open(path, "rb") as f:
|
|
122
|
-
encoded = b64encode(f.read()).decode("ascii")
|
|
123
|
-
# Clipboard images are always saved as PNG
|
|
124
|
-
data_url = f"data:image/png;base64,{encoded}"
|
|
125
|
-
return ImageURLPart(url=data_url, id=None)
|
|
126
|
-
except OSError:
|
|
127
|
-
return None
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def copy_to_clipboard(text: str) -> None:
|
|
131
|
-
"""Copy text to system clipboard using platform-specific commands."""
|
|
132
|
-
try:
|
|
133
|
-
if sys.platform == "darwin":
|
|
134
|
-
subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
|
|
135
|
-
elif sys.platform == "win32":
|
|
136
|
-
subprocess.run(["clip"], input=text.encode("utf-16"), check=True)
|
|
137
|
-
else:
|
|
138
|
-
# Linux: try xclip first, then xsel
|
|
139
|
-
if shutil.which("xclip"):
|
|
140
|
-
subprocess.run(
|
|
141
|
-
["xclip", "-selection", "clipboard"],
|
|
142
|
-
input=text.encode("utf-8"),
|
|
143
|
-
check=True,
|
|
144
|
-
)
|
|
145
|
-
elif shutil.which("xsel"):
|
|
146
|
-
subprocess.run(
|
|
147
|
-
["xsel", "--clipboard", "--input"],
|
|
148
|
-
input=text.encode("utf-8"),
|
|
149
|
-
check=True,
|
|
150
|
-
)
|
|
151
|
-
except (OSError, subprocess.SubprocessError):
|
|
152
|
-
pass
|
|
File without changes
|
|
File without changes
|