klaude-code 1.4.3__py3-none-any.whl → 1.6.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 (37) hide show
  1. klaude_code/cli/main.py +22 -11
  2. klaude_code/cli/runtime.py +171 -34
  3. klaude_code/command/__init__.py +4 -0
  4. klaude_code/command/fork_session_cmd.py +220 -2
  5. klaude_code/command/help_cmd.py +2 -1
  6. klaude_code/command/model_cmd.py +3 -5
  7. klaude_code/command/model_select.py +84 -0
  8. klaude_code/command/refresh_cmd.py +4 -4
  9. klaude_code/command/registry.py +23 -0
  10. klaude_code/command/resume_cmd.py +62 -2
  11. klaude_code/command/thinking_cmd.py +30 -199
  12. klaude_code/config/select_model.py +47 -97
  13. klaude_code/config/thinking.py +255 -0
  14. klaude_code/core/executor.py +53 -63
  15. klaude_code/llm/usage.py +1 -1
  16. klaude_code/protocol/commands.py +11 -0
  17. klaude_code/protocol/op.py +15 -0
  18. klaude_code/session/__init__.py +2 -2
  19. klaude_code/session/selector.py +65 -65
  20. klaude_code/session/session.py +18 -12
  21. klaude_code/ui/modes/repl/completers.py +27 -15
  22. klaude_code/ui/modes/repl/event_handler.py +24 -33
  23. klaude_code/ui/modes/repl/input_prompt_toolkit.py +393 -57
  24. klaude_code/ui/modes/repl/key_bindings.py +30 -10
  25. klaude_code/ui/modes/repl/renderer.py +1 -1
  26. klaude_code/ui/renderers/developer.py +2 -2
  27. klaude_code/ui/renderers/metadata.py +11 -6
  28. klaude_code/ui/renderers/user_input.py +18 -1
  29. klaude_code/ui/rich/markdown.py +41 -9
  30. klaude_code/ui/rich/status.py +83 -22
  31. klaude_code/ui/rich/theme.py +2 -2
  32. klaude_code/ui/terminal/notifier.py +42 -0
  33. klaude_code/ui/terminal/selector.py +488 -136
  34. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/METADATA +1 -1
  35. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/RECORD +37 -35
  36. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/WHEEL +0 -0
  37. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/entry_points.txt +0 -0
@@ -13,6 +13,7 @@ from uuid import uuid4
13
13
 
14
14
  from pydantic import BaseModel, Field
15
15
 
16
+ from klaude_code.protocol.llm_param import Thinking
16
17
  from klaude_code.protocol.model import UserInputPayload
17
18
 
18
19
  if TYPE_CHECKING:
@@ -75,6 +76,17 @@ class ChangeModelOperation(Operation):
75
76
  session_id: str
76
77
  model_name: str
77
78
  save_as_default: bool = False
79
+ # When True, the executor must not auto-trigger an interactive thinking selector.
80
+ # This is required for in-prompt model switching where the terminal is already
81
+ # controlled by a prompt_toolkit PromptSession.
82
+ defer_thinking_selection: bool = False
83
+ # When False, do not emit WelcomeEvent (which renders a banner/panel).
84
+ # This is useful for in-prompt model switching where extra output is noisy.
85
+ emit_welcome_event: bool = True
86
+
87
+ # When False, do not emit the "Switched to: ..." developer message.
88
+ # This is useful for in-prompt model switching where extra output is noisy.
89
+ emit_switch_message: bool = True
78
90
 
79
91
  async def execute(self, handler: OperationHandler) -> None:
80
92
  await handler.handle_change_model(self)
@@ -85,6 +97,9 @@ class ChangeThinkingOperation(Operation):
85
97
 
86
98
  type: OperationType = OperationType.CHANGE_THINKING
87
99
  session_id: str
100
+ thinking: Thinking | None = None
101
+ emit_welcome_event: bool = True
102
+ emit_switch_message: bool = True
88
103
 
