klaude-code 1.4.3__py3-none-any.whl → 1.5.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/cli/main.py +75 -11
- klaude_code/cli/runtime.py +171 -34
- klaude_code/command/__init__.py +4 -0
- klaude_code/command/help_cmd.py +2 -1
- klaude_code/command/model_cmd.py +3 -5
- klaude_code/command/model_select.py +84 -0
- klaude_code/command/registry.py +23 -0
- klaude_code/command/resume_cmd.py +52 -2
- klaude_code/command/thinking_cmd.py +30 -199
- klaude_code/config/select_model.py +47 -97
- klaude_code/config/thinking.py +255 -0
- klaude_code/core/executor.py +53 -63
- klaude_code/protocol/commands.py +11 -0
- klaude_code/protocol/op.py +15 -0
- klaude_code/session/__init__.py +2 -2
- klaude_code/session/selector.py +33 -61
- klaude_code/ui/modes/repl/completers.py +27 -15
- klaude_code/ui/modes/repl/event_handler.py +2 -1
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +393 -57
- klaude_code/ui/modes/repl/key_bindings.py +30 -10
- klaude_code/ui/renderers/metadata.py +3 -6
- klaude_code/ui/renderers/user_input.py +18 -1
- klaude_code/ui/rich/theme.py +2 -2
- klaude_code/ui/terminal/notifier.py +42 -0
- klaude_code/ui/terminal/selector.py +419 -136
- {klaude_code-1.4.3.dist-info → klaude_code-1.5.0.dist-info}/METADATA +1 -1
- {klaude_code-1.4.3.dist-info → klaude_code-1.5.0.dist-info}/RECORD +29 -27
- {klaude_code-1.4.3.dist-info → klaude_code-1.5.0.dist-info}/WHEEL +0 -0
- {klaude_code-1.4.3.dist-info → klaude_code-1.5.0.dist-info}/entry_points.txt +0 -0
klaude_code/cli/main.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import contextlib
|
|
2
3
|
import os
|
|
3
4
|
import sys
|
|
4
5
|
from pathlib import Path
|
|
@@ -10,18 +11,81 @@ from klaude_code.cli.config_cmd import register_config_commands
|
|
|
10
11
|
from klaude_code.cli.debug import DEBUG_FILTER_HELP, open_log_file_in_editor, resolve_debug_settings
|
|
11
12
|
from klaude_code.cli.self_update import register_self_update_commands, version_option_callback
|
|
12
13
|
from klaude_code.cli.session_cmd import register_session_commands
|
|
13
|
-
from klaude_code.session import Session,
|
|
14
|
+
from klaude_code.session import Session, build_session_select_options
|
|
14
15
|
from klaude_code.trace import DebugType, prepare_debug_log_file
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
def select_session_interactive() -> str | None:
|
|
19
|
+
"""Interactive session selection for CLI.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Selected session ID, or None if no session selected or no sessions exist.
|
|
23
|
+
"""
|
|
24
|
+
from klaude_code.trace import log
|
|
25
|
+
|
|
26
|
+
options = build_session_select_options()
|
|
27
|
+
if not options:
|
|
28
|
+
log("No sessions found for this project.")
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
from prompt_toolkit.styles import Style
|
|
32
|
+
|
|
33
|
+
from klaude_code.ui.terminal.selector import SelectItem, select_one
|
|
34
|
+
|
|
35
|
+
items: list[SelectItem[str]] = []
|
|
36
|
+
for opt in options:
|
|
37
|
+
title = [
|
|
38
|
+
("class:msg", f"{opt.first_user_message}\n"),
|
|
39
|
+
("class:meta", f" {opt.messages_count} · {opt.relative_time} · {opt.model_name} · {opt.session_id}\n\n"),
|
|
40
|
+
]
|
|
41
|
+
items.append(
|
|
42
|
+
SelectItem(
|
|
43
|
+
title=title,
|
|
44
|
+
value=opt.session_id,
|
|
45
|
+
search_text=f"{opt.first_user_message} {opt.model_name} {opt.session_id}",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
return select_one(
|
|
51
|
+
message="Select a session to resume:",
|
|
52
|
+
items=items,
|
|
53
|
+
pointer="→",
|
|
54
|
+
style=Style(
|
|
55
|
+
[
|
|
56
|
+
("msg", ""),
|
|
57
|
+
("meta", "fg:ansibrightblack"),
|
|
58
|
+
("pointer", "bold fg:ansigreen"),
|
|
59
|
+
("highlighted", "fg:ansigreen"),
|
|
60
|
+
("search_prefix", "fg:ansibrightblack"),
|
|
61
|
+
("search_success", "noinherit fg:ansigreen"),
|
|
62
|
+
("search_none", "noinherit fg:ansired"),
|
|
63
|
+
("question", "bold"),
|
|
64
|
+
("text", ""),
|
|
65
|
+
]
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
17
72
|
def set_terminal_title(title: str) -> None:
|
|
18
73
|
"""Set terminal window title using ANSI escape sequence."""
|
|
19
74
|
# Never write terminal control sequences when stdout is not a TTY (pipes/CI/redirects).
|
|
20
75
|
# This avoids corrupting machine-readable output (e.g., JSON streaming) and log captures.
|
|
21
|
-
|
|
76
|
+
#
|
|
77
|
+
# Use the original stdout to bypass prompt_toolkit's `patch_stdout()`. Writing OSC
|
|
78
|
+
# sequences to the patched stdout can cause them to appear as visible text.
|
|
79
|
+
stream = getattr(sys, "__stdout__", None) or sys.stdout
|
|
80
|
+
try:
|
|
81
|
+
if not stream.isatty():
|
|
82
|
+
return
|
|
83
|
+
except Exception:
|
|
22
84
|
return
|
|
23
|
-
|
|
24
|
-
|
|
85
|
+
|
|
86
|
+
stream.write(f"\033]0;{title}\007")
|
|
87
|
+
with contextlib.suppress(Exception):
|
|
88
|
+
stream.flush()
|
|
25
89
|
|
|
26
90
|
|
|
27
91
|
def update_terminal_title(model_name: str | None = None) -> None:
|
|
@@ -147,19 +211,19 @@ def exec_command(
|
|
|
147
211
|
raise typer.Exit(1)
|
|
148
212
|
|
|
149
213
|
from klaude_code.cli.runtime import AppInitConfig, run_exec
|
|
214
|
+
from klaude_code.command.model_select import select_model_interactive
|
|
150
215
|
from klaude_code.config import load_config
|
|
151
|
-
from klaude_code.config.select_model import select_model_from_config
|
|
152
216
|
|
|
153
217
|
chosen_model = model
|
|
154
218
|
if model or select_model:
|
|
155
|
-
chosen_model =
|
|
219
|
+
chosen_model = select_model_interactive(preferred=model)
|
|
156
220
|
if chosen_model is None:
|
|
157
221
|
raise typer.Exit(1)
|
|
158
222
|
else:
|
|
159
223
|
# Check if main_model is configured; if not, trigger interactive selection
|
|
160
224
|
config = load_config()
|
|
161
225
|
if config.main_model is None:
|
|
162
|
-
chosen_model =
|
|
226
|
+
chosen_model = select_model_interactive()
|
|
163
227
|
if chosen_model is None:
|
|
164
228
|
raise typer.Exit(1)
|
|
165
229
|
# Save the selection as default
|
|
@@ -282,13 +346,13 @@ def main_callback(
|
|
|
282
346
|
return
|
|
283
347
|
|
|
284
348
|
from klaude_code.cli.runtime import AppInitConfig, run_interactive
|
|
285
|
-
from klaude_code.
|
|
349
|
+
from klaude_code.command.model_select import select_model_interactive
|
|
286
350
|
|
|
287
351
|
update_terminal_title()
|
|
288
352
|
|
|
289
353
|
chosen_model = model
|
|
290
354
|
if model or select_model:
|
|
291
|
-
chosen_model =
|
|
355
|
+
chosen_model = select_model_interactive(preferred=model)
|
|
292
356
|
if chosen_model is None:
|
|
293
357
|
return
|
|
294
358
|
|
|
@@ -297,7 +361,7 @@ def main_callback(
|
|
|
297
361
|
session_id: str | None = None
|
|
298
362
|
|
|
299
363
|
if resume:
|
|
300
|
-
session_id =
|
|
364
|
+
session_id = select_session_interactive()
|
|
301
365
|
if session_id is None:
|
|
302
366
|
return
|
|
303
367
|
# If user didn't pick, allow fallback to --continue
|
|
@@ -343,7 +407,7 @@ def main_callback(
|
|
|
343
407
|
|
|
344
408
|
cfg = load_config()
|
|
345
409
|
if cfg.main_model is None:
|
|
346
|
-
chosen_model =
|
|
410
|
+
chosen_model = select_model_interactive()
|
|
347
411
|
if chosen_model is None:
|
|
348
412
|
raise typer.Exit(1)
|
|
349
413
|
# Save the selection as default
|
klaude_code/cli/runtime.py
CHANGED
|
@@ -3,6 +3,7 @@ import contextlib
|
|
|
3
3
|
import sys
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from typing import Any, Protocol
|
|
6
|
+
from uuid import uuid4
|
|
6
7
|
|
|
7
8
|
import typer
|
|
8
9
|
from rich.text import Text
|
|
@@ -10,12 +11,13 @@ from rich.text import Text
|
|
|
10
11
|
from klaude_code import ui
|
|
11
12
|
from klaude_code.cli.main import update_terminal_title
|
|
12
13
|
from klaude_code.cli.self_update import get_update_message
|
|
13
|
-
from klaude_code.command import has_interactive_command
|
|
14
|
+
from klaude_code.command import dispatch_command, get_command_info_list, has_interactive_command, is_slash_command_name
|
|
14
15
|
from klaude_code.config import Config, load_config
|
|
15
16
|
from klaude_code.core.agent import Agent, DefaultModelProfileProvider, VanillaModelProfileProvider
|
|
16
17
|
from klaude_code.core.executor import Executor
|
|
17
18
|
from klaude_code.core.manager import build_llm_clients
|
|
18
|
-
from klaude_code.protocol import events, op
|
|
19
|
+
from klaude_code.protocol import events, llm_param, op
|
|
20
|
+
from klaude_code.protocol import model as protocol_model
|
|
19
21
|
from klaude_code.protocol.model import UserInputPayload
|
|
20
22
|
from klaude_code.session.session import Session, close_default_store
|
|
21
23
|
from klaude_code.trace import DebugType, log, set_debug_logging
|
|
@@ -57,6 +59,79 @@ class AppComponents:
|
|
|
57
59
|
theme: str | None
|
|
58
60
|
|
|
59
61
|
|
|
62
|
+
async def submit_user_input_payload(
|
|
63
|
+
*,
|
|
64
|
+
executor: Executor,
|
|
65
|
+
event_queue: asyncio.Queue[events.Event],
|
|
66
|
+
user_input: UserInputPayload,
|
|
67
|
+
session_id: str | None,
|
|
68
|
+
) -> str | None:
|
|
69
|
+
"""Parse/dispatch a user input payload and submit resulting operations.
|
|
70
|
+
|
|
71
|
+
The UI/CLI layer owns slash command parsing and any interactive prompts.
|
|
72
|
+
Core only executes concrete operations.
|
|
73
|
+
|
|
74
|
+
Returns a submission id that should be awaited, or None if there is nothing
|
|
75
|
+
to wait for (e.g. commands that only emit events).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
sid = session_id or executor.context.current_session_id()
|
|
79
|
+
if sid is None:
|
|
80
|
+
raise RuntimeError("No active session")
|
|
81
|
+
|
|
82
|
+
agent = executor.context.current_agent
|
|
83
|
+
if agent is None or agent.session.id != sid:
|
|
84
|
+
await executor.submit_and_wait(op.InitAgentOperation(session_id=sid))
|
|
85
|
+
agent = executor.context.current_agent
|
|
86
|
+
|
|
87
|
+
if agent is None:
|
|
88
|
+
raise RuntimeError("Failed to initialize agent")
|
|
89
|
+
|
|
90
|
+
submission_id = uuid4().hex
|
|
91
|
+
|
|
92
|
+
await executor.context.emit_event(
|
|
93
|
+
events.UserMessageEvent(content=user_input.text, session_id=sid, images=user_input.images)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
result = await dispatch_command(user_input, agent, submission_id=submission_id)
|
|
97
|
+
operations: list[op.Operation] = list(result.operations or [])
|
|
98
|
+
|
|
99
|
+
run_ops = [candidate for candidate in operations if isinstance(candidate, op.RunAgentOperation)]
|
|
100
|
+
if len(run_ops) > 1:
|
|
101
|
+
raise ValueError("Multiple RunAgentOperation results are not supported")
|
|
102
|
+
|
|
103
|
+
persisted_user_input = run_ops[0].input if run_ops else user_input
|
|
104
|
+
|
|
105
|
+
if result.persist_user_input:
|
|
106
|
+
agent.session.append_history(
|
|
107
|
+
[
|
|
108
|
+
protocol_model.UserMessageItem(
|
|
109
|
+
content=persisted_user_input.text,
|
|
110
|
+
images=persisted_user_input.images,
|
|
111
|
+
)
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if result.events:
|
|
116
|
+
for evt in result.events:
|
|
117
|
+
if result.persist_events and isinstance(evt, events.DeveloperMessageEvent):
|
|
118
|
+
agent.session.append_history([evt.item])
|
|
119
|
+
await executor.context.emit_event(evt)
|
|
120
|
+
|
|
121
|
+
submitted_ids: list[str] = []
|
|
122
|
+
for operation_item in operations:
|
|
123
|
+
submitted_ids.append(await executor.submit(operation_item))
|
|
124
|
+
|
|
125
|
+
if not submitted_ids:
|
|
126
|
+
# Ensure event-only commands are fully rendered before showing the next prompt.
|
|
127
|
+
await event_queue.join()
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
if run_ops:
|
|
131
|
+
return run_ops[0].id
|
|
132
|
+
return submitted_ids[-1]
|
|
133
|
+
|
|
134
|
+
|
|
60
135
|
async def initialize_app_components(init_config: AppInitConfig) -> AppComponents:
|
|
61
136
|
"""Initialize all application components (LLM clients, executor, UI)."""
|
|
62
137
|
set_debug_logging(init_config.debug, filters=init_config.debug_filters)
|
|
@@ -232,10 +307,15 @@ async def run_exec(init_config: AppInitConfig, input_content: str) -> None:
|
|
|
232
307
|
is_new_session=True,
|
|
233
308
|
)
|
|
234
309
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
310
|
+
wait_id = await submit_user_input_payload(
|
|
311
|
+
executor=components.executor,
|
|
312
|
+
event_queue=components.event_queue,
|
|
313
|
+
user_input=UserInputPayload(text=input_content),
|
|
314
|
+
session_id=session_id,
|
|
238
315
|
)
|
|
316
|
+
if wait_id is not None:
|
|
317
|
+
await components.executor.wait_for(wait_id)
|
|
318
|
+
await components.event_queue.join()
|
|
239
319
|
|
|
240
320
|
except KeyboardInterrupt:
|
|
241
321
|
await _handle_keyboard_interrupt(components.executor)
|
|
@@ -281,10 +361,66 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
|
|
|
281
361
|
elif components.theme == "dark":
|
|
282
362
|
is_light_background = False
|
|
283
363
|
|
|
364
|
+
def _get_active_session_id() -> str | None:
|
|
365
|
+
"""Get the current active session ID dynamically.
|
|
366
|
+
|
|
367
|
+
This is necessary because /clear command creates a new session with a different ID.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
return components.executor.context.current_session_id()
|
|
371
|
+
|
|
372
|
+
async def _change_model_from_prompt(model_name: str) -> None:
|
|
373
|
+
sid = _get_active_session_id()
|
|
374
|
+
if not sid:
|
|
375
|
+
return
|
|
376
|
+
await components.executor.submit_and_wait(
|
|
377
|
+
op.ChangeModelOperation(
|
|
378
|
+
session_id=sid,
|
|
379
|
+
model_name=model_name,
|
|
380
|
+
save_as_default=False,
|
|
381
|
+
defer_thinking_selection=True,
|
|
382
|
+
emit_welcome_event=False,
|
|
383
|
+
emit_switch_message=False,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _get_current_llm_config() -> llm_param.LLMConfigParameter | None:
|
|
388
|
+
agent = components.executor.context.current_agent
|
|
389
|
+
if agent is None:
|
|
390
|
+
return None
|
|
391
|
+
return agent.profile.llm_client.get_llm_config()
|
|
392
|
+
|
|
393
|
+
async def _change_thinking_from_prompt(thinking: llm_param.Thinking) -> None:
|
|
394
|
+
sid = _get_active_session_id()
|
|
395
|
+
if not sid:
|
|
396
|
+
return
|
|
397
|
+
await components.executor.submit_and_wait(
|
|
398
|
+
op.ChangeThinkingOperation(
|
|
399
|
+
session_id=sid,
|
|
400
|
+
thinking=thinking,
|
|
401
|
+
emit_welcome_event=False,
|
|
402
|
+
emit_switch_message=False,
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Inject command name checker into user_input renderer (for slash command highlighting)
|
|
407
|
+
from klaude_code.ui.renderers.user_input import set_command_name_checker
|
|
408
|
+
|
|
409
|
+
set_command_name_checker(is_slash_command_name)
|
|
410
|
+
|
|
284
411
|
input_provider: ui.InputProviderABC = ui.PromptToolkitInput(
|
|
285
412
|
status_provider=_status_provider,
|
|
286
413
|
pre_prompt=_stop_rich_bottom_ui,
|
|
287
414
|
is_light_background=is_light_background,
|
|
415
|
+
get_current_model_config_name=lambda: (
|
|
416
|
+
components.executor.context.current_agent.session.model_config_name
|
|
417
|
+
if components.executor.context.current_agent is not None
|
|
418
|
+
else None
|
|
419
|
+
),
|
|
420
|
+
on_change_model=_change_model_from_prompt,
|
|
421
|
+
get_current_llm_config=_get_current_llm_config,
|
|
422
|
+
on_change_thinking=_change_thinking_from_prompt,
|
|
423
|
+
command_info_provider=get_command_info_list,
|
|
288
424
|
)
|
|
289
425
|
|
|
290
426
|
# --- Custom Ctrl+C handler: double-press within 2s to exit, single press shows toast ---
|
|
@@ -330,13 +466,6 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
|
|
|
330
466
|
is_new_session=session_id is None,
|
|
331
467
|
)
|
|
332
468
|
|
|
333
|
-
def _get_active_session_id() -> str | None:
|
|
334
|
-
"""Get the current active session ID dynamically.
|
|
335
|
-
|
|
336
|
-
This is necessary because /clear command creates a new session with a different ID.
|
|
337
|
-
"""
|
|
338
|
-
return components.executor.context.current_session_id()
|
|
339
|
-
|
|
340
469
|
# Input
|
|
341
470
|
await input_provider.start()
|
|
342
471
|
async for user_input in input_provider.iter_inputs():
|
|
@@ -345,30 +474,38 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
|
|
|
345
474
|
break
|
|
346
475
|
elif user_input.text.strip() == "":
|
|
347
476
|
continue
|
|
348
|
-
#
|
|
349
|
-
#
|
|
477
|
+
# Use dynamic session_id lookup to handle /clear creating new sessions.
|
|
478
|
+
# UI/CLI parses commands and submits concrete operations; core executes operations.
|
|
350
479
|
active_session_id = _get_active_session_id()
|
|
351
|
-
|
|
352
|
-
|
|
480
|
+
is_interactive = has_interactive_command(user_input.text)
|
|
481
|
+
|
|
482
|
+
wait_id = await submit_user_input_payload(
|
|
483
|
+
executor=components.executor,
|
|
484
|
+
event_queue=components.event_queue,
|
|
485
|
+
user_input=user_input,
|
|
486
|
+
session_id=active_session_id,
|
|
353
487
|
)
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
488
|
+
|
|
489
|
+
if wait_id is None:
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
if is_interactive:
|
|
493
|
+
await components.executor.wait_for(wait_id)
|
|
494
|
+
continue
|
|
495
|
+
|
|
496
|
+
# Esc monitor for long-running, interruptible operations
|
|
497
|
+
async def _on_esc_interrupt() -> None:
|
|
498
|
+
await components.executor.submit(op.InterruptOperation(target_session_id=_get_active_session_id()))
|
|
499
|
+
|
|
500
|
+
stop_event, esc_task = start_esc_interrupt_monitor(_on_esc_interrupt)
|
|
501
|
+
# Wait for this specific task to complete before accepting next input
|
|
502
|
+
try:
|
|
503
|
+
await components.executor.wait_for(wait_id)
|
|
504
|
+
finally:
|
|
505
|
+
# Stop ESC monitor and wait for it to finish cleaning up TTY
|
|
506
|
+
stop_event.set()
|
|
507
|
+
with contextlib.suppress(Exception):
|
|
508
|
+
await esc_task
|
|
372
509
|
|
|
373
510
|
except KeyboardInterrupt:
|
|
374
511
|
await _handle_keyboard_interrupt(components.executor)
|
klaude_code/command/__init__.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from .command_abc import CommandABC, CommandResult
|
|
2
2
|
from .registry import (
|
|
3
3
|
dispatch_command,
|
|
4
|
+
get_command_info_list,
|
|
5
|
+
get_command_names,
|
|
4
6
|
get_commands,
|
|
5
7
|
has_interactive_command,
|
|
6
8
|
is_slash_command_name,
|
|
@@ -94,6 +96,8 @@ __all__ = [
|
|
|
94
96
|
"CommandResult",
|
|
95
97
|
"dispatch_command",
|
|
96
98
|
"ensure_commands_loaded",
|
|
99
|
+
"get_command_info_list",
|
|
100
|
+
"get_command_names",
|
|
97
101
|
"get_commands",
|
|
98
102
|
"has_interactive_command",
|
|
99
103
|
"is_slash_command_name",
|
klaude_code/command/help_cmd.py
CHANGED
|
@@ -22,8 +22,9 @@ Usage:
|
|
|
22
22
|
[b]esc[/b] to interrupt agent task
|
|
23
23
|
[b]shift-enter[/b] or [b]ctrl-j[/b] for new line
|
|
24
24
|
[b]ctrl-v[/b] for pasting image
|
|
25
|
+
[b]ctrl-l[/b] to switch model
|
|
26
|
+
[b]ctrl-t[/b] to switch thinking level
|
|
25
27
|
[b]--continue[/b] or [b]--resume[/b] to continue an old session
|
|
26
|
-
[b]--select-model[/b] to switch model
|
|
27
28
|
|
|
28
29
|
Available slash commands:"""
|
|
29
30
|
]
|
klaude_code/command/model_cmd.py
CHANGED
|
@@ -3,7 +3,7 @@ import asyncio
|
|
|
3
3
|
from prompt_toolkit.styles import Style
|
|
4
4
|
|
|
5
5
|
from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
|
|
6
|
-
from klaude_code.
|
|
6
|
+
from klaude_code.command.model_select import select_model_interactive
|
|
7
7
|
from klaude_code.protocol import commands, events, model, op
|
|
8
8
|
from klaude_code.ui.terminal.selector import SelectItem, select_one
|
|
9
9
|
|
|
@@ -13,7 +13,7 @@ SELECT_STYLE = Style(
|
|
|
13
13
|
("pointer", "ansigreen"),
|
|
14
14
|
("highlighted", "ansigreen"),
|
|
15
15
|
("text", "ansibrightblack"),
|
|
16
|
-
("question", ""),
|
|
16
|
+
("question", "bold"),
|
|
17
17
|
]
|
|
18
18
|
)
|
|
19
19
|
|
|
@@ -29,8 +29,6 @@ def _confirm_change_default_model_sync(selected_model: str) -> bool:
|
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
try:
|
|
32
|
-
# Add a blank line between the model selector and this confirmation prompt.
|
|
33
|
-
print("")
|
|
34
32
|
result = select_one(
|
|
35
33
|
message=f"Save '{selected_model}' as default model?",
|
|
36
34
|
items=items,
|
|
@@ -68,7 +66,7 @@ class ModelCommand(CommandABC):
|
|
|
68
66
|
return "model name"
|
|
69
67
|
|
|
70
68
|
async def run(self, agent: Agent, user_input: model.UserInputPayload) -> CommandResult:
|
|
71
|
-
selected_model = await asyncio.to_thread(
|
|
69
|
+
selected_model = await asyncio.to_thread(select_model_interactive, preferred=user_input.text)
|
|
72
70
|
|
|
73
71
|
current_model = agent.profile.llm_client.model_name if agent.profile else None
|
|
74
72
|
if selected_model is None or selected_model == current_model:
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Interactive model selection for CLI."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from klaude_code.config.config import load_config
|
|
6
|
+
from klaude_code.config.select_model import match_model_from_config
|
|
7
|
+
from klaude_code.trace import log
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def select_model_interactive(preferred: str | None = None) -> str | None:
|
|
11
|
+
"""Interactive single-choice model selector.
|
|
12
|
+
|
|
13
|
+
This function combines matching logic with interactive UI selection.
|
|
14
|
+
For CLI usage.
|
|
15
|
+
|
|
16
|
+
If preferred is provided:
|
|
17
|
+
- Exact match: return immediately
|
|
18
|
+
- Single partial match (case-insensitive): return immediately
|
|
19
|
+
- Otherwise: fall through to interactive selection
|
|
20
|
+
"""
|
|
21
|
+
result = match_model_from_config(preferred)
|
|
22
|
+
|
|
23
|
+
if result.error_message:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
if result.matched_model:
|
|
27
|
+
return result.matched_model
|
|
28
|
+
|
|
29
|
+
# Non-interactive environments (CI/pipes) should never enter an interactive prompt.
|
|
30
|
+
# If we couldn't resolve to a single model deterministically above, fail with a clear hint.
|
|
31
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
32
|
+
log(("Error: cannot use interactive model selection without a TTY", "red"))
|
|
33
|
+
log(("Hint: pass --model <config-name> or set main_model in ~/.klaude/klaude-config.yaml", "yellow"))
|
|
34
|
+
if preferred:
|
|
35
|
+
log((f"Hint: '{preferred}' did not resolve to a single configured model", "yellow"))
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
# Interactive selection
|
|
39
|
+
from prompt_toolkit.styles import Style
|
|
40
|
+
|
|
41
|
+
from klaude_code.ui.terminal.selector import build_model_select_items, select_one
|
|
42
|
+
|
|
43
|
+
config = load_config()
|
|
44
|
+
names = [m.model_name for m in result.filtered_models]
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
items = build_model_select_items(result.filtered_models)
|
|
48
|
+
|
|
49
|
+
message = f"Select a model (filtered by '{result.filter_hint}'):" if result.filter_hint else "Select a model:"
|
|
50
|
+
selected = select_one(
|
|
51
|
+
message=message,
|
|
52
|
+
items=items,
|
|
53
|
+
pointer="->",
|
|
54
|
+
use_search_filter=True,
|
|
55
|
+
initial_value=config.main_model,
|
|
56
|
+
style=Style(
|
|
57
|
+
[
|
|
58
|
+
("pointer", "ansigreen"),
|
|
59
|
+
("highlighted", "ansigreen"),
|
|
60
|
+
("msg", ""),
|
|
61
|
+
("meta", "fg:ansibrightblack"),
|
|
62
|
+
("text", "ansibrightblack"),
|
|
63
|
+
("question", "bold"),
|
|
64
|
+
("search_prefix", "ansibrightblack"),
|
|
65
|
+
# search filter colors at the bottom
|
|
66
|
+
("search_success", "noinherit fg:ansigreen"),
|
|
67
|
+
("search_none", "noinherit fg:ansired"),
|
|
68
|
+
]
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
if isinstance(selected, str) and selected in names:
|
|
72
|
+
return selected
|
|
73
|
+
except KeyboardInterrupt:
|
|
74
|
+
return None
|
|
75
|
+
except Exception as e:
|
|
76
|
+
log((f"Failed to use prompt_toolkit for model selection: {e}", "yellow"))
|
|
77
|
+
# Never return an unvalidated model name here.
|
|
78
|
+
# If we can't interactively select, fall back to a known configured model.
|
|
79
|
+
if isinstance(preferred, str) and preferred in names:
|
|
80
|
+
return preferred
|
|
81
|
+
if config.main_model and config.main_model in names:
|
|
82
|
+
return config.main_model
|
|
83
|
+
|
|
84
|
+
return None
|
klaude_code/command/registry.py
CHANGED
|
@@ -105,6 +105,29 @@ def get_commands() -> dict[commands.CommandName | str, "CommandABC"]:
|
|
|
105
105
|
return _COMMANDS.copy()
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def get_command_info_list() -> list[commands.CommandInfo]:
|
|
109
|
+
"""Get lightweight command metadata for UI purposes.
|
|
110
|
+
|
|
111
|
+
Returns CommandInfo list in registration order (display order).
|
|
112
|
+
"""
|
|
113
|
+
_ensure_commands_loaded()
|
|
114
|
+
return [
|
|
115
|
+
commands.CommandInfo(
|
|
116
|
+
name=_command_key_to_str(cmd.name),
|
|
117
|
+
summary=cmd.summary,
|
|
118
|
+
support_addition_params=cmd.support_addition_params,
|
|
119
|
+
placeholder=cmd.placeholder,
|
|
120
|
+
)
|
|
121
|
+
for cmd in _COMMANDS.values()
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_command_names() -> frozenset[str]:
|
|
126
|
+
"""Get all registered command names as a frozen set for fast lookup."""
|
|
127
|
+
_ensure_commands_loaded()
|
|
128
|
+
return frozenset(_command_key_to_str(key) for key in _COMMANDS)
|
|
129
|
+
|
|
130
|
+
|
|
108
131
|
def is_slash_command_name(name: str) -> bool:
|
|
109
132
|
_ensure_commands_loaded()
|
|
110
133
|
return _resolve_command_key(name) is not None
|
|
@@ -1,8 +1,58 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
|
|
3
|
+
from prompt_toolkit.styles import Style
|
|
4
|
+
|
|
3
5
|
from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
|
|
4
6
|
from klaude_code.protocol import commands, events, model, op
|
|
5
|
-
from klaude_code.session.selector import
|
|
7
|
+
from klaude_code.session.selector import build_session_select_options
|
|
8
|
+
from klaude_code.trace import log
|
|
9
|
+
from klaude_code.ui.terminal.selector import SelectItem, select_one
|
|
10
|
+
|
|
11
|
+
SESSION_SELECT_STYLE = Style(
|
|
12
|
+
[
|
|
13
|
+
("msg", ""),
|
|
14
|
+
("meta", "fg:ansibrightblack"),
|
|
15
|
+
("pointer", "bold fg:ansigreen"),
|
|
16
|
+
("highlighted", "fg:ansigreen"),
|
|
17
|
+
("search_prefix", "fg:ansibrightblack"),
|
|
18
|
+
("search_success", "noinherit fg:ansigreen"),
|
|
19
|
+
("search_none", "noinherit fg:ansired"),
|
|
20
|
+
("question", "bold"),
|
|
21
|
+
("text", ""),
|
|
22
|
+
]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _select_session_sync() -> str | None:
|
|
27
|
+
"""Interactive session selection (sync version for asyncio.to_thread)."""
|
|
28
|
+
options = build_session_select_options()
|
|
29
|
+
if not options:
|
|
30
|
+
log("No sessions found for this project.")
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
items: list[SelectItem[str]] = []
|
|
34
|
+
for opt in options:
|
|
35
|
+
title = [
|
|
36
|
+
("class:msg", f"{opt.first_user_message}\n"),
|
|
37
|
+
("class:meta", f" {opt.messages_count} · {opt.relative_time} · {opt.model_name} · {opt.session_id}\n\n"),
|
|
38
|
+
]
|
|
39
|
+
items.append(
|
|
40
|
+
SelectItem(
|
|
41
|
+
title=title,
|
|
42
|
+
value=opt.session_id,
|
|
43
|
+
search_text=f"{opt.first_user_message} {opt.model_name} {opt.session_id}",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
return select_one(
|
|
49
|
+
message="Select a session to resume:",
|
|
50
|
+
items=items,
|
|
51
|
+
pointer="→",
|
|
52
|
+
style=SESSION_SELECT_STYLE,
|
|
53
|
+
)
|
|
54
|
+
except KeyboardInterrupt:
|
|
55
|
+
return None
|
|
6
56
|
|
|
7
57
|
|
|
8
58
|
class ResumeCommand(CommandABC):
|
|
@@ -33,7 +83,7 @@ class ResumeCommand(CommandABC):
|
|
|
33
83
|
)
|
|
34
84
|
return CommandResult(events=[event], persist_user_input=False, persist_events=False)
|
|
35
85
|
|
|
36
|
-
selected_session_id = await asyncio.to_thread(
|
|
86
|
+
selected_session_id = await asyncio.to_thread(_select_session_sync)
|
|
37
87
|
if selected_session_id is None:
|
|
38
88
|
event = events.DeveloperMessageEvent(
|
|
39
89
|
session_id=agent.session.id,
|