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
@@ -1,4 +1,4 @@
1
- import sys
1
+ from dataclasses import dataclass
2
2
 
3
3
  from klaude_code.config.config import ModelEntry, load_config, print_no_available_models_hint
4
4
  from klaude_code.trace import log
@@ -17,15 +17,34 @@ def _normalize_model_key(value: str) -> str:
17
17
  return "".join(ch for ch in value.casefold() if ch.isalnum())
18
18
 
19
19
 
20
- def select_model_from_config(preferred: str | None = None) -> str | None:
20
+ @dataclass
21
+ class ModelMatchResult:
22
+ """Result of model matching.
23
+
24
+ Attributes:
25
+ matched_model: The single matched model name, or None if ambiguous/no match.
26
+ filtered_models: List of filtered models for interactive selection.
27
+ filter_hint: The filter hint to show (original preferred value), or None.
28
+ error_message: Error message if no models available, or None.
21
29
  """
22
- Interactive single-choice model selector.
23
- for `--select-model`
30
+
31
+ matched_model: str | None
32
+ filtered_models: list[ModelEntry]
33
+ filter_hint: str | None
34
+ error_message: str | None = None
35
+
36
+
37
+ def match_model_from_config(preferred: str | None = None) -> ModelMatchResult:
38
+ """Match model from config without interactive selection.
24
39
 
25
40
  If preferred is provided:
26
- - Exact match: return immediately
27
- - Single partial match (case-insensitive): return immediately
28
- - Otherwise: fall through to interactive selection
41
+ - Exact match: returns matched_model
42
+ - Single partial match (case-insensitive): returns matched_model
43
+ - Multiple matches: returns filtered_models for interactive selection
44
+ - No matches: returns all models with filter_hint=None
45
+
46
+ Returns:
47
+ ModelMatchResult with match state.
29
48
  """
30
49
  config = load_config()
31
50
 
@@ -36,17 +55,22 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
36
55
 
37
56
  if not models:
38
57
  print_no_available_models_hint()
39
- return None
58
+ return ModelMatchResult(
59
+ matched_model=None,
60
+ filtered_models=[],
61
+ filter_hint=None,
62
+ error_message="No models available",
63
+ )
40
64
 
41
65
  names: list[str] = [m.model_name for m in models]
42
66
 
43
67
  # Try to match preferred model name
44
- filtered_models = models
68
+ filter_hint = preferred
45
69
  if preferred and preferred.strip():
46
70
  preferred = preferred.strip()
47
71
  # Exact match
48
72
  if preferred in names:
49
- return preferred
73
+ return ModelMatchResult(matched_model=preferred, filtered_models=models, filter_hint=None)
50
74
 
51
75
  preferred_lower = preferred.lower()
52
76
  # Case-insensitive exact match (model_name or model_params.model)
@@ -56,7 +80,9 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
56
80
  if preferred_lower == m.model_name.lower() or preferred_lower == (m.model_params.model or "").lower()
57
81
  ]
58
82
  if len(exact_ci_matches) == 1:
59
- return exact_ci_matches[0].model_name
83
+ return ModelMatchResult(
84
+ matched_model=exact_ci_matches[0].model_name, filtered_models=models, filter_hint=None
85
+ )
60
86
 
61
87
  # Normalized matching (e.g. gpt52 == gpt-5.2, gpt52 in gpt-5.2-2025-...)
62
88
  preferred_norm = _normalize_model_key(preferred)
@@ -69,7 +95,9 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
69
95
  or preferred_norm == _normalize_model_key(m.model_params.model or "")
70
96
  ]
71
97
  if len(normalized_matches) == 1:
72
- return normalized_matches[0].model_name
98
+ return ModelMatchResult(
99
+ matched_model=normalized_matches[0].model_name, filtered_models=models, filter_hint=None
100
+ )
73
101
 
74
102
  if not normalized_matches and len(preferred_norm) >= 4:
75
103
  normalized_matches = [
@@ -79,7 +107,9 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
79
107
  or preferred_norm in _normalize_model_key(m.model_params.model or "")
80
108
  ]