89
104
  async def execute(self, handler: OperationHandler) -> None:
90
105
  await handler.handle_change_thinking(self)
@@ -1,4 +1,4 @@
1
- from .selector import resume_select_session
1
+ from .selector import SessionSelectOption, build_session_select_options, format_user_messages_display
2
2
  from .session import Session
3
3
 
4
- __all__ = ["Session", "resume_select_session"]
4
+ __all__ = ["Session", "SessionSelectOption", "build_session_select_options", "format_user_messages_display"]
@@ -1,7 +1,5 @@
1
1
  import time
2
-
3
- from klaude_code.trace import log, log_debug
4
- from klaude_code.ui.terminal.selector import SelectItem, select_one
2
+ from dataclasses import dataclass
5
3
 
6
4
  from .session import Session
7
5
 
@@ -30,69 +28,71 @@ def _relative_time(ts: float) -> str:
30
28
  return f"{months} month{'s' if months != 1 else ''} ago"
31
29
 
32
30
 
33
- def resume_select_session() -> str | None:
31
+ @dataclass(frozen=True, slots=True)
32
+ class SessionSelectOption:
33
+ """Option data for session selection UI."""
34
+
35
+ session_id: str
36
+ user_messages: list[str]
37
+ messages_count: str
38
+ relative_time: str
39
+ model_name: str
40
+
41
+
42
+ def _format_message(msg: str) -> str:
43
+ """Format a user message for display (strip and collapse newlines)."""
44
+ return msg.strip().replace("\n", " ")
45
+
46
+
47
+ def format_user_messages_display(messages: list[str]) -> list[str]:
48
+ """Format user messages for display in session selection.
49
+
50
+ Shows up to 6 messages. If more than 6, shows first 3 and last 3 with ellipsis.
51
+ Each message is on its own line.
52
+
53
+ Args:
54
+ messages: List of user messages.
55
+
56
+ Returns:
57
+ List of formatted message lines for display.
58
+ """
59
+ if len(messages) <= 6:
60
+ return messages
61
+
62
+ # More than 6: show first 3, ellipsis, last 3
63
+ result = messages[:3]
64
+ result.append("⋮")
65
+ result.extend(messages[-3:])
66
+ return result
67
+
68
+
69
+ def build_session_select_options() -> list[SessionSelectOption]:
70
+ """Build session selection options data.
71
+
72
+ Returns:
73
+ List of SessionSelectOption, or empty list if no sessions exist.
74
+ """
34
75
  sessions = Session.list_sessions()
35
76
  if not sessions:
36
- log("No sessions found for this project.")
37
- return None
38
-
39
- try:
40
- from prompt_toolkit.styles import Style
41
-
42
- items: list[SelectItem[str]] = []
43
- for s in sessions:
44
- first_msg = s.first_user_message or "N/A"
45
- first_msg = first_msg.strip().replace("\n", " ")
46
-
47
- msg_count = "N/A" if s.messages_count == -1 else f"{s.messages_count} messages"
48
- model = s.model_name or "N/A"
49
-
50
- title = [
51
- ("class:msg", f"{first_msg}\n"),
52
- ("class:meta", f" {msg_count} · {_relative_time(s.updated_at)} · {model} · {s.id}\n\n"),
53
- ]
54
- items.append(
55
- SelectItem(
56
- title=title,
57
- value=str(s.id),
58
- search_text=f"{first_msg} {model} {s.id}",
59
- )
60
- )
77
+ return []
61
78
 
