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.
- klaude_code/app/runtime.py +2 -15
- klaude_code/cli/list_model.py +30 -13
- klaude_code/cli/main.py +26 -10
- klaude_code/config/assets/builtin_config.yaml +177 -310
- klaude_code/config/config.py +158 -21
- klaude_code/config/{select_model.py → model_matcher.py} +41 -16
- klaude_code/config/sub_agent_model_helper.py +217 -0
- klaude_code/config/thinking.py +2 -2
- klaude_code/const.py +1 -1
- klaude_code/core/agent_profile.py +43 -5
- klaude_code/core/executor.py +129 -47
- klaude_code/core/manager/llm_clients_builder.py +17 -11
- klaude_code/core/prompts/prompt-nano-banana.md +1 -1
- klaude_code/core/tool/file/diff_builder.py +25 -18
- klaude_code/core/tool/sub_agent_tool.py +2 -1
- klaude_code/llm/anthropic/client.py +12 -9
- klaude_code/llm/anthropic/input.py +54 -29
- klaude_code/llm/client.py +1 -1
- klaude_code/llm/codex/client.py +2 -2
- klaude_code/llm/google/client.py +7 -7
- klaude_code/llm/google/input.py +23 -2
- klaude_code/llm/input_common.py +2 -2
- klaude_code/llm/openai_compatible/client.py +3 -3
- klaude_code/llm/openai_compatible/input.py +22 -13
- klaude_code/llm/openai_compatible/stream.py +1 -1
- klaude_code/llm/openrouter/client.py +4 -4
- klaude_code/llm/openrouter/input.py +35 -25
- klaude_code/llm/responses/client.py +5 -5
- klaude_code/llm/responses/input.py +96 -57
- klaude_code/protocol/commands.py +1 -2
- klaude_code/protocol/events/__init__.py +7 -1
- klaude_code/protocol/events/chat.py +10 -0
- klaude_code/protocol/events/system.py +4 -0
- klaude_code/protocol/llm_param.py +1 -1
- klaude_code/protocol/model.py +0 -26
- klaude_code/protocol/op.py +17 -5
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/protocol/sub_agent/AGENTS.md +28 -0
- klaude_code/protocol/sub_agent/__init__.py +10 -14
- klaude_code/protocol/sub_agent/image_gen.py +2 -1
- klaude_code/session/codec.py +2 -6
- klaude_code/session/session.py +13 -3
- klaude_code/skill/assets/create-plan/SKILL.md +3 -5
- klaude_code/tui/command/__init__.py +3 -6
- klaude_code/tui/command/clear_cmd.py +0 -1
- klaude_code/tui/command/command_abc.py +6 -4
- klaude_code/tui/command/copy_cmd.py +10 -10
- klaude_code/tui/command/debug_cmd.py +11 -10
- klaude_code/tui/command/export_online_cmd.py +18 -23
- klaude_code/tui/command/fork_session_cmd.py +39 -43
- klaude_code/tui/command/model_cmd.py +10 -49
- klaude_code/tui/command/model_picker.py +142 -0
- klaude_code/tui/command/refresh_cmd.py +0 -1
- klaude_code/tui/command/registry.py +15 -21
- klaude_code/tui/command/resume_cmd.py +10 -16
- klaude_code/tui/command/status_cmd.py +8 -12
- klaude_code/tui/command/sub_agent_model_cmd.py +185 -0
- klaude_code/tui/command/terminal_setup_cmd.py +8 -11
- klaude_code/tui/command/thinking_cmd.py +4 -6
- klaude_code/tui/commands.py +5 -0
- klaude_code/tui/components/bash_syntax.py +1 -1
- klaude_code/tui/components/command_output.py +96 -0
- klaude_code/tui/components/common.py +1 -1
- klaude_code/tui/components/developer.py +3 -115
- klaude_code/tui/components/metadata.py +1 -63
- klaude_code/tui/components/rich/cjk_wrap.py +3 -2
- klaude_code/tui/components/rich/status.py +49 -3
- klaude_code/tui/components/rich/theme.py +2 -0
- klaude_code/tui/components/sub_agent.py +25 -46
- klaude_code/tui/components/welcome.py +99 -0
- klaude_code/tui/input/prompt_toolkit.py +19 -8
- klaude_code/tui/machine.py +5 -0
- klaude_code/tui/renderer.py +7 -8
- klaude_code/tui/runner.py +0 -6
- klaude_code/tui/terminal/selector.py +8 -6
- {klaude_code-2.2.0.dist-info → klaude_code-2.4.0.dist-info}/METADATA +21 -74
- {klaude_code-2.2.0.dist-info → klaude_code-2.4.0.dist-info}/RECORD +79 -76
- klaude_code/tui/command/help_cmd.py +0 -51
- klaude_code/tui/command/model_select.py +0 -84
- klaude_code/tui/command/release_notes_cmd.py +0 -85
- {klaude_code-2.2.0.dist-info → klaude_code-2.4.0.dist-info}/WHEEL +0 -0
- {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
|
}
|
|
@@ -37,14 +37,16 @@ class CommandResult(BaseModel):
|
|
|
37
37
|
"""Result of a command execution."""
|
|
38
38
|
|
|
39
39
|
events: (
|
|
40
|
-
list[
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
45
|
+
events.CommandOutputEvent(
|
|
44
46
|
session_id=agent.session.id,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
71
|
+
def _command_output(self, agent: "Agent", content: str, *, is_error: bool = False) -> CommandResult:
|
|
70
72
|
return CommandResult(
|
|
71
73
|
events=[
|
|
72
|
-
events.
|
|
74
|
+
events.CommandOutputEvent(
|
|
73
75
|
session_id=agent.session.id,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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.
|
|
42
|
+
event = events.CommandOutputEvent(
|
|
43
43
|
session_id=agent.session.id,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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.
|
|
58
|
+
event = events.CommandOutputEvent(
|
|
60
59
|
session_id=agent.session.id,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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.
|
|
71
|
+
event = events.CommandOutputEvent(
|
|
74
72
|
session_id=agent.session.id,
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
80
|
+
event = events.CommandOutputEvent(
|
|
85
81
|
session_id=agent.session.id,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
120
|
-
|
|
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 |
|
|
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
|
|
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.
|
|
206
|
+
event = events.CommandOutputEvent(
|
|
208
207
|
session_id=agent.session.id,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
),
|
|
208
|
+
command_name=self.name,
|
|
209
|
+
content="(no messages to fork)",
|
|
210
|
+
is_error=True,
|
|
213
211
|
)
|
|
214
|
-
return CommandResult(events=[event]
|
|
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.
|
|
225
|
+
event = events.CommandOutputEvent(
|
|
228
226
|
session_id=agent.session.id,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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]
|
|
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.
|
|
237
|
+
event = events.CommandOutputEvent(
|
|
244
238
|
session_id=agent.session.id,
|
|
245
|
-
|
|
246
|
-
|
|
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]
|
|
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
|
|
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.
|
|
264
|
+
event = events.CommandOutputEvent(
|
|
263
265
|
session_id=agent.session.id,
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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]
|
|
270
|
+
return CommandResult(events=[event])
|
|
@@ -1,46 +1,9 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
|
|
3
|
-
from
|
|
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 .
|
|
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 "
|
|
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
|
-
|
|
33
|
+
model_result = await asyncio.to_thread(select_model_interactive, preferred=user_input.text)
|
|
71
34
|
|
|
72
|
-
current_model = agent.
|
|
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.
|
|
40
|
+
events.CommandOutputEvent(
|
|
77
41
|
session_id=agent.session.id,
|
|
78
|
-
|
|
79
|
-
|
|
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=
|
|
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)
|