klaude-code 2.2.0__py3-none-any.whl → 2.4.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 (82) hide show
  1. klaude_code/app/runtime.py +2 -15
  2. klaude_code/cli/list_model.py +30 -13
  3. klaude_code/cli/main.py +26 -10
  4. klaude_code/config/assets/builtin_config.yaml +177 -310
  5. klaude_code/config/config.py +158 -21
  6. klaude_code/config/{select_model.py → model_matcher.py} +41 -16
  7. klaude_code/config/sub_agent_model_helper.py +217 -0
  8. klaude_code/config/thinking.py +2 -2
  9. klaude_code/const.py +1 -1
  10. klaude_code/core/agent_profile.py +43 -5
  11. klaude_code/core/executor.py +129 -47
  12. klaude_code/core/manager/llm_clients_builder.py +17 -11
  13. klaude_code/core/prompts/prompt-nano-banana.md +1 -1
  14. klaude_code/core/tool/file/diff_builder.py +25 -18
  15. klaude_code/core/tool/sub_agent_tool.py +2 -1
  16. klaude_code/llm/anthropic/client.py +12 -9
  17. klaude_code/llm/anthropic/input.py +54 -29
  18. klaude_code/llm/client.py +1 -1
  19. klaude_code/llm/codex/client.py +2 -2
  20. klaude_code/llm/google/client.py +7 -7
  21. klaude_code/llm/google/input.py +23 -2
  22. klaude_code/llm/input_common.py +2 -2
  23. klaude_code/llm/openai_compatible/client.py +3 -3
  24. klaude_code/llm/openai_compatible/input.py +22 -13
  25. klaude_code/llm/openai_compatible/stream.py +1 -1
  26. klaude_code/llm/openrouter/client.py +4 -4
  27. klaude_code/llm/openrouter/input.py +35 -25
  28. klaude_code/llm/responses/client.py +5 -5
  29. klaude_code/llm/responses/input.py +96 -57
  30. klaude_code/protocol/commands.py +1 -2
  31. klaude_code/protocol/events/__init__.py +7 -1
  32. klaude_code/protocol/events/chat.py +10 -0
  33. klaude_code/protocol/events/system.py +4 -0
  34. klaude_code/protocol/llm_param.py +1 -1
  35. klaude_code/protocol/model.py +0 -26
  36. klaude_code/protocol/op.py +17 -5
  37. klaude_code/protocol/op_handler.py +5 -0
  38. klaude_code/protocol/sub_agent/AGENTS.md +28 -0
  39. klaude_code/protocol/sub_agent/__init__.py +10 -14
  40. klaude_code/protocol/sub_agent/image_gen.py +2 -1
  41. klaude_code/session/codec.py +2 -6
  42. klaude_code/session/session.py +13 -3
  43. klaude_code/skill/assets/create-plan/SKILL.md +3 -5
  44. klaude_code/tui/command/__init__.py +3 -6
  45. klaude_code/tui/command/clear_cmd.py +0 -1
  46. klaude_code/tui/command/command_abc.py +6 -4
  47. klaude_code/tui/command/copy_cmd.py +10 -10
  48. klaude_code/tui/command/debug_cmd.py +11 -10
  49. klaude_code/tui/command/export_online_cmd.py +18 -23
  50. klaude_code/tui/command/fork_session_cmd.py +39 -43
  51. klaude_code/tui/command/model_cmd.py +10 -49
  52. klaude_code/tui/command/model_picker.py +142 -0
  53. klaude_code/tui/command/refresh_cmd.py +0 -1
  54. klaude_code/tui/command/registry.py +15 -21
  55. klaude_code/tui/command/resume_cmd.py +10 -16
  56. klaude_code/tui/command/status_cmd.py +8 -12
  57. klaude_code/tui/command/sub_agent_model_cmd.py +185 -0
  58. klaude_code/tui/command/terminal_setup_cmd.py +8 -11
  59. klaude_code/tui/command/thinking_cmd.py +4 -6
  60. klaude_code/tui/commands.py +5 -0
  61. klaude_code/tui/components/bash_syntax.py +1 -1
  62. klaude_code/tui/components/command_output.py +96 -0
  63. klaude_code/tui/components/common.py +1 -1
  64. klaude_code/tui/components/developer.py +3 -115
  65. klaude_code/tui/components/metadata.py +1 -63
  66. klaude_code/tui/components/rich/cjk_wrap.py +3 -2
  67. klaude_code/tui/components/rich/status.py +49 -3
  68. klaude_code/tui/components/rich/theme.py +2 -0
  69. klaude_code/tui/components/sub_agent.py +25 -46
  70. klaude_code/tui/components/welcome.py +99 -0
  71. klaude_code/tui/input/prompt_toolkit.py +19 -8
  72. klaude_code/tui/machine.py +5 -0
  73. klaude_code/tui/renderer.py +7 -8
  74. klaude_code/tui/runner.py +0 -6
  75. klaude_code/tui/terminal/selector.py +8 -6
  76. {klaude_code-2.2.0.dist-info → klaude_code-2.4.0.dist-info}/METADATA +21 -74
  77. {klaude_code-2.2.0.dist-info → klaude_code-2.4.0.dist-info}/RECORD +79 -76
  78. klaude_code/tui/command/help_cmd.py +0 -51
  79. klaude_code/tui/command/model_select.py +0 -84
  80. klaude_code/tui/command/release_notes_cmd.py +0 -85
  81. {klaude_code-2.2.0.dist-info → klaude_code-2.4.0.dist-info}/WHEEL +0 -0
  82. {klaude_code-2.2.0.dist-info → klaude_code-2.4.0.dist-info}/entry_points.txt +0 -0