62
- return select_one(
63
- message="Select a session to resume:",
64
- items=items,
65
- pointer="→",
66
- style=Style(
67
- [
68
- ("msg", ""),
69
- ("meta", "fg:ansibrightblack"),
70
- ("pointer", "bold fg:ansigreen"),
71
- ("highlighted", "fg:ansigreen"),
72
- ("search_prefix", "fg:ansibrightblack"),
73
- ("search_success", "noinherit fg:ansigreen"),
74
- ("search_none", "noinherit fg:ansired"),
75
- ("question", "bold"),
76
- ("text", ""),
77
- ]
78
- ),
79
+ options: list[SessionSelectOption] = []
80
+ for s in sessions:
81
+ user_messages = [_format_message(m) for m in s.user_messages if m.strip()]
82
+ if not user_messages:
83
+ user_messages = ["N/A"]
84
+
85
+ msg_count = "N/A" if s.messages_count == -1 else f"{s.messages_count} messages"
86
+ model = s.model_name or "N/A"
87
+
88
+ options.append(
89
+ SessionSelectOption(
90
+ session_id=str(s.id),
91
+ user_messages=user_messages,
92
+ messages_count=msg_count,
93
+ relative_time=_relative_time(s.updated_at),
94
+ model_name=model,
95
+ )
79
96
  )
80
- except Exception as e:
81
- log_debug(f"Failed to use prompt_toolkit for session select, {e}")
82
-
83
- for i, s in enumerate(sessions, 1):
84
- first_msg = (s.first_user_message or "N/A").strip().replace("\n", " ")
85
- if len(first_msg) > 60:
86
- first_msg = first_msg[:59] + "…"
87
- msg_count = "N/A" if s.messages_count == -1 else f"{s.messages_count} msgs"
88
- model = s.model_name or "N/A"
89
- print(f"{i}. {first_msg}")
90
- print(f" {_relative_time(s.updated_at)} · {msg_count} · {model}")
91
- try:
92
- raw = input("Select a session number: ").strip()
93
- idx = int(raw)
94
- if 1 <= idx <= len(sessions):
95
- return str(sessions[idx - 1].id)
96
- except (ValueError, EOFError):
97
- return None
98
- return None
97
+
98
+ return options
@@ -197,11 +197,16 @@ class Session(BaseModel):
197
197
  )
198
198
  self._store.append_and_flush(session_id=self.id, items=items, meta=meta)
199
199
 
200
- def fork(self, *, new_id: str | None = None) -> Session:
200
+ def fork(self, *, new_id: str | None = None, until_index: int | None = None) -> Session:
201
201
  """Create a new session as a fork of the current session.
202
202
 
203
203
  The forked session copies metadata and conversation history, but does not
204
204
  modify the current session.
205
+
206
+ Args:
207
+ new_id: Optional ID for the forked session.
208
+ until_index: If provided, only copy conversation history up to (but not including) this index.
209
+ If None, copy all history.
205
210
  """
206
211
 
207
212
  forked = Session.create(id=new_id, work_dir=self.work_dir)
@@ -213,7 +218,8 @@ class Session(BaseModel):
213
218
  forked.file_tracker = {k: v.model_copy(deep=True) for k, v in self.file_tracker.items()}
214
219
  forked.todos = [todo.model_copy(deep=True) for todo in self.todos]
215
220
 
216
- items = [it.model_copy(deep=True) for it in self.conversation_history]
221
+ history_to_copy = self.conversation_history[:until_index] if until_index is not None else self.conversation_history
222
+ items = [it.model_copy(deep=True) for it in history_to_copy]
217
223
  if items:
218
224
  forked.append_history(items)
219
225
 
@@ -338,7 +344,7 @@ class Session(BaseModel):
338
344
  updated_at: float
339
345
  work_dir: str
340
346
  path: str
341
- first_user_message: str | None = None
347
+ user_messages: list[str] = []
342
348
  messages_count: int = -1
343
349
  model_name: str | None = None
344
350
 
@@ -346,10 +352,11 @@ class Session(BaseModel):
346
352
  def list_sessions(cls) -> list[SessionMetaBrief]:
347
353
  store = get_default_store()
348
354
 
349
- def _get_first_user_message(session_id: str) -> str | None:
355
+ def _get_user_messages(session_id: str) -> list[str]:
350
356
  events_path = store.paths.events_file(session_id)