81
109
  if len(normalized_matches) == 1:
82
- return normalized_matches[0].model_name
110
+ return ModelMatchResult(
111
+ matched_model=normalized_matches[0].model_name, filtered_models=models, filter_hint=None
112
+ )
83
113
 
84
114
  # Partial match (case-insensitive) on model_name or model_params.model.
85
115
  # If normalized matching found candidates (even if multiple), prefer those as the filter set.
@@ -89,93 +119,13 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
89
119
  if preferred_lower in m.model_name.lower() or preferred_lower in (m.model_params.model or "").lower()
90
120
  ]
91
121
  if len(matches) == 1:
92
- return matches[0].model_name
122
+ return ModelMatchResult(matched_model=matches[0].model_name, filtered_models=models, filter_hint=None)
93
123
  if matches:
94
124
  # Multiple matches: filter the list for interactive selection
95
- filtered_models = matches
125
+ return ModelMatchResult(matched_model=None, filtered_models=matches, filter_hint=filter_hint)
96
126
  else:
97
127
  # No matches: show all models without filter hint
98
- preferred = None
99
128
  log(("No matching models found. Showing all models.", "yellow"))
129
+ return ModelMatchResult(matched_model=None, filtered_models=models, filter_hint=None)
100
130
 
101
- # Non-interactive environments (CI/pipes) should never enter an interactive prompt.
102
- # If we couldn't resolve to a single model deterministically above, fail with a clear hint.
103
- if not sys.stdin.isatty() or not sys.stdout.isatty():
104
- log(("Error: cannot use interactive model selection without a TTY", "red"))
105
- log(("Hint: pass --model <config-name> or set main_model in ~/.klaude/klaude-config.yaml", "yellow"))
106
- if preferred:
107
- log((f"Hint: '{preferred}' did not resolve to a single configured model", "yellow"))
108
- return None
109
-
110
- try:
111
- from prompt_toolkit.styles import Style
112
-
113
- from klaude_code.ui.terminal.selector import SelectItem, select_one
114
-
115
- max_model_name_length = max(len(m.model_name) for m in filtered_models)
116
-
117
- def _thinking_info(m: ModelEntry) -> str:
118
- thinking = m.model_params.thinking
119
- if not thinking:
120
- return ""
121
- if thinking.reasoning_effort:
122
- return f"reasoning {thinking.reasoning_effort}"
123
- if thinking.budget_tokens:
124
- return f"thinking budget {thinking.budget_tokens}"
125
- return "thinking (configured)"
126
-
127
- items: list[SelectItem[str]] = []
128
- for m in filtered_models:
129
- model_id = m.model_params.model or "N/A"
130
- first_line_prefix = f"{m.model_name:<{max_model_name_length}} → "
131
- thinking_info = _thinking_info(m)
132
- meta_parts: list[str] = [m.provider]
133
- if thinking_info:
134
- meta_parts.append(thinking_info)
135
- if m.model_params.verbosity:
136
- meta_parts.append(f"verbosity {m.model_params.verbosity}")
137
- meta_str = " · ".join(meta_parts)
138
- title = [
139
- ("class:msg", first_line_prefix),
140
- ("class:msg bold", model_id),
141
- ("class:meta", f" {meta_str}\n"),
142
- ]
143
- search_text = f"{m.model_name} {model_id} {m.provider}"
144
- items.append(SelectItem(title=title, value=m.model_name, search_text=search_text))
145
-
146
- try:
147
- message = f"Select a model (filtered by '{preferred}'):" if preferred else "Select a model:"
148
- result = select_one(
149
- message=message,
150
- items=items,
151
- pointer="→",
152
- use_search_filter=True,
153
- initial_value=config.main_model,
154
- style=Style(
155
- [
156
- ("pointer", "ansigreen"),
157
- ("highlighted", "ansigreen"),
158
- ("msg", ""),
159
- ("meta", "fg:ansibrightblack"),
160
- ("text", "ansibrightblack"),
161
- ("question", "bold"),
162
- ("search_prefix", "ansibrightblack"),
163
- # search filter colors at the bottom
164
- ("search_success", "noinherit fg:ansigreen"),
165
- ("search_none", "noinherit fg:ansired"),
166
- ]
167
- ),
168
- )
169
- if isinstance(result, str) and result in names:
170
- return result
171
- except KeyboardInterrupt:
172
- return None
173
- except Exception as e:
174
- log((f"Failed to use prompt_toolkit for model selection: {e}", "yellow"))
175
- # Never return an unvalidated model name here.
176
- # If we can't interactively select, fall back to a known configured model.
177
- if isinstance(preferred, str) and preferred in names:
178
- return preferred
179
- if config.main_model and config.main_model in names:
180
- return config.main_model
181
- return None
131
+ return ModelMatchResult(matched_model=None, filtered_models=models, filter_hint=None)
@@ -0,0 +1,255 @@
1
+ """Thinking level configuration data and helpers.
2
+
3
+ This module contains thinking level definitions and helper functions
4
+ that are shared between command layer and UI layer.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Literal
9
+
10
+ from klaude_code.protocol import llm_param
11
+
12
+ ReasoningEffort = Literal["high", "medium", "low", "minimal", "none", "xhigh"]
13
+
14
+ # Thinking level options for different protocols
15
+ RESPONSES_LEVELS = ["low", "medium", "high"]
16
+ RESPONSES_GPT51_LEVELS = ["none", "low", "medium", "high"]
17
+ RESPONSES_GPT52_LEVELS = ["none", "low", "medium", "high", "xhigh"]
18
+ RESPONSES_CODEX_MAX_LEVELS = ["medium", "high", "xhigh"]
19
+ RESPONSES_GEMINI_FLASH_LEVELS = ["minimal", "low", "medium", "high"]
20
+
21
+ ANTHROPIC_LEVELS: list[tuple[str, int | None]] = [
22
+ ("off", 0),
23
+ ("low (2048 tokens)", 2048),
24
+ ("medium (8192 tokens)", 8192),
25
+ ("high (31999 tokens)", 31999),
26
+ ]
27
+
28
+
29
+ def is_openrouter_model_with_reasoning_effort(model_name: str | None) -> bool:
30
+ """Check if the model is GPT series, Grok or Gemini 3."""
31
+ if not model_name:
32
+ return False
33
+ model_lower = model_name.lower()
34
+ return model_lower.startswith(("openai/gpt-", "x-ai/grok-", "google/gemini-3"))
35
+
36
+
37
+ def _is_gpt51_model(model_name: str | None) -> bool:
38
+ """Check if the model is GPT-5.1."""
39
+ if not model_name:
40
+ return False
41
+ return model_name.lower() in ["gpt-5.1", "openai/gpt-5.1", "gpt-5.1-codex-2025-11-13"]
42
+
43
+
44
+ def _is_gpt52_model(model_name: str | None) -> bool:
45
+ """Check if the model is GPT-5.2."""
46
+ if not model_name:
47
+ return False
48
+ return model_name.lower() in ["gpt-5.2", "openai/gpt-5.2"]
49
+
50
+
51
+ def _is_codex_max_model(model_name: str | None) -> bool:
52
+ """Check if the model is GPT-5.1-codex-max."""
53
+ if not model_name:
54
+ return False
55
+ return "codex-max" in model_name.lower()
56
+
57
+
58
+ def _is_gemini_flash_model(model_name: str | None) -> bool:
59
+ """Check if the model is Gemini 3 Flash."""
60
+ if not model_name:
61
+ return False
62
+ return "gemini-3-flash" in model_name.lower()
63
+
64
+
65
+ def should_auto_trigger_thinking(model_name: str | None) -> bool:
66
+ """Check if model should auto-trigger thinking selection on switch."""
67
+ if not model_name:
68
+ return False
69
+ model_lower = model_name.lower()
70
+ return "gpt-5" in model_lower or "gemini-3" in model_lower or "opus" in model_lower
71
+
72
+
73
+ def get_levels_for_responses(model_name: str | None) -> list[str]:
74
+ """Get thinking levels for responses protocol."""
75
+ if _is_codex_max_model(model_name):
76
+ return RESPONSES_CODEX_MAX_LEVELS
77
+ if _is_gpt52_model(model_name):
78
+ return RESPONSES_GPT52_LEVELS
79
+ if _is_gpt51_model(model_name):
80
+ return RESPONSES_GPT51_LEVELS
81
+ if _is_gemini_flash_model(model_name):
82
+ return RESPONSES_GEMINI_FLASH_LEVELS
83
+ return RESPONSES_LEVELS
84
+
85
+
86
+ def format_current_thinking(config: llm_param.LLMConfigParameter) -> str:
87
+ """Format the current thinking configuration for display."""
88
+ thinking = config.thinking
89
+ if not thinking:
90
+ return "not configured"
91
+
92
+ protocol = config.protocol
93
+
94
+ if protocol in (llm_param.LLMClientProtocol.RESPONSES, llm_param.LLMClientProtocol.CODEX):
95
+ if thinking.reasoning_effort:
96
+ return f"reasoning_effort={thinking.reasoning_effort}"
97
+ return "not set"
98
+
99
+ if protocol == llm_param.LLMClientProtocol.ANTHROPIC:
100
+ if thinking.type == "disabled":
101
+ return "off"
102
+ if thinking.type == "enabled":
103
+ return f"enabled (budget_tokens={thinking.budget_tokens})"
104
+ return "not set"
105
+
106
+ if protocol == llm_param.LLMClientProtocol.OPENROUTER:
107
+ if is_openrouter_model_with_reasoning_effort(config.model):
108
+ if thinking.reasoning_effort:
109
+ return f"reasoning_effort={thinking.reasoning_effort}"
110
+ else:
111
+ if thinking.type == "disabled":
112
+ return "off"
113
+ if thinking.type == "enabled":
114
+ return f"enabled (budget_tokens={thinking.budget_tokens})"
115
+ return "not set"
116
+
117
+ if protocol == llm_param.LLMClientProtocol.OPENAI:
118
+ if thinking.type == "disabled":
119
+ return "off"
120
+ if thinking.type == "enabled":
121
+ return f"enabled (budget_tokens={thinking.budget_tokens})"
122
+ return "not set"
123
+
124
+ return "unknown protocol"
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Thinking picker data structures
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ @dataclass
133
+ class ThinkingOption:
134
+ """A thinking option for selection.
135
+
136
+ Attributes:
137
+ label: Display label for this option (e.g., "low", "medium (8192 tokens)").
138
+ value: Encoded value string (e.g., "effort:low", "budget:2048").
139
+ """
140
+
141
+ label: str
142
+ value: str
143
+
144
+
145
+ @dataclass
146
+ class ThinkingPickerData:
147
+ """Data for building thinking picker UI.
148
+
149
+ Attributes:
150
+ options: List of thinking options.
151
+ message: Prompt message (e.g., "Select reasoning effort:").
152
+ current_value: Currently selected value, or None.
153
+ """
154
+
155
+ options: list[ThinkingOption]
156
+ message: str
157
+ current_value: str | None
158
+
159
+
160
+ def _build_effort_options(levels: list[str]) -> list[ThinkingOption]:
161
+ """Build effort-based thinking options."""
162
+ return [ThinkingOption(label=level, value=f"effort:{level}") for level in levels]
163
+
164
+
165
+ def _build_budget_options() -> list[ThinkingOption]:
166
+ """Build budget-based thinking options."""
167
+ return [ThinkingOption(label=label, value=f"budget:{tokens or 0}") for label, tokens in ANTHROPIC_LEVELS]
168
+
169
+
170
+ def _get_current_effort_value(thinking: llm_param.Thinking | None) -> str | None:
171
+ """Get current value for effort-based thinking."""
172
+ if thinking and thinking.reasoning_effort:
173
+ return f"effort:{thinking.reasoning_effort}"
174
+ return None
175
+
176
+
177
+ def _get_current_budget_value(thinking: llm_param.Thinking | None) -> str | None:
178
+ """Get current value for budget-based thinking."""
179
+ if thinking:
180
+ if thinking.type == "disabled":
181
+ return "budget:0"
182
+ if thinking.budget_tokens:
183
+ return f"budget:{thinking.budget_tokens}"
184
+ return None
185
+
186
+
187
+ def get_thinking_picker_data(config: llm_param.LLMConfigParameter) -> ThinkingPickerData | None:
188
+ """Get thinking picker data based on LLM config.
189
+
190
+ Returns:
191
+ ThinkingPickerData with options and current value, or None if protocol doesn't support thinking.
192
+ """
193
+ protocol = config.protocol
194
+ model_name = config.model
195
+ thinking = config.thinking
196
+
197
+ if protocol in (llm_param.LLMClientProtocol.RESPONSES, llm_param.LLMClientProtocol.CODEX):
198
+ levels = get_levels_for_responses(model_name)
199
+ return ThinkingPickerData(
200
+ options=_build_effort_options(levels),
201
+ message="Select reasoning effort:",
202
+ current_value=_get_current_effort_value(thinking),
203
+ )
204
+
205
+ if protocol == llm_param.LLMClientProtocol.ANTHROPIC:
206
+ return ThinkingPickerData(
207
+ options=_build_budget_options(),
208
+ message="Select thinking level:",
209
+ current_value=_get_current_budget_value(thinking),
210
+ )
211
+
212
+ if protocol == llm_param.LLMClientProtocol.OPENROUTER:
213
+ if is_openrouter_model_with_reasoning_effort(model_name):
214
+ levels = get_levels_for_responses(model_name)
215
+ return ThinkingPickerData(
216
+ options=_build_effort_options(levels),
217
+ message="Select reasoning effort:",
218
+ current_value=_get_current_effort_value(thinking),
219
+ )
220
+ return ThinkingPickerData(
221
+ options=_build_budget_options(),
222
+ message="Select thinking level:",
223
+ current_value=_get_current_budget_value(thinking),
224
+ )
225
+
226
+ if protocol == llm_param.LLMClientProtocol.OPENAI:
227
+ return ThinkingPickerData(
228
+ options=_build_budget_options(),
229
+ message="Select thinking level:",
230
+ current_value=_get_current_budget_value(thinking),
231
+ )
232
+
233
+ return None
234
+
235
+
236
+ def parse_thinking_value(value: str) -> llm_param.Thinking | None:
237
+ """Parse a thinking value string into a Thinking object.
238
+
239
+ Args:
240
+ value: Encoded value string (e.g., "effort:low", "budget:2048").
241
+
242
+ Returns:
243
+ Thinking object, or None if invalid format.
244
+ """
245
+ if value.startswith("effort:"):
246
+ effort = value[7:]
247
+ return llm_param.Thinking(reasoning_effort=effort) # type: ignore[arg-type]
248
+
249
+ if value.startswith("budget:"):
250
+ budget = int(value[7:])
251
+ if budget == 0:
252
+ return llm_param.Thinking(type="disabled", budget_tokens=0)
253
+ return llm_param.Thinking(type="enabled", budget_tokens=budget)
254
+
255
+ return None
@@ -13,18 +13,13 @@ from collections.abc import Callable
13
13
  from dataclasses import dataclass
14
14
  from pathlib import Path
15
15
 
16
- from klaude_code.command import dispatch_command
17
- from klaude_code.command.thinking_cmd import (
18
- format_current_thinking,
19
- select_thinking_for_protocol,
20
- should_auto_trigger_thinking,
21
- )
22
16
  from klaude_code.config import load_config
23
17
  from klaude_code.core.agent import Agent, DefaultModelProfileProvider, ModelProfileProvider
24
18
  from klaude_code.core.manager import LLMClients, SubAgentManager
25
19
  from klaude_code.core.tool import current_run_subtask_callback
26
20
  from klaude_code.llm.registry import create_llm_client
27
21
  from klaude_code.protocol import commands, events, model, op
22
+ from klaude_code.protocol.llm_param import Thinking
28
23
  from klaude_code.protocol.op_handler import OperationHandler
29
24
  from klaude_code.protocol.sub_agent import SubAgentResult
30
25
  from klaude_code.session.export import build_export_html, get_default_export_path
@@ -181,7 +176,11 @@ class ExecutorContext:
181
176
  await self._ensure_agent(operation.session_id)
182
177
 
183
178
  async def handle_user_input(self, operation: op.UserInputOperation) -> None:
184
- """Handle a user input operation by dispatching it into operations."""
179
+ """Handle a user input operation.
180
+
181
+ Core should not parse slash commands. The UI/CLI layer is responsible for
182
+ turning raw user input into one or more operations.
183
+ """
185
184
 
186
185
  if operation.session_id is None:
187
186
  raise ValueError("session_id cannot be None")
@@ -190,33 +189,18 @@ class ExecutorContext:
190
189
  agent = await self._ensure_agent(session_id)
191
190
  user_input = operation.input
192
191
 
193
- # Emit the original user input to UI (even if the persisted text differs).
194
192
  await self.emit_event(
195
193
  events.UserMessageEvent(content=user_input.text, session_id=session_id, images=user_input.images)
196
194
  )
195
+ agent.session.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
197
196
 
198
- result = await dispatch_command(user_input, agent, submission_id=operation.id)
199
- ops: list[op.Operation] = list(result.operations or [])
200
-
201
- run_ops = [candidate for candidate in ops if isinstance(candidate, op.RunAgentOperation)]
202
- if len(run_ops) > 1:
203
- raise ValueError("Multiple RunAgentOperation results are not supported")
204
-
205
- persisted_user_input = run_ops[0].input if run_ops else user_input
206
-
207
- if result.persist_user_input:
208
- agent.session.append_history(
209
- [model.UserMessageItem(content=persisted_user_input.text, images=persisted_user_input.images)]
197
+ await self.handle_run_agent(
198
+ op.RunAgentOperation(
199
+ id=operation.id,
200
+ session_id=session_id,
201
+ input=user_input,
210
202
  )
211
-
212
- if result.events:
213
- for evt in result.events:
214
- if result.persist_events and isinstance(evt, events.DeveloperMessageEvent):
215
- agent.session.append_history([evt.item])
216
- await self.emit_event(evt)
217
-
218
- for operation_item in ops:
219
- await operation_item.execute(handler=self)
203
+ )
220
204
 
221
205
  async def handle_run_agent(self, operation: op.RunAgentOperation) -> None:
222
206
  agent = await self._ensure_agent(operation.session_id)
@@ -243,56 +227,62 @@ class ExecutorContext:
243
227
  config.main_model = operation.model_name
244
228
  await config.save()
245
229
 
246
- default_note = " (saved as default)" if operation.save_as_default else ""
247
- developer_item = model.DeveloperMessageItem(
248
- content=f"Switched to: {llm_config.model}{default_note}",
249
- command_output=model.CommandOutput(command_name=commands.CommandName.MODEL),
250
- )
251
- agent.session.append_history([developer_item])
252
-
253
- await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
230
+ if operation.emit_switch_message:
231
+ default_note = " (saved as default)" if operation.save_as_default else ""
232
+ developer_item = model.DeveloperMessageItem(
233
+ content=f"Switched to: {llm_config.model}{default_note}",
234
+ command_output=model.CommandOutput(command_name=commands.CommandName.MODEL),
235
+ )
236
+ agent.session.append_history([developer_item])
237
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
254
238
 
255
239
  if self._on_model_change is not None:
256
240
  self._on_model_change(llm_client.model_name)
257
241
 
258
- if should_auto_trigger_thinking(llm_config.model):
259
- thinking_op = op.ChangeThinkingOperation(session_id=operation.session_id)
260
- await thinking_op.execute(handler=self)
261
- # WelcomeEvent is already handled by the thinking change
262
- else:
242
+ if operation.emit_welcome_event:
263
243
  await self.emit_event(events.WelcomeEvent(llm_config=llm_config, work_dir=str(agent.session.work_dir)))
264
244
 
265
245
  async def handle_change_thinking(self, operation: op.ChangeThinkingOperation) -> None:
266
- """Handle a change thinking operation by prompting user to select thinking level."""
246
+ """Handle a change thinking operation.
247
+
248
+ Interactive thinking selection must happen in the UI/CLI layer. Core only
249
+ applies a concrete thinking configuration.
250
+ """
267
251
  agent = await self._ensure_agent(operation.session_id)
268
- if not agent.profile:
269
- return
270
252
 
271
253
  config = agent.profile.llm_client.get_llm_config()
272
- current = format_current_thinking(config)
273
-
274
- new_thinking = await select_thinking_for_protocol(config)
275
254
 
276
- if new_thinking is None:
255
+ def _format_thinking_for_display(thinking: Thinking | None) -> str:
256
+ if thinking is None:
257
+ return "not configured"
258
+ if thinking.reasoning_effort:
259
+ return f"reasoning_effort={thinking.reasoning_effort}"
260
+ if thinking.type == "disabled":
261
+ return "off"
262
+ if thinking.type == "enabled":
263
+ if thinking.budget_tokens is None:
264
+ return "enabled"
265
+ return f"enabled (budget_tokens={thinking.budget_tokens})"
266
+ return "not set"
267
+
268
+ if operation.thinking is None:
269
+ raise ValueError("thinking must be provided; interactive selection belongs to UI")
270
+
271
+ current = _format_thinking_for_display(config.thinking)
272
+ config.thinking = operation.thinking
273
+ agent.session.model_thinking = operation.thinking
274
+ new_status = _format_thinking_for_display(config.thinking)
275
+
276
+ if operation.emit_switch_message:
277
277
  developer_item = model.DeveloperMessageItem(
278
- content="(thinking unchanged)",
278
+ content=f"Thinking changed: {current} -> {new_status}",
279
279
  command_output=model.CommandOutput(command_name=commands.CommandName.THINKING),
280
280
  )
281
+ agent.session.append_history([developer_item])
281
282
  await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
282
- return
283
-
284
- config.thinking = new_thinking
285
- agent.session.model_thinking = new_thinking
286
- new_status = format_current_thinking(config)
287
-
288
- developer_item = model.DeveloperMessageItem(
289
- content=f"Thinking changed: {current} -> {new_status}",
290
- command_output=model.CommandOutput(command_name=commands.CommandName.THINKING),
291
- )
292
- agent.session.append_history([developer_item])
293
283
 
294
- await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
295
- await self.emit_event(events.WelcomeEvent(work_dir=str(agent.session.work_dir), llm_config=config))
284
+ if operation.emit_welcome_event:
285
+ await self.emit_event(events.WelcomeEvent(work_dir=str(agent.session.work_dir), llm_config=config))
296
286
 
297
287
  async def handle_clear_session(self, operation: op.ClearSessionOperation) -> None:
298
288
  agent = await self._ensure_agent(operation.session_id)
klaude_code/llm/usage.py CHANGED
@@ -81,7 +81,7 @@ class MetadataTracker:
81
81
  ) * 1000
82
82
 
83
83
  if self._last_token_time is not None and self._metadata_item.usage.output_tokens > 0:
84
- time_duration = self._last_token_time - self._first_token_time
84
+ time_duration = self._last_token_time - self._request_start_time
85
85
  if time_duration >= 0.15:
86
86
  self._metadata_item.usage.throughput_tps = self._metadata_item.usage.output_tokens / time_duration
87
87
 
@@ -1,6 +1,17 @@
1
+ from dataclasses import dataclass
1
2
  from enum import Enum
2
3
 
3
4
 
5
+ @dataclass(frozen=True, slots=True)
6
+ class CommandInfo:
7
+ """Lightweight command metadata for UI purposes (no logic)."""
8
+
9
+ name: str
10
+ summary: str
11
+ support_addition_params: bool = False
12
+ placeholder: str = ""
13
+
14
+
4
15
  class CommandName(str, Enum):
5
16
  INIT = "init"
6
17
  DEBUG = "debug"