@@ -35,12 +35,11 @@ def ensure_commands_loaded() -> None:
35
35
  from .export_cmd import ExportCommand
36
36
  from .export_online_cmd import ExportOnlineCommand
37
37
  from .fork_session_cmd import ForkSessionCommand
38
- from .help_cmd import HelpCommand
39
38
  from .model_cmd import ModelCommand
40
39
  from .refresh_cmd import RefreshTerminalCommand
41
- from .release_notes_cmd import ReleaseNotesCommand
42
40
  from .resume_cmd import ResumeCommand
43
41
  from .status_cmd import StatusCommand
42
+ from .sub_agent_model_cmd import SubAgentModelCommand
44
43
  from .terminal_setup_cmd import TerminalSetupCommand
45
44
  from .thinking_cmd import ThinkingCommand
46
45
 
@@ -49,13 +48,12 @@ def ensure_commands_loaded() -> None:
49
48
  register(ExportCommand())
50
49
  register(RefreshTerminalCommand())
51
50
  register(ModelCommand())
51
+ register(SubAgentModelCommand())
52
52
  register(ThinkingCommand())
53
53
  register(ForkSessionCommand())
54
54
  load_prompt_commands()
55
55
  register(StatusCommand())
56
56
  register(ResumeCommand())
57
- register(HelpCommand())
58
- register(ReleaseNotesCommand())
59
57
  register(ExportOnlineCommand())
60
58
  register(TerminalSetupCommand())
61
59
  register(DebugCommand())
@@ -73,12 +71,11 @@ def __getattr__(name: str) -> object:
73
71
  "ExportCommand": "export_cmd",
74
72
  "ExportOnlineCommand": "export_online_cmd",
75
73
  "ForkSessionCommand": "fork_session_cmd",
76
- "HelpCommand": "help_cmd",
77
74
  "ModelCommand": "model_cmd",
78
75
  "RefreshTerminalCommand": "refresh_cmd",
79
- "ReleaseNotesCommand": "release_notes_cmd",
80
76
  "ResumeCommand": "resume_cmd",
81
77
  "StatusCommand": "status_cmd",
78
+ "SubAgentModelCommand": "sub_agent_model_cmd",
82
79
  "TerminalSetupCommand": "terminal_setup_cmd",
83
80
  "ThinkingCommand": "thinking_cmd",
84
81
  }
@@ -22,5 +22,4 @@ class ClearCommand(CommandABC):
22
22
 
23
23
  return CommandResult(
24
24
  operations=[op.ClearSessionOperation(session_id=agent.session.id)],
25
- persist=False,
26
25
  )
@@ -37,14 +37,16 @@ class CommandResult(BaseModel):
37
37
  """Result of a command execution."""
38
38
 