351
357
  if not events_path.exists():
352
- return None
358
+ return []
359
+ messages: list[str] = []
353
360
  try:
354
361
  for line in events_path.read_text(encoding="utf-8").splitlines():
355
362
  obj_raw = json.loads(line)
@@ -360,15 +367,14 @@ class Session(BaseModel):
360
367
  continue
361
368
  data_raw = obj.get("data")
362
369
  if not isinstance(data_raw, dict):
363
- return None
370
+ continue
364
371
  data = cast(dict[str, Any], data_raw)
365
372
  content = data.get("content")
366
373
  if isinstance(content, str):
367
- return content
368
- return None
374
+ messages.append(content)
369
375
  except (OSError, json.JSONDecodeError):
370
- return None
371
- return None
376
+ pass
377
+ return messages
372
378
 
373
379
  items: list[Session.SessionMetaBrief] = []
374
380
  for meta_path in store.iter_meta_files():
@@ -382,7 +388,7 @@ class Session(BaseModel):
382
388
  created = float(data.get("created_at", meta_path.stat().st_mtime))
383
389
  updated = float(data.get("updated_at", meta_path.stat().st_mtime))
384
390
  work_dir = str(data.get("work_dir", ""))
385
- first_user_message = _get_first_user_message(sid)
391
+ user_messages = _get_user_messages(sid)
386
392
  messages_count = int(data.get("messages_count", -1))
387
393
  model_name = data.get("model_name") if isinstance(data.get("model_name"), str) else None
388
394
 
@@ -393,7 +399,7 @@ class Session(BaseModel):
393
399
  updated_at=updated,
394
400
  work_dir=work_dir,
395
401
  path=str(meta_path),
396
- first_user_message=first_user_message,
402
+ user_messages=user_messages,
397
403
  messages_count=messages_count,
398
404
  model_name=model_name,
399
405
  )
@@ -19,7 +19,7 @@ import re
19
19
  import shutil
20
20
  import subprocess
21
21
  import time
22
- from collections.abc import Iterable
22
+ from collections.abc import Callable, Iterable
23
23
  from pathlib import Path
24
24
  from typing import NamedTuple
25
25
 
@@ -27,7 +27,7 @@ from prompt_toolkit.completion import Completer, Completion
27
27
  from prompt_toolkit.document import Document
28
28
  from prompt_toolkit.formatted_text import FormattedText
29
29
 
30
- from klaude_code.command import CommandABC, get_commands
30
+ from klaude_code.protocol.commands import CommandInfo
31
31
  from klaude_code.trace.log import DebugType, log_debug
32
32
 
33
33
  # Pattern to match @token for completion refresh (used by key bindings).
@@ -40,12 +40,18 @@ AT_TOKEN_PATTERN = re.compile(r'(^|\s)@(?P<frag>"[^"]*"|[^\s]*)$')
40
40
  SKILL_TOKEN_PATTERN = re.compile(r"^[$¥](?P<frag>\S*)$")
41
41
 
42
42
 
43
- def create_repl_completer() -> Completer:
43
+ def create_repl_completer(
44
+ command_info_provider: Callable[[], list[CommandInfo]] | None = None,
45
+ ) -> Completer:
44
46
  """Create and return the combined REPL completer.
45
47
 
48
+ Args:
49
+ command_info_provider: Optional callable that returns command metadata.
50
+ If None, slash command completion is disabled.
51
+
46
52
  Returns a completer that handles both @ file paths and / slash commands.
47
53
  """
48
- return _ComboCompleter()
54
+ return _ComboCompleter(command_info_provider=command_info_provider)
49
55
 
50
56
 
51
57
  class _CmdResult(NamedTuple):
@@ -66,6 +72,9 @@ class _SlashCommandCompleter(Completer):
66
72
 
67
73
  _SLASH_TOKEN_RE = re.compile(r"^/(?P<frag>\S*)$")
