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.
Files changed (60) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/auth/__init__.py +10 -0
  3. klaude_code/auth/env.py +81 -0
  4. klaude_code/cli/auth_cmd.py +87 -8
  5. klaude_code/cli/config_cmd.py +5 -5
  6. klaude_code/cli/cost_cmd.py +159 -60
  7. klaude_code/cli/main.py +146 -65
  8. klaude_code/cli/self_update.py +7 -7
  9. klaude_code/config/builtin_config.py +23 -9
  10. klaude_code/config/config.py +19 -9
  11. klaude_code/const.py +10 -1
  12. klaude_code/core/reminders.py +4 -5
  13. klaude_code/core/turn.py +8 -9
  14. klaude_code/llm/google/client.py +12 -0
  15. klaude_code/llm/openai_compatible/stream.py +5 -1
  16. klaude_code/llm/openrouter/client.py +1 -0
  17. klaude_code/protocol/commands.py +0 -1
  18. klaude_code/protocol/events.py +214 -0
  19. klaude_code/protocol/sub_agent/image_gen.py +0 -4
  20. klaude_code/session/session.py +51 -18
  21. klaude_code/skill/loader.py +12 -13
  22. klaude_code/skill/manager.py +3 -3
  23. klaude_code/tui/command/__init__.py +1 -4
  24. klaude_code/tui/command/copy_cmd.py +1 -1
  25. klaude_code/tui/command/fork_session_cmd.py +4 -4
  26. klaude_code/tui/commands.py +0 -5
  27. klaude_code/tui/components/command_output.py +1 -1
  28. klaude_code/tui/components/metadata.py +4 -5
  29. klaude_code/tui/components/rich/markdown.py +60 -0
  30. klaude_code/tui/components/rich/theme.py +8 -0
  31. klaude_code/tui/components/sub_agent.py +6 -0
  32. klaude_code/tui/components/user_input.py +38 -27
  33. klaude_code/tui/display.py +11 -1
  34. klaude_code/tui/input/AGENTS.md +44 -0
  35. klaude_code/tui/input/completers.py +21 -21
  36. klaude_code/tui/input/drag_drop.py +197 -0
  37. klaude_code/tui/input/images.py +227 -0
  38. klaude_code/tui/input/key_bindings.py +173 -19
  39. klaude_code/tui/input/paste.py +71 -0
  40. klaude_code/tui/input/prompt_toolkit.py +13 -3
  41. klaude_code/tui/machine.py +90 -56
  42. klaude_code/tui/renderer.py +1 -62
  43. klaude_code/tui/runner.py +1 -1
  44. klaude_code/tui/terminal/image.py +40 -9
  45. klaude_code/tui/terminal/selector.py +52 -2
  46. {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/METADATA +32 -40
  47. {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/RECORD +49 -54
  48. klaude_code/cli/session_cmd.py +0 -87
  49. klaude_code/protocol/events/__init__.py +0 -63
  50. klaude_code/protocol/events/base.py +0 -18
  51. klaude_code/protocol/events/chat.py +0 -30
  52. klaude_code/protocol/events/lifecycle.py +0 -23
  53. klaude_code/protocol/events/metadata.py +0 -16
  54. klaude_code/protocol/events/streaming.py +0 -43
  55. klaude_code/protocol/events/system.py +0 -56
  56. klaude_code/protocol/events/tools.py +0 -27
  57. klaude_code/tui/command/terminal_setup_cmd.py +0 -248
  58. klaude_code/tui/input/clipboard.py +0 -152
  59. {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/WHEEL +0 -0
  60. {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/entry_points.txt +0 -0
@@ -12,29 +12,52 @@ from klaude_code.tui.components.rich.theme import ThemeKey
12
12
  # patterns such as foo@bar.com as file references.
13
13
  AT_FILE_RENDER_PATTERN = re.compile(r'(?<!\S)@("([^"]+)"|\S+)')
14
14
 
15
- # Match $skill or ¥skill pattern at the beginning of the first line
16
- SKILL_RENDER_PATTERN = re.compile(r"^[$¥](\S+)")
15
+ # Match $skill or ¥skill pattern inline (at start of line or after whitespace)
16
+ SKILL_RENDER_PATTERN = re.compile(r"(?<!\S)[$¥](\S+)")
17
17
 
18
18
  USER_MESSAGE_MARK = "❯ "
19
19
 
20
20
 
21
- def render_at_pattern(
21
+ def render_at_and_skill_patterns(
22
22
  text: str,
23
23
  at_style: str = ThemeKey.USER_INPUT_AT_PATTERN,
24
+ skill_style: str = ThemeKey.USER_INPUT_SKILL,
24
25
  other_style: str = ThemeKey.USER_INPUT,
25
26
  ) -> Text:
26
- if "@" not in text:
27
+ """Render text with highlighted @file and $skill patterns."""
28
+ has_at = "@" in text
29
+ has_skill = "$" in text or "\u00a5" in text # $ or ¥
30
+
31
+ if not has_at and not has_skill:
27
32
  return Text(text, style=other_style)
28
33
 
34
+ # Collect all matches with their styles
35
+ matches: list[tuple[int, int, str]] = [] # (start, end, style)
36
+
37
+ if has_at:
38
+ for match in AT_FILE_RENDER_PATTERN.finditer(text):
39
+ matches.append((match.start(), match.end(), at_style))
40
+
41
+ if has_skill:
42
+ for match in SKILL_RENDER_PATTERN.finditer(text):
43
+ skill_name = match.group(1)
44
+ if _is_valid_skill_name(skill_name):
45
+ matches.append((match.start(), match.end(), skill_style))
46
+
47
+ if not matches:
48
+ return Text(text, style=other_style)
49
+
50
+ # Sort by start position
51
+ matches.sort(key=lambda x: x[0])
52
+
29
53
  result = Text("")
30
54
  last_end = 0
31
- for match in AT_FILE_RENDER_PATTERN.finditer(text):
32
- start, end = match.span()
55
+ for start, end, style in matches:
56
+ if start < last_end:
57
+ continue # Skip overlapping matches
33
58
  if start > last_end:
34
- # Text before the @-pattern
35
59
  result.append_text(Text(text[last_end:start], other_style))
36
- # The @-pattern itself (e.g. @path or @"path with spaces")
37
- result.append_text(Text(text[start:end], at_style))
60
+ result.append_text(Text(text[start:end], style))
38
61
  last_end = end
39
62
 
40
63
  if last_end < len(text):
@@ -54,37 +77,25 @@ def render_user_input(content: str) -> RenderableType:
54
77
  """Render a user message as a group of quoted lines with styles.
55
78
 
56
79
  - Highlights slash command token on the first line
57
- - Highlights $skill pattern on the first line if recognized
58
- - Highlights @file patterns in all lines
80
+ - Highlights @file and $skill patterns in all lines
59
81
  """
60
82
  lines = content.strip().split("\n")
61
83
  renderables: list[RenderableType] = []
62
84
  for i, line in enumerate(lines):
63
- line_text = render_at_pattern(line)
64
-
85
+ # Handle slash command on first line
65
86
  if i == 0 and line.startswith("/"):
66
87
  splits = line.split(" ", maxsplit=1)
67
88
  line_text = Text.assemble(
68
89
  (splits[0], ThemeKey.USER_INPUT_SLASH_COMMAND),
69
90
  " ",
70
- render_at_pattern(splits[1]) if len(splits) > 1 else Text(""),
91
+ render_at_and_skill_patterns(splits[1]) if len(splits) > 1 else Text(""),
71
92
  )
72
93
  renderables.append(line_text)
73
94
  continue
74
95
 
75
- if i == 0 and (line.startswith("$") or line.startswith("¥")):
76
- m = SKILL_RENDER_PATTERN.match(line)
77
- if m and _is_valid_skill_name(m.group(1)):
78
- skill_token = m.group(0) # e.g. "$skill-name"
79
- rest = line[len(skill_token) :]
80
- line_text = Text.assemble(
81
- (skill_token, ThemeKey.USER_INPUT_SKILL),
82
- render_at_pattern(rest) if rest else Text(""),
83
- )
84
- renderables.append(line_text)
85
- continue
86
-
87
- renderables.append(line_text)
96
+ # Render @file and $skill patterns
97
+ renderables.append(render_at_and_skill_patterns(line))
98
+
88
99
  grid = create_grid()
89
100
  grid.padding = (0, 0)
90
101
  mark = Text(USER_MESSAGE_MARK, style=ThemeKey.USER_INPUT_PROMPT)
@@ -30,8 +30,18 @@ class TUIDisplay(DisplayABC):
30
30
 
31
31
  @override
32
32
  async def consume_event(self, event: events.Event) -> None:
33
+ if isinstance(event, events.ReplayHistoryEvent):
34
+ await self._renderer.execute(self._machine.begin_replay())
35
+ for item in event.events:
36
+ commands = self._machine.transition_replay(item)
37
+ if commands:
38
+ await self._renderer.execute(commands)
39
+ await self._renderer.execute(self._machine.end_replay())
40
+ return
41
+
33
42
  commands = self._machine.transition(event)
34
- await self._renderer.execute(commands)
43
+ if commands:
44
+ await self._renderer.execute(commands)
35
45
 
36
46
  @override
37
47
  async def start(self) -> None:
@@ -0,0 +1,44 @@
1
+ # tui/input Module
2
+
3
+ REPL input handling: key bindings, paste/drag-drop conversion, and image attachment.
4
+
5
+ ## Data Flow
6
+
7
+ ```
8
+ User Input (keyboard/paste/drag)
9
+ -> key_bindings.py (event dispatch)
10
+ -> [convert/fold] -> buffer text with markers
11
+ -> Enter submit -> expand markers, write history
12
+ -> iter_inputs() -> extract images -> UserInputPayload
13
+ ```
14
+
15
+ ## Marker Syntax
16
+
17
+ | Marker | Purpose |
18
+ |--------|---------|
19
+ | `@<path>` | File/directory reference (triggers ReadTool) |
20
+ | `[image <path>]` | Image attachment (encoded on submit) |
21
+ | `[paste #N ...]` | Folded multi-line paste (expanded on submit) |
22
+
23
+ ## Files
24
+
25
+ | File | Responsibility |
26
+ |------|----------------|
27
+ | `key_bindings.py` | Keyboard/paste event handlers; dispatches to converters; `copy_to_clipboard()` |
28
+ | `prompt_toolkit.py` | PromptSession setup; `iter_inputs()` submit flow |
29
+ | `drag_drop.py` | `file://` URI and path list to `@`/`[image]` conversion |
30
+ | `images.py` | Image handling: marker syntax, Ctrl+V capture, `extract_images_from_text()` |
31
+ | `paste.py` | `[paste #N ...]` fold/expand for large pastes |
32
+ | `completers.py` | `@`/`/`/`$` completion providers |
33
+
34
+ ## Special Syntax Features (`@file`, `$skill`)
35
+
36
+ These features span three modules that must stay in sync:
37
+
38
+ | Stage | Module | Responsibility |
39
+ |-------|--------|----------------|
40
+ | Input | `completers.py` | Autocomplete paths/skills as user types |
41
+ | Display | `components/user_input.py` | Highlight syntax in rendered messages |
42
+ | Action | `core/reminders.py` | Inject file content / load skill for LLM |
43
+
44
+ When adding or modifying syntax (e.g., new prefix like `#tag`), update all three.
@@ -38,7 +38,8 @@ from klaude_code.protocol.commands import CommandInfo
38
38
  AT_TOKEN_PATTERN = re.compile(r'(^|\s)@(?P<frag>"[^"]*"|[^\s]*)$')
39
39
 
40
40
  # Pattern to match $skill or ¥skill token for skill completion (used by key bindings).
41
- SKILL_TOKEN_PATTERN = re.compile(r"^[$¥](?P<frag>\S*)$")
41
+ # Supports inline matching: after whitespace or at start of line.
42
+ SKILL_TOKEN_PATTERN = re.compile(r"(^|\s)[$¥](?P<frag>\S*)$")
42
43
 
43
44
 
44
45
  def create_repl_completer(
@@ -130,10 +131,10 @@ class _SlashCommandCompleter(Completer):
130
131
 
131
132
 
132
133
  class _SkillCompleter(Completer):
133
- """Complete skill names at the beginning of the first line.
134
+ """Complete skill names with $ or ¥ prefix.
134
135
 
135
136
  Behavior:
136
- - Only triggers when cursor is on first line and text matches $ or ¥…
137
+ - Triggers when cursor is after $ or ¥ (at start of line or after whitespace)
137
138
  - Shows available skills with descriptions
138
139
  - Inserts trailing space after completion
139
140
  """
@@ -145,19 +146,16 @@ class _SkillCompleter(Completer):
145
146
  document: Document,
146
147
  complete_event, # type: ignore[override]
147
148
  ) -> Iterable[Completion]:
148
- # Only complete on first line
149
- if document.cursor_position_row != 0:
150
- return
151
-
152
149
  text_before = document.current_line_before_cursor
153
150
  m = self._SKILL_TOKEN_RE.search(text_before)
154
151
  if not m:
155
152
  return
156
153
 
157
154
  frag = m.group("frag").lower()
158
- # Get the prefix character ($ or ¥)
159
- prefix_char = text_before[0]
160
- token_start = len(text_before) - len(f"{prefix_char}{m.group('frag')}")
155
+ # Calculate token start: the match includes optional leading whitespace
156
+ # The actual token is $frag or ¥frag (1 char prefix + frag)
157
+ token_len = 1 + len(m.group("frag")) # $ or ¥ + frag
158
+ token_start = len(text_before) - token_len
161
159
  start_position = token_start - len(text_before) # negative offset
162
160
 
163
161
  # Get available skills from SkillTool
@@ -204,8 +202,6 @@ class _SkillCompleter(Completer):
204
202
 
205
203
  def is_skill_context(self, document: Document) -> bool:
206
204
  """Check if current context is a skill completion."""
207
- if document.cursor_position_row != 0:
208
- return False
209
205
  text_before = document.current_line_before_cursor
210
206
  return bool(self._SKILL_TOKEN_RE.search(text_before))
211
207
 
@@ -228,8 +224,8 @@ class _ComboCompleter(Completer):
228
224
  yield from self._slash_completer.get_completions(document, complete_event)
229
225
  return
230
226
 
231
- # Try skill completion (only on first line with $ prefix)
232
- if document.cursor_position_row == 0 and self._skill_completer.is_skill_context(document):
227
+ # Try skill completion (with $ or ¥ prefix)
228
+ if self._skill_completer.is_skill_context(document):
233
229
  yield from self._skill_completer.get_completions(document, complete_event)
234
230
  return
235
231
 
@@ -313,11 +309,13 @@ class _AtFilesCompleter(Completer):
313
309
  if not suggestions:
314
310
  return [] # type: ignore[reportUnknownVariableType]
315
311
  start_position = token_start_in_input - len(text_before)
316
- for s in suggestions[: self._max_results]:
312
+ suggestions_to_show = suggestions[: self._max_results]
313
+ align_width = self._display_align_width(suggestions_to_show)
314
+ for s in suggestions_to_show:
317
315
  yield Completion(
318
316
  text=self._format_completion_text(s, is_quoted=is_quoted),
319
317
  start_position=start_position,
320
- display=self._format_display_label(s, 0),
318
+ display=self._format_display_label(s, align_width),
321
319
  display_meta=s,
322
320
  )
323
321
  return [] # type: ignore[reportUnknownVariableType]
@@ -329,12 +327,14 @@ class _AtFilesCompleter(Completer):
329
327
 
330
328
  # Prepare Completion objects. Replace from the '@' character.
331
329
  start_position = token_start_in_input - len(text_before) # negative
332
- for s in suggestions[: self._max_results]:
330
+ suggestions_to_show = suggestions[: self._max_results]
331
+ align_width = self._display_align_width(suggestions_to_show)
332
+ for s in suggestions_to_show:
333
333
  # Insert formatted text (with quoting when needed) so that subsequent typing does not keep triggering
334
334
  yield Completion(
335
335
  text=self._format_completion_text(s, is_quoted=is_quoted),
336
336
  start_position=start_position,
337
- display=self._format_display_label(s, 0),
337
+ display=self._format_display_label(s, align_width),
338
338
  display_meta=s,
339
339
  )
340
340
 
@@ -543,9 +543,9 @@ class _AtFilesCompleter(Completer):
543
543
  Keep this unstyled so that the completion menu's selection style can
544
544
  fully override the selected row.
545
545
  """
546
-
547
- _ = align_width
548
- return self._display_name(suggestion)
546
+ name = self._display_name(suggestion)
547
+ # Pad to align_width + extra padding for visual separation from meta
548
+ return name.ljust(align_width + 6)
549
549
 
550
550
  def _display_align_width(self, suggestions: list[str]) -> int:
551
551
  """Calculate alignment width for display labels."""
@@ -0,0 +1,197 @@
1
+ """Drag-and-drop / paste helpers for the REPL input.
2
+
3
+ Terminals typically implement drag-and-drop as a bracketed paste of either:
4
+ - A file URL (e.g. "file:///Users/me/foo.txt")
5
+ - A shell-escaped path (e.g. "/Users/me/My\\ File.txt")
6
+
7
+ We convert these into the input formats that Klaude already supports:
8
+ - Regular files/dirs -> @path (quoted when needed)
9
+ - Image files -> [image <path>] markers (resolved to ImageURLPart on submit)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import re
16
+ import shlex
17
+ from pathlib import Path
18
+ from urllib.parse import unquote, urlparse
19
+
20
+ from klaude_code.tui.input.images import format_image_marker, is_image_file
21
+
22
+ _FILE_URI_RE = re.compile(r"file://\S+")
23
+
24
+
25
+ def _format_at_token(path_str: str) -> str:
26
+ """Format a file path for the @ reader, quoting when whitespace exists."""
27
+
28
+ if any(ch.isspace() for ch in path_str):
29
+ return f'@"{path_str}"'
30
+ return f"@{path_str}"
31
+
32
+
33
+ def _normalize_path_for_at(path: Path, *, cwd: Path) -> str:
34
+ """Return a stable, display-friendly path string for @ references."""
35
+
36
+ # Use absolute() instead of resolve() to avoid expanding symlinks.
37
+ # On macOS, /var -> /private/var, and users expect /var paths to stay as /var.
38
+ try:
39
+ resolved = path.absolute()
40
+ except OSError:
41
+ resolved = path
42
+
43
+ cwd_resolved = cwd
44
+ with contextlib.suppress(OSError):
45
+ cwd_resolved = cwd.absolute()
46
+
47
+ as_dir = False
48
+ try:
49
+ as_dir = resolved.exists() and resolved.is_dir()
50
+ except OSError:
51
+ as_dir = False
52
+
53
+ # Prefer relative paths under CWD to match completer output.
54
+ candidate: str
55
+ try:
56
+ rel = resolved.relative_to(cwd_resolved)
57
+ candidate = rel.as_posix()
58
+ except ValueError:
59
+ candidate = resolved.as_posix()
60
+
61
+ if as_dir and candidate and not candidate.endswith("/"):
62
+ candidate += "/"
63
+ return candidate
64
+
65
+
66
+ def _file_uri_to_path(uri: str) -> Path | None:
67
+ """Parse a file:// URI to a filesystem path."""
68
+
69
+ try:
70
+ parsed = urlparse(uri)
71
+ except Exception:
72
+ return None
73
+
74
+ if parsed.scheme != "file":
75
+ return None
76
+
77
+ # Common forms:
78
+ # - file:///Users/me/foo.txt
79
+ # - file://localhost/Users/me/foo.txt
80
+ # - file:///C:/Users/me/foo.txt (Windows)
81
+ raw_path = unquote(parsed.path or "")
82
+ if not raw_path:
83
+ return None
84
+
85
+ if re.match(r"^/[A-Za-z]:/", raw_path):
86
+ # Windows drive letter URIs often include an extra leading slash.
87
+ raw_path = raw_path[1:]
88
+
89
+ return Path(raw_path)
90
+
91
+
92
+ def _replace_file_uris(
93
+ text: str,
94
+ *,
95
+ cwd: Path,
96
+ ) -> tuple[str, bool]:
97
+ """Replace all file://... occurrences in text.
98
+
99
+ Returns (new_text, changed).
100
+ """
101
+
102
+ changed = False
103
+
104
+ def _replace(match: re.Match[str]) -> str:
105
+ nonlocal changed
106
+ uri = match.group(0)
107
+
108
+ # Strip trailing punctuation that is very likely not part of the URI.
109
+ trail = ""
110
+ while uri and uri[-1] in ")],.;:!?":
111
+ trail = uri[-1] + trail
112
+ uri = uri[:-1]
113
+
114
+ path = _file_uri_to_path(uri)
115
+ if path is None:
116
+ return match.group(0)
117
+
118
+ try:
119
+ is_image = path.exists() and path.is_file() and is_image_file(path)
120
+ except OSError:
121
+ is_image = False
122
+
123
+ if is_image:
124
+ changed = True
125
+ return format_image_marker(_normalize_path_for_at(path, cwd=cwd)) + trail
126
+
127
+ token = _format_at_token(_normalize_path_for_at(path, cwd=cwd))
128
+ changed = True
129
+ return token + trail
130
+
131
+ out = _FILE_URI_RE.sub(_replace, text)
132
+ return out, changed
133
+
134
+
135
+ def _looks_like_path_list(text: str) -> list[str] | None:
136
+ """Return tokens if text looks like a pure path list, else None."""
137
+
138
+ stripped = text.strip()
139
+ if not stripped:
140
+ return None
141
+
142
+ # Avoid converting when the paste already contains our input syntax.
143
+ if "@" in stripped or "[image " in stripped:
144
+ return None
145
+
146
+ try:
147
+ tokens = shlex.split(stripped, posix=True)
148
+ except ValueError:
149
+ return None
150
+
151
+ if not tokens:
152
+ return None
153
+
154
+ # Heuristic: all tokens must exist on disk.
155
+ for tok in tokens:
156
+ p = Path(tok).expanduser()
157
+ try:
158
+ if not p.exists():
159
+ return None
160
+ except OSError:
161
+ return None
162
+
163
+ return tokens
164
+
165
+
166
+ def convert_dropped_text(
167
+ text: str,
168
+ *,
169
+ cwd: Path,
170
+ ) -> str:
171
+ """Convert drag-and-drop text into @ tokens and/or image markers."""
172
+
173
+ out, changed = _replace_file_uris(text, cwd=cwd)
174
+ if changed:
175
+ return out
176
+
177
+ tokens = _looks_like_path_list(text)
178
+ if not tokens:
179
+ return text
180
+
181
+ converted: list[str] = []
182
+ for tok in tokens:
183
+ p = Path(tok).expanduser()
184
+ try:
185
+ is_img = p.exists() and p.is_file() and is_image_file(p)
186
+ except OSError:
187
+ is_img = False
188
+
189
+ if is_img:
190
+ converted.append(format_image_marker(_normalize_path_for_at(p, cwd=cwd)))
191
+ continue
192
+
193
+ converted.append(_format_at_token(_normalize_path_for_at(p, cwd=cwd)))
194
+
195
+ # Preserve trailing newline if present (common for bracketed paste payloads).
196
+ suffix = "\n" if text.endswith("\n") else ""
197
+ return " ".join(converted) + suffix