39
39
  events: (
40
- list[protocol_events.DeveloperMessageEvent | protocol_events.WelcomeEvent | protocol_events.ReplayHistoryEvent]
40
+ list[
41
+ protocol_events.CommandOutputEvent
42
+ | protocol_events.ErrorEvent
43
+ | protocol_events.WelcomeEvent
44
+ | protocol_events.ReplayHistoryEvent
45
+ ]
41
46
  | None
42
47
  ) = None # List of UI events to display immediately
43
48
  operations: list[op.Operation] | None = None
44
49
 
45
- # Persistence controls: some slash commands are UI/control actions and should not be written to session history.
46
- persist: bool = True
47
-
48
50
 
49
51
  class CommandABC(ABC):
50
52
  """Abstract base class for slash commands."""
@@ -1,4 +1,4 @@
1
- from klaude_code.protocol import commands, events, message, model
1
+ from klaude_code.protocol import commands, events, message
2
2
  from klaude_code.tui.input.clipboard import copy_to_clipboard
3
3
 
4
4
  from .command_abc import Agent, CommandABC, CommandResult
@@ -20,10 +20,10 @@ class CopyCommand(CommandABC):
20
20
 
21
21
  last = _get_last_assistant_text(agent.session.conversation_history)
22
22
  if not last:
23
- return _developer_message(agent, "(no assistant message to copy)", self.name)
23
+ return _command_output(agent, "(no assistant message to copy)", self.name, is_error=True)
24
24
 
25
25
  copy_to_clipboard(last)
26
- return _developer_message(agent, "Copied last assistant message to clipboard.", self.name)
26
+ return _command_output(agent, "Copied last assistant message to clipboard.", self.name)
27
27
 
28
28
 
29
29
  def _get_last_assistant_text(history: list[message.HistoryEvent]) -> str:
@@ -37,16 +37,16 @@ def _get_last_assistant_text(history: list[message.HistoryEvent]) -> str:
37
37
  return ""
38
38
 
39
39
 