68
74
 
75
+ def __init__(self, command_info_provider: Callable[[], list[CommandInfo]] | None = None) -> None:
76
+ self._command_info_provider = command_info_provider
77
+
69
78
  def get_completions(
70
79
  self,
71
80
  document: Document,
@@ -75,6 +84,9 @@ class _SlashCommandCompleter(Completer):
75
84
  if document.cursor_position_row != 0:
76
85
  return
77
86
 
87
+ if self._command_info_provider is None:
88
+ return
89
+
78
90
  text_before = document.current_line_before_cursor
79
91
  m = self._SLASH_TOKEN_RE.search(text_before)
80
92
  if not m:
@@ -84,20 +96,20 @@ class _SlashCommandCompleter(Completer):
84
96
  token_start = len(text_before) - len(f"/{frag}")
85
97
  start_position = token_start - len(text_before) # negative offset
86
98
 
87
- # Get available commands
88
- commands = get_commands()
99
+ # Get available commands from provider
100
+ command_infos = self._command_info_provider()
89
101
 
90
102
  # Filter commands that match the fragment (preserve registration order)
91
- matched: list[tuple[str, CommandABC, str]] = []
92
- for cmd_name, cmd_obj in commands.items():
93
- if cmd_name.startswith(frag):
94
- hint = f" [{cmd_obj.placeholder}]" if cmd_obj.support_addition_params else ""
95
- matched.append((cmd_name, cmd_obj, hint))
103
+ matched: list[tuple[str, CommandInfo, str]] = []
104
+ for cmd_info in command_infos:
105
+ if cmd_info.name.startswith(frag):
106
+ hint = f" [{cmd_info.placeholder}]" if cmd_info.support_addition_params else ""
107
+ matched.append((cmd_info.name, cmd_info, hint))
96
108
 
97
109
  if not matched:
98
110
  return
99
111
 
100
- for cmd_name, cmd_obj, hint in matched:
112
+ for cmd_name, cmd_info, hint in matched:
101
113
  completion_text = f"/{cmd_name} "
102
114
  # Use FormattedText to style the hint (placeholder) in bright black
103
115
  display = FormattedText([("", cmd_name), ("ansibrightblack", hint)]) if hint else cmd_name
@@ -105,7 +117,7 @@ class _SlashCommandCompleter(Completer):
105
117
  text=completion_text,
106
118
  start_position=start_position,
107
119
  display=display,
108
- display_meta=str(cmd_obj.summary),
120
+ display_meta=cmd_info.summary,
109
121
  )
110
122
 
111
123
  def is_slash_command_context(self, document: Document) -> bool:
@@ -200,9 +212,9 @@ class _SkillCompleter(Completer):
200
212
  class _ComboCompleter(Completer):
201
213
  """Combined completer that handles @ file paths, / slash commands, and $ skills."""
202
214
 
203
- def __init__(self) -> None:
215
+ def __init__(self, command_info_provider: Callable[[], list[CommandInfo]] | None = None) -> None:
204
216
  self._at_completer = _AtFilesCompleter()
205
- self._slash_completer = _SlashCommandCompleter()
217
+ self._slash_completer = _SlashCommandCompleter(command_info_provider=command_info_provider)
206
218
  self._skill_completer = _SkillCompleter()
207
219
 
208
220
  def get_completions(
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
 
5
- from rich.cells import cell_len
6
5
  from rich.rule import Rule
7
6
  from rich.text import Text
8
7
 
@@ -15,7 +14,7 @@ from klaude_code.ui.renderers.thinking import THINKING_MESSAGE_MARK, normalize_t
15
14
  from klaude_code.ui.rich import status as r_status
16
15
  from klaude_code.ui.rich.markdown import MarkdownStream, ThinkingMarkdown
17
16
  from klaude_code.ui.rich.theme import ThemeKey
18
- from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
17
+ from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier, emit_tmux_signal
19
18
  from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
20
19
 
21
20
 
@@ -265,11 +264,27 @@ class SpinnerStatusState:
265
264
 
266
265
  return result
267
266
 
268
- def get_context_text(self) -> Text | None:
269
- """Get context usage text for right-aligned display."""
270
- if self._context_percent is None:
267
+ def get_right_text(self) -> r_status.DynamicText | None:
268
+ """Get right-aligned status text (elapsed time and optional context %)."""
269
+
270
+ elapsed_text = r_status.current_elapsed_text()
271
+ has_context = self._context_percent is not None
272
+
273
+ if elapsed_text is None and not has_context:
271
274
  return None
272
- return Text(f"{self._context_percent:.1f}%", style=ThemeKey.METADATA_DIM)
275
+
276
+ def _render() -> Text:
277
+ parts: list[str] = []
278
+ if self._context_percent is not None:
279
+ parts.append(f"{self._context_percent:.1f}%")
280
+ current_elapsed = r_status.current_elapsed_text()
281
+ if current_elapsed is not None:
282
+ if parts:
283
+ parts.append(" · ")
284
+ parts.append(current_elapsed)
285
+ return Text("".join(parts), style=ThemeKey.METADATA_DIM)
286
+
287
+ return r_status.DynamicText(_render)
273
288
 
274
289
 
275
290
  class DisplayEventHandler:
@@ -509,6 +524,7 @@ class DisplayEventHandler:
509
524
  self.spinner_status.reset()
510
525
  self.renderer.spinner_stop()
511
526
  self.renderer.console.print(Rule(characters="─", style=ThemeKey.LINES))
527
+ emit_tmux_signal() # Signal test harness if KLAUDE_TEST_SIGNAL is set
512
528
  await self.stage_manager.transition_to(Stage.WAITING)
513
529
  self._maybe_notify_task_finish(event)
514
530
 
@@ -549,11 +565,10 @@ class DisplayEventHandler:
549
565
  def _update_spinner(self) -> None:
550
566
  """Update spinner text from current status state."""
551
567
  status_text = self.spinner_status.get_status()
552
- context_text = self.spinner_status.get_context_text()
553
- status_text = self._truncate_spinner_status_text(status_text, right_text=context_text)
568
+ right_text = self.spinner_status.get_right_text()
554
569
  self.renderer.spinner_update(
555
570
  status_text,
556
- context_text,
571
+ right_text,
557
572
  )
558
573
 
559
574
  async def _flush_assistant_buffer(self, state: StreamState) -> None:
@@ -611,27 +626,3 @@ class DisplayEventHandler:
611
626
  if len(todo.content) > 0:
612
627
  status_text = todo.content
613
628
  return status_text.replace("\n", " ").strip()
614
-
615
- def _truncate_spinner_status_text(self, status_text: Text, *, right_text: Text | None) -> Text:
616
- """Truncate spinner status to a single line based on terminal width.
617
-
618
- Rich wraps based on terminal cell width (CJK chars count as 2). Use
619
- cell-aware truncation to prevent the status from wrapping into two lines.
620
- """
621
-
622
- terminal_width = self.renderer.console.size.width
623
-
624
- # BreathingSpinner renders as a 2-column Table.grid(padding=1):
625
- # 1 cell for glyph + 1 cell of padding between columns (collapsed).
626
- spinner_prefix_cells = 2
627
-
628
- hint_cells = cell_len(r_status.current_hint_text())
629
- right_cells = cell_len(right_text.plain) if right_text is not None else 0
630
-
631
- max_main_cells = terminal_width - spinner_prefix_cells - hint_cells - right_cells - 1
632
- # rich.text.Text.truncate behaves unexpectedly for 0; clamp to at least 1.
633
- max_main_cells = max(1, max_main_cells)
634
-
635
- truncated = status_text.copy()
636
- truncated.truncate(max_main_cells, overflow="ellipsis", pad=False)
637
- return truncated