40
- def _developer_message(agent: Agent, content: str, command_name: commands.CommandName) -> CommandResult:
40
+ def _command_output(
41
+ agent: Agent, content: str, command_name: commands.CommandName, *, is_error: bool = False
42
+ ) -> CommandResult:
41
43
  return CommandResult(
42
44
  events=[
43
- events.DeveloperMessageEvent(
45
+ events.CommandOutputEvent(
44
46
  session_id=agent.session.id,
45
- item=message.DeveloperMessage(
46
- parts=message.text_parts_from_str(content),
47
- ui_extra=model.build_command_output_extra(command_name),
48
- ),
47
+ command_name=command_name,
48
+ content=content,
49
+ is_error=is_error,
49
50
  )
50
51
  ],
51
- persist=False,
52
52
  )
@@ -1,5 +1,5 @@
1
1
  from klaude_code.log import DebugType, get_current_log_file, is_debug_enabled, set_debug_logging
2
- from klaude_code.protocol import commands, events, message, model
2
+ from klaude_code.protocol import commands, events, message
3
3
 
4
4
  from .command_abc import Agent, CommandABC, CommandResult
5
5
 
@@ -52,7 +52,7 @@ class DebugCommand(CommandABC):
52
52
  # /debug (no args) - enable debug
53
53
  if not raw:
54
54
  set_debug_logging(True, write_to_file=True)
55
- return self._message_result(agent, _format_status())
55
+ return self._command_output(agent, _format_status())
56
56
 
57
57
  # /debug <filters> - enable with filters
58
58
  try:
@@ -60,21 +60,22 @@ class DebugCommand(CommandABC):
60
60
  if filters:
61
61
  set_debug_logging(True, write_to_file=True, filters=filters)
62
62
  filter_names = ", ".join(sorted(dt.value for dt in filters))
63
- return self._message_result(agent, f"Filters: {filter_names}\n{_format_status()}")
63
+ return self._command_output(agent, f"Filters: {filter_names}\n{_format_status()}")
64
64
  except ValueError:
65
65
  pass
66
66
 
67
- return self._message_result(agent, f"Invalid filter: {raw}\nValid: {', '.join(dt.value for dt in DebugType)}")
67
+ return self._command_output(
68
+ agent, f"Invalid filter: {raw}\nValid: {', '.join(dt.value for dt in DebugType)}", is_error=True
69
+ )
68
70
 
69
- def _message_result(self, agent: "Agent", content: str) -> CommandResult:
71
+ def _command_output(self, agent: "Agent", content: str, *, is_error: bool = False) -> CommandResult:
70
72
  return CommandResult(
71
73
  events=[
72
- events.DeveloperMessageEvent(
74
+ events.CommandOutputEvent(
73
75
  session_id=agent.session.id,
74
- item=message.DeveloperMessage(
75
- parts=message.text_parts_from_str(content),
76
- ui_extra=model.build_command_output_extra(self.name),
77
- ),
76
+ command_name=self.name,
77
+ content=content,
78
+ is_error=is_error,
78
79
  )
79
80
  ]
80
81
  )
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from rich.console import Console
10
10
  from rich.text import Text
11
11
 
12
- from klaude_code.protocol import commands, events, message, model
12
+ from klaude_code.protocol import commands, events, message
13
13
  from klaude_code.session.export import build_export_html
14
14
 
15
15
  from .command_abc import Agent, CommandABC, CommandResult
@@ -39,54 +39,49 @@ class ExportOnlineCommand(CommandABC):
39
39
  # Check if npx or surge is available
40
40
  surge_cmd = self._get_surge_command()
41
41
  if not surge_cmd:
42
- event = events.DeveloperMessageEvent(
42
+ event = events.CommandOutputEvent(
43
43
  session_id=agent.session.id,
44
- item=message.DeveloperMessage(
45
- parts=message.text_parts_from_str("surge.sh CLI not found. Install with: npm install -g surge"),
46
- ui_extra=model.build_command_output_extra(self.name, is_error=True),
47
- ),
44
+ command_name=self.name,
45
+ content="surge.sh CLI not found. Install with: npm install -g surge",
46
+ is_error=True,
48
47
  )
49
48
  return CommandResult(events=[event])
50
49
 
51
50
  try:
52
51
  console = Console()
53
52
  # Check login status inside status context since npx surge whoami can be slow
54
- with console.status(Text("Checking surge.sh login status", style="dim"), spinner_style="dim"):
53
+ with console.status(Text("Checking surge.sh login status...", style="dim"), spinner_style="dim"):
55
54
  logged_in = self._is_surge_logged_in(surge_cmd)
56
55
 
57
56
  if not logged_in:
58
57
  login_cmd = " ".join([*surge_cmd, "login"])
59
- event = events.DeveloperMessageEvent(
58
+ event = events.CommandOutputEvent(
60
59
  session_id=agent.session.id,
61
- item=message.DeveloperMessage(
62
- parts=message.text_parts_from_str(f"Not logged in to surge.sh. Please run: {login_cmd}"),
63
- ui_extra=model.build_command_output_extra(self.name, is_error=True),
64
- ),
60
+ command_name=self.name,
61
+ content=f"Not logged in to surge.sh. Please run: {login_cmd}",
62
+ is_error=True,
65
63
  )
66
64
  return CommandResult(events=[event])
67
65
 
68
- with console.status(Text("Deploying to surge.sh", style="dim"), spinner_style="dim"):
66
+ with console.status(Text("Deploying to surge.sh...", style="dim"), spinner_style="dim"):
69
67
  html_doc = self._build_html(agent)
70
68
  domain = self._generate_domain()
71
69
  url = self._deploy_to_surge(surge_cmd, html_doc, domain)
72
70
 
73
- event = events.DeveloperMessageEvent(
71
+ event = events.CommandOutputEvent(
74
72
  session_id=agent.session.id,
75
- item=message.DeveloperMessage(
76
- parts=message.text_parts_from_str(f"Session deployed to: {url}"),
77
- ui_extra=model.build_command_output_extra(self.name),
78
- ),
73
+ command_name=self.name,
74
+ content=f"Session deployed to: {url}",
79
75
  )
80
76
  return CommandResult(events=[event])
81
77
  except Exception as exc:
82
78
  import traceback
83
79
 
84
- event = events.DeveloperMessageEvent(
80
+ event = events.CommandOutputEvent(
85
81
  session_id=agent.session.id,
86
- item=message.DeveloperMessage(
87
- parts=message.text_parts_from_str(f"Failed to deploy session: {exc}\n{traceback.format_exc()}"),
88
- ui_extra=model.build_command_output_extra(self.name, is_error=True),
89
- ),
82
+ command_name=self.name,
83
+ content=f"Failed to deploy session: {exc}\n{traceback.format_exc()}",
84
+ is_error=True,
90
85
  )
91
86
  return CommandResult(events=[event])
92
87
 
@@ -31,7 +31,7 @@ FORK_SELECT_STYLE = Style(
31
31
  class ForkPoint:
32
32
  """A fork point in conversation history."""
33
33
 
34
- history_index: int | None # None means fork entire conversation
34
+ history_index: int # -1 means fork entire conversation
35
35
  user_message: str
36
36
  tool_call_stats: dict[str, int] # tool_name -> count
37
37
  last_assistant_summary: str
@@ -94,7 +94,7 @@ def _build_fork_points(conversation_history: list[message.HistoryEvent]) -> list
94
94
  if user_indices:
95
95
  fork_points.append(
96
96
  ForkPoint(
97
- history_index=None, # None means fork entire conversation
97
+ history_index=-1, # None means fork entire conversation
98
98
  user_message="", # No specific message, this represents the end
99
99
  tool_call_stats={},
100
100
  last_assistant_summary="",
@@ -104,9 +104,9 @@ def _build_fork_points(conversation_history: list[message.HistoryEvent]) -> list
104
104
  return fork_points
105
105
 
106
106
 
107
- def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int | None]]:
107
+ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
108
108
  """Build SelectItem list from fork points."""
109
- items: list[SelectItem[int | None]] = []
109
+ items: list[SelectItem[int]] = []
110
110
 
111
111
  for i, fp in enumerate(fork_points):
112
112
  is_first = i == 0
@@ -116,8 +116,8 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int | N
116
116
  title_parts: list[tuple[str, str]] = []
117
117
 
118
118
  # First line: separator (with special markers for first/last fork points)
119
- if is_first and not is_last:
120
- title_parts.append(("class:separator", "----- fork from here (empty session) -----\n\n"))
119
+ if is_first:
120
+ pass
121
121
  elif is_last:
122
122
  title_parts.append(("class:separator", "----- fork from here (entire session) -----\n\n"))
123
123
  else:
@@ -150,17 +150,16 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int | N
150
150
  return items
151
151
 
152
152
 
153
- def _select_fork_point_sync(fork_points: list[ForkPoint]) -> int | None | Literal["cancelled"]:
153
+ def _select_fork_point_sync(fork_points: list[ForkPoint]) -> int | Literal["cancelled"]:
154
154
  """Interactive fork point selection (sync version for asyncio.to_thread).
155
155
 
156
156
  Returns:
157
- - int: history index to fork at (exclusive)
158
- - None: fork entire conversation
157
+ - int: history index to fork at (exclusive), -1 means fork entire conversation
159
158
  - "cancelled": user cancelled selection
160
159
  """
161
160
  items = _build_select_items(fork_points)
162
161
  if not items:
163
- return None
162
+ return -1
164
163
 
165
164
  # Default to the last option (fork entire conversation)
166
165
  last_value = items[-1].value
@@ -204,14 +203,13 @@ class ForkSessionCommand(CommandABC):
204
203
  del user_input # unused
205
204
 
206
205
  if agent.session.messages_count == 0:
207
- event = events.DeveloperMessageEvent(
206
+ event = events.CommandOutputEvent(
208
207
  session_id=agent.session.id,
209
- item=message.DeveloperMessage(
210
- parts=message.text_parts_from_str("(no messages to fork)"),
211
- ui_extra=model.build_command_output_extra(self.name),
212
- ),
208
+ command_name=self.name,
209
+ content="(no messages to fork)",
210
+ is_error=True,
213
211
  )
214
- return CommandResult(events=[event], persist=False)
212
+ return CommandResult(events=[event])
215
213
 
216
214
  # Build fork points from conversation history
217
215
  fork_points = _build_fork_points(agent.session.conversation_history)
@@ -224,51 +222,49 @@ class ForkSessionCommand(CommandABC):
224
222
  resume_cmd = f"klaude --resume-by-id {new_session.id}"
225
223
  copy_to_clipboard(resume_cmd)
226
224
 
227
- event = events.DeveloperMessageEvent(
225
+ event = events.CommandOutputEvent(
228
226
  session_id=agent.session.id,
229
- item=message.DeveloperMessage(
230
- parts=message.text_parts_from_str(f"Session forked successfully. New session id: {new_session.id}"),
231
- ui_extra=model.build_command_output_extra(
232
- self.name,
233
- ui_extra=model.SessionIdUIExtra(session_id=new_session.id),
234
- ),
235
- ),
227
+ command_name=self.name,
228
+ content=f"Session forked successfully. New session id: {new_session.id}",
229
+ ui_extra=model.SessionIdUIExtra(session_id=new_session.id),
236
230
  )
237
- return CommandResult(events=[event], persist=False)
231
+ return CommandResult(events=[event])
238
232
 
239
233
  # Interactive selection
240
234
  selected = await asyncio.to_thread(_select_fork_point_sync, fork_points)
241
235
 
242
236
  if selected == "cancelled":
243
- event = events.DeveloperMessageEvent(
237
+ event = events.CommandOutputEvent(
244
238
  session_id=agent.session.id,
245
- item=message.DeveloperMessage(
246
- parts=message.text_parts_from_str("(fork cancelled)"),
247
- ui_extra=model.build_command_output_extra(self.name),
248
- ),
239
+ command_name=self.name,
240
+ content="(fork cancelled)",
249
241
  )
250
- return CommandResult(events=[event], persist=False)
242
+ return CommandResult(events=[event])
243
+
244
+ # First option (empty session) is just for UI display, not a valid fork point
245
+ if selected == fork_points[0].history_index:
246
+ event = events.CommandOutputEvent(
247
+ session_id=agent.session.id,
248
+ command_name=self.name,
249
+ content="(cannot fork to empty session)",
250
+ is_error=True,
251
+ )
252
+ return CommandResult(events=[event])
251
253
 
252
254
  # Perform the fork
253
255
  new_session = agent.session.fork(until_index=selected)
254
256
  await new_session.wait_for_flush()
255
257
 
256
258
  # Build result message
257
- fork_description = "entire conversation" if selected is None else f"up to message index {selected}"
259
+ fork_description = "entire conversation" if selected == -1 else f"up to message index {selected}"
258
260
 
259
261
  resume_cmd = f"klaude --resume-by-id {new_session.id}"
260
262
  copy_to_clipboard(resume_cmd)
261
263
 
262
- event = events.DeveloperMessageEvent(
264
+ event = events.CommandOutputEvent(
263
265
  session_id=agent.session.id,
264
- item=message.DeveloperMessage(
265
- parts=message.text_parts_from_str(
266
- f"Session forked ({fork_description}). New session id: {new_session.id}"
267
- ),
268
- ui_extra=model.build_command_output_extra(
269
- self.name,
270
- ui_extra=model.SessionIdUIExtra(session_id=new_session.id),
271
- ),
272
- ),
266
+ command_name=self.name,
267
+ content=f"Session forked ({fork_description}). New session id: {new_session.id}",
268
+ ui_extra=model.SessionIdUIExtra(session_id=new_session.id),
273
269
  )
274
- return CommandResult(events=[event], persist=False)
270
+ return CommandResult(events=[event])
@@ -1,46 +1,9 @@
1
1
  import asyncio
2
2
 
3
- from prompt_toolkit.styles import Style
4
-
5
- from klaude_code.protocol import commands, events, message, model, op
6
- from klaude_code.tui.terminal.selector import SelectItem, select_one
3
+ from klaude_code.protocol import commands, events, message, op
7
4
 
8
5
  from .command_abc import Agent, CommandABC, CommandResult
9
- from .model_select import select_model_interactive
10
-
11
- SELECT_STYLE = Style(
12
- [
13
- ("instruction", "ansibrightblack"),
14
- ("pointer", "ansigreen"),
15
- ("highlighted", "ansigreen"),
16
- ("text", "ansibrightblack"),
17
- ("question", "bold"),
18
- ]
19
- )
20
-
21
-
22
- def _confirm_change_default_model_sync(selected_model: str) -> bool:
23
- items: list[SelectItem[bool]] = [
24
- SelectItem(title=[("class:text", "No (session only)\n")], value=False, search_text="No"),
25
- SelectItem(
26
- title=[("class:text", "Yes (save as default main_model in ~/.klaude/klaude-config.yaml)\n")],
27
- value=True,
28
- search_text="Yes",
29
- ),
30
- ]
31
-
32
- try:
33
- result = select_one(
34
- message=f"Save '{selected_model}' as default model?",
35
- items=items,
36
- pointer="→",
37
- style=SELECT_STYLE,
38
- use_search_filter=False,
39
- )
40
- except KeyboardInterrupt:
41
- return False
42
-
43
- return bool(result)
6
+ from .model_picker import ModelSelectStatus, select_model_interactive
44
7
 
45
8
 
46
9
  class ModelCommand(CommandABC):
@@ -52,7 +15,7 @@ class ModelCommand(CommandABC):
52
15
 
53
16
  @property
54
17
  def summary(self) -> str:
55
- return "Select and switch LLM"
18
+ return "Change model (saves to config)"
56
19
 
57
20
  @property
58
21
  def is_interactive(self) -> bool:
@@ -67,28 +30,26 @@ class ModelCommand(CommandABC):
67
30
  return "model name"
68
31
 
69
32
  async def run(self, agent: Agent, user_input: message.UserInputPayload) -> CommandResult:
70
- selected_model = await asyncio.to_thread(select_model_interactive, preferred=user_input.text)
33
+ model_result = await asyncio.to_thread(select_model_interactive, preferred=user_input.text)
71
34
 
72
- current_model = agent.profile.llm_client.model_name if agent.profile else None
35
+ current_model = agent.session.model_config_name
36
+ selected_model = model_result.model if model_result.status == ModelSelectStatus.SELECTED else None
73
37
  if selected_model is None or selected_model == current_model:
74
38
  return CommandResult(
75
39
  events=[
76
- events.DeveloperMessageEvent(
40
+ events.CommandOutputEvent(
77
41
  session_id=agent.session.id,
78
- item=message.DeveloperMessage(
79
- parts=message.text_parts_from_str("(no change)"),
80
- ui_extra=model.build_command_output_extra(self.name),
81
- ),
42
+ command_name=self.name,
43
+ content="(no change)",
82
44
  )
83
45
  ]
84
46
  )
85
- save_as_default = await asyncio.to_thread(_confirm_change_default_model_sync, selected_model)
86
47
  return CommandResult(
87
48
  operations=[
88
49
  op.ChangeModelOperation(
89
50
  session_id=agent.session.id,
90
51
  model_name=selected_model,
91
- save_as_default=save_as_default,
52
+ save_as_default=True,
92
53
  )
93
54
  ]
94
55
  )
@@ -0,0 +1,142 @@
1
+ """Interactive model selection for CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+
9
+ from klaude_code.config.config import load_config
10
+ from klaude_code.config.model_matcher import match_model_from_config
11
+ from klaude_code.log import log
12
+
13
+
14
+ class ModelSelectStatus(Enum):
15
+ SELECTED = "selected"
16
+ CANCELLED = "cancelled"
17
+ NO_MATCH = "no_match"
18
+ NO_MODELS = "no_models"
19
+ NON_TTY = "non_tty"
20
+ ERROR = "error"
21
+
22
+
23
+ @dataclass
24
+ class ModelSelectResult:
25
+ status: ModelSelectStatus
26
+ model: str | None = None
27
+
28
+
29
+ def select_model_interactive(
30
+ preferred: str | None = None,
31
+ keywords: list[str] | None = None,
32
+ ) -> ModelSelectResult:
33
+ """Interactive single-choice model selector.
34
+
35
+ This function combines matching logic with interactive UI selection.
36
+ For CLI usage.
37
+
38
+ If keywords is provided, preferred is ignored and the model list is pre-filtered by model_id.
39
+
40
+ If preferred is provided:
41
+ - Exact match: return immediately
42
+ - Single partial match (case-insensitive): return immediately
43
+ - Otherwise: fall through to interactive selection
44
+ """
45
+ config = load_config()
46
+ result = match_model_from_config(None if keywords else preferred)
47
+
48
+ if result.error_message:
49
+ return ModelSelectResult(status=ModelSelectStatus.NO_MODELS)
50
+
51
+ if result.matched_model:
52
+ return ModelSelectResult(status=ModelSelectStatus.SELECTED, model=result.matched_model)
53
+
54
+ if keywords:
55
+ keywords_lower = [k.lower() for k in keywords]
56
+ filtered_models = [
57
+ m for m in result.filtered_models if any(kw in (m.model_id or "").lower() for kw in keywords_lower)
58
+ ]
59
+ if not filtered_models:
60
+ return ModelSelectResult(status=ModelSelectStatus.NO_MATCH)
61
+ result.filtered_models = filtered_models
62
+ result.filter_hint = ", ".join(keywords)
63
+ result.matched_model = None
64
+
65
+ # Non-interactive environments (CI/pipes) should never enter an interactive prompt.
66
+ # If we couldn't resolve to a single model deterministically above, fail with a clear hint.
67
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
68
+ log(("Error: cannot use interactive model selection without a TTY", "red"))
69
+ log(("Hint: pass --model <config-name> or set main_model in ~/.klaude/klaude-config.yaml", "yellow"))
70
+ if preferred and not keywords:
71
+ log((f"Hint: '{preferred}' did not resolve to a single configured model", "yellow"))
72
+ return ModelSelectResult(status=ModelSelectStatus.NON_TTY)
73
+
74
+ # Interactive selection
75
+ from prompt_toolkit.styles import Style
76
+
77
+ from klaude_code.tui.terminal.selector import build_model_select_items, select_one
78
+
79
+ names = [m.selector for m in result.filtered_models]
80
+
81
+ try:
82
+ items = build_model_select_items(result.filtered_models)
83
+
84
+ message = f"Select a model (filtered by '{result.filter_hint}'):" if result.filter_hint else "Select a model:"
85
+
86
+ initial_value = config.main_model
87
+ if isinstance(initial_value, str) and initial_value and "@" not in initial_value:
88
+ try:
89
+ resolved = config.resolve_model_location_prefer_available(
90
+ initial_value
91
+ ) or config.resolve_model_location(initial_value)
92
+ except ValueError:
93
+ resolved = None
94
+ if resolved is not None:
95
+ initial_value = f"{resolved[0]}@{resolved[1]}"
96
+
97
+ selected = select_one(
98
+ message=message,
99
+ items=items,
100
+ pointer="→",
101
+ use_search_filter=True,
102
+ initial_value=initial_value,
103
+ style=Style(
104
+ [
105
+ ("pointer", "ansigreen"),
106
+ ("highlighted", "ansigreen"),
107
+ ("msg", ""),
108
+ ("meta", "fg:ansibrightblack"),
109
+ ("text", "ansibrightblack"),
110
+ ("question", "bold"),
111
+ ("search_prefix", "ansibrightblack"),
112
+ # search filter colors at the bottom
113
+ ("search_success", "noinherit fg:ansigreen"),
114
+ ("search_none", "noinherit fg:ansired"),
115
+ ]
116
+ ),
117
+ )
118
+ if isinstance(selected, str) and selected in names:
119
+ return ModelSelectResult(status=ModelSelectStatus.SELECTED, model=selected)
120
+ except KeyboardInterrupt:
121
+ return ModelSelectResult(status=ModelSelectStatus.CANCELLED)
122
+ except Exception as e:
123
+ log((f"Failed to use prompt_toolkit for model selection: {e}", "yellow"))
124
+ # Never return an unvalidated model name here.
125
+ # If we can't interactively select, fall back to a known configured model.
126
+ if result.matched_model and result.matched_model in names:
127
+ return ModelSelectResult(status=ModelSelectStatus.SELECTED, model=result.matched_model)
128
+ if config.main_model and config.main_model in names:
129
+ return ModelSelectResult(status=ModelSelectStatus.SELECTED, model=config.main_model)
130
+ if config.main_model and "@" not in config.main_model:
131
+ try:
132
+ resolved = config.resolve_model_location_prefer_available(
133
+ config.main_model
134
+ ) or config.resolve_model_location(config.main_model)
135
+ except ValueError:
136
+ resolved = None
137
+ if resolved is not None:
138
+ selector = f"{resolved[0]}@{resolved[1]}"
139
+ if selector in names:
140
+ return ModelSelectResult(status=ModelSelectStatus.SELECTED, model=selector)
141
+
142
+ return ModelSelectResult(status=ModelSelectStatus.ERROR)
@@ -38,5 +38,4 @@ class RefreshTerminalCommand(CommandABC):
38
38
  is_load=False,
39
39
  ),
40
40
  ],
41
- persist=False,
42
41
  )