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.
- klaude_code/cli/main.py +22 -11
- klaude_code/cli/runtime.py +171 -34
- klaude_code/command/__init__.py +4 -0
- klaude_code/command/fork_session_cmd.py +220 -2
- 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/refresh_cmd.py +4 -4
- klaude_code/command/registry.py +23 -0
- klaude_code/command/resume_cmd.py +62 -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/llm/usage.py +1 -1
- 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 +65 -65
- klaude_code/session/session.py +18 -12
- klaude_code/ui/modes/repl/completers.py +27 -15
- klaude_code/ui/modes/repl/event_handler.py +24 -33
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +393 -57
- klaude_code/ui/modes/repl/key_bindings.py +30 -10
- klaude_code/ui/modes/repl/renderer.py +1 -1
- klaude_code/ui/renderers/developer.py +2 -2
- klaude_code/ui/renderers/metadata.py +11 -6
- klaude_code/ui/renderers/user_input.py +18 -1
- klaude_code/ui/rich/markdown.py +41 -9
- klaude_code/ui/rich/status.py +83 -22
- klaude_code/ui/rich/theme.py +2 -2
- klaude_code/ui/terminal/notifier.py +42 -0
- klaude_code/ui/terminal/selector.py +488 -136
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/METADATA +1 -1
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/RECORD +37 -35
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/WHEEL +0 -0
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.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,7 +11,8 @@ 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.
|
|
14
|
+
from klaude_code.command.resume_cmd import select_session_sync
|
|
15
|
+
from klaude_code.session import Session
|
|
14
16
|
from klaude_code.trace import DebugType, prepare_debug_log_file
|
|
15
17
|
|
|
16
18
|
|
|
@@ -18,10 +20,19 @@ def set_terminal_title(title: str) -> None:
|
|
|
18
20
|
"""Set terminal window title using ANSI escape sequence."""
|
|
19
21
|
# Never write terminal control sequences when stdout is not a TTY (pipes/CI/redirects).
|
|
20
22
|
# This avoids corrupting machine-readable output (e.g., JSON streaming) and log captures.
|
|
21
|
-
|
|
23
|
+
#
|
|
24
|
+
# Use the original stdout to bypass prompt_toolkit's `patch_stdout()`. Writing OSC
|
|
25
|
+
# sequences to the patched stdout can cause them to appear as visible text.
|
|
26
|
+
stream = getattr(sys, "__stdout__", None) or sys.stdout
|
|
27
|
+
try:
|
|
28
|
+
if not stream.isatty():
|
|
29
|
+
return
|
|
30
|
+
except Exception:
|
|
22
31
|
return
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
|
|
33
|
+
stream.write(f"\033]0;{title}\007")
|
|
34
|
+
with contextlib.suppress(Exception):
|
|
35
|
+
stream.flush()
|
|
25
36
|
|
|
26
37
|
|
|
27
38
|
def update_terminal_title(model_name: str | None = None) -> None:
|
|
@@ -147,19 +158,19 @@ def exec_command(
|
|
|
147
158
|
raise typer.Exit(1)
|
|
148
159
|
|
|
149
160
|
from klaude_code.cli.runtime import AppInitConfig, run_exec
|
|
161
|
+
from klaude_code.command.model_select import select_model_interactive
|
|
150
162
|
from klaude_code.config import load_config
|
|
151
|
-
from klaude_code.config.select_model import select_model_from_config
|
|
152
163
|
|
|
153
164
|
chosen_model = model
|
|
154
165
|
if model or select_model:
|
|
155
|
-
chosen_model =
|
|
166
|
+
chosen_model = select_model_interactive(preferred=model)
|
|
156
167
|
if chosen_model is None:
|
|
157
168
|
raise typer.Exit(1)
|
|
158
169
|
else:
|
|
159
170
|
# Check if main_model is configured; if not, trigger interactive selection
|
|
160
171
|
config = load_config()
|
|
161
172
|
if config.main_model is None:
|
|
162
|
-
chosen_model =
|
|
173
|
+
chosen_model = select_model_interactive()
|
|
163
174
|
if chosen_model is None:
|
|
164
175
|
raise typer.Exit(1)
|
|
165
176
|
# Save the selection as default
|
|
@@ -282,13 +293,13 @@ def main_callback(
|
|
|
282
293
|
return
|
|
283
294
|
|
|
284
295
|
from klaude_code.cli.runtime import AppInitConfig, run_interactive
|
|
285
|
-
from klaude_code.
|
|
296
|
+
from klaude_code.command.model_select import select_model_interactive
|
|
286
297
|
|
|
287
298
|
update_terminal_title()
|
|
288
299
|
|
|
289
300
|
chosen_model = model
|
|
290
301
|
if model or select_model:
|
|
291
|
-
chosen_model =
|
|
302
|
+
chosen_model = select_model_interactive(preferred=model)
|
|
292
303
|
if chosen_model is None:
|
|
293
304
|
return
|
|
294
305
|
|
|
@@ -297,7 +308,7 @@ def main_callback(
|
|
|
297
308
|
session_id: str | None = None
|
|
298
309
|
|
|
299
310
|
if resume:
|
|
300
|
-
session_id =
|
|
311
|
+
session_id = select_session_sync()
|
|
301
312
|
if session_id is None:
|
|
302
313
|
return
|
|
303
314
|
# If user didn't pick, allow fallback to --continue
|
|
@@ -343,7 +354,7 @@ def main_callback(
|
|
|
343
354
|
|
|
344
355
|
cfg = load_config()
|
|
345
356
|
if cfg.main_model is None:
|
|
346
|
-
chosen_model =
|
|
357
|
+
chosen_model = select_model_interactive()
|
|
347
358
|
if chosen_model is None:
|
|
348
359
|
raise typer.Exit(1)
|
|
349
360
|
# 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",
|
|
@@ -1,5 +1,182 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from prompt_toolkit.styles import Style
|
|
7
|
+
|
|
1
8
|
from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
|
|
2
9
|
from klaude_code.protocol import commands, events, model
|
|
10
|
+
from klaude_code.ui.terminal.selector import SelectItem, select_one
|
|
11
|
+
|
|
12
|
+
FORK_SELECT_STYLE = Style(
|
|
13
|
+
[
|
|
14
|
+
("msg", ""),
|
|
15
|
+
("meta", "fg:ansibrightblack"),
|
|
16
|
+
("separator", "fg:ansibrightblack"),
|
|
17
|
+
("assistant", "fg:ansiblue"),
|
|
18
|
+
("pointer", "bold fg:ansigreen"),
|
|
19
|
+
("search_prefix", "fg:ansibrightblack"),
|
|
20
|
+
("search_success", "noinherit fg:ansigreen"),
|
|
21
|
+
("search_none", "noinherit fg:ansired"),
|
|
22
|
+
("question", "bold"),
|
|
23
|
+
("text", ""),
|
|
24
|
+
]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ForkPoint:
|
|
30
|
+
"""A fork point in conversation history."""
|
|
31
|
+
|
|
32
|
+
history_index: int | None # None means fork entire conversation
|
|
33
|
+
user_message: str
|
|
34
|
+
tool_call_stats: dict[str, int] # tool_name -> count
|
|
35
|
+
last_assistant_summary: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _truncate(text: str, max_len: int = 60) -> str:
|
|
39
|
+
"""Truncate text to max_len, adding ellipsis if needed."""
|
|
40
|
+
text = text.replace("\n", " ").strip()
|
|
41
|
+
if len(text) <= max_len:
|
|
42
|
+
return text
|
|
43
|
+
return text[: max_len - 3] + "..."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_fork_points(conversation_history: list[model.ConversationItem]) -> list[ForkPoint]:
|
|
47
|
+
"""Build list of fork points from conversation history.
|
|
48
|
+
|
|
49
|
+
Fork points are:
|
|
50
|
+
- Each UserMessageItem position (for UI display, including first which would be empty session)
|
|
51
|
+
- The end of the conversation (fork entire conversation)
|
|
52
|
+
"""
|
|
53
|
+
fork_points: list[ForkPoint] = []
|
|
54
|
+
user_indices: list[int] = []
|
|
55
|
+
|
|
56
|
+
for i, item in enumerate(conversation_history):
|
|
57
|
+
if isinstance(item, model.UserMessageItem):
|
|
58
|
+
user_indices.append(i)
|
|
59
|
+
|
|
60
|
+
# For each UserMessageItem, create a fork point at that position
|
|
61
|
+
for i, user_idx in enumerate(user_indices):
|
|
62
|
+
user_item = conversation_history[user_idx]
|
|
63
|
+
assert isinstance(user_item, model.UserMessageItem)
|
|
64
|
+
|
|
65
|
+
# Find the end of this "task" (next UserMessageItem or end of history)
|
|
66
|
+
next_user_idx = user_indices[i + 1] if i + 1 < len(user_indices) else len(conversation_history)
|
|
67
|
+
|
|
68
|
+
# Count tool calls by name and find last assistant message in this segment
|
|
69
|
+
tool_stats: dict[str, int] = {}
|
|
70
|
+
last_assistant_content = ""
|
|
71
|
+
for j in range(user_idx, next_user_idx):
|
|
72
|
+
item = conversation_history[j]
|
|
73
|
+
if isinstance(item, model.ToolCallItem):
|
|
74
|
+
tool_stats[item.name] = tool_stats.get(item.name, 0) + 1
|
|
75
|
+
elif isinstance(item, model.AssistantMessageItem) and item.content:
|
|
76
|
+
last_assistant_content = item.content
|
|
77
|
+
|
|
78
|
+
fork_points.append(
|
|
79
|
+
ForkPoint(
|
|
80
|
+
history_index=user_idx,
|
|
81
|
+
user_message=user_item.content or "(empty)",
|
|
82
|
+
tool_call_stats=tool_stats,
|
|
83
|
+
last_assistant_summary=_truncate(last_assistant_content) if last_assistant_content else "",
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Add the "fork entire conversation" option at the end
|
|
88
|
+
if user_indices:
|
|
89
|
+
fork_points.append(
|
|
90
|
+
ForkPoint(
|
|
91
|
+
history_index=None, # None means fork entire conversation
|
|
92
|
+
user_message="", # No specific message, this represents the end
|
|
93
|
+
tool_call_stats={},
|
|
94
|
+
last_assistant_summary="",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return fork_points
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int | None]]:
|
|
102
|
+
"""Build SelectItem list from fork points."""
|
|
103
|
+
items: list[SelectItem[int | None]] = []
|
|
104
|
+
|
|
105
|
+
for i, fp in enumerate(fork_points):
|
|
106
|
+
is_first = i == 0
|
|
107
|
+
is_last = i == len(fork_points) - 1
|
|
108
|
+
|
|
109
|
+
# Build the title
|
|
110
|
+
title_parts: list[tuple[str, str]] = []
|
|
111
|
+
|
|
112
|
+
# First line: separator (with special markers for first/last fork points)
|
|
113
|
+
if is_first and not is_last:
|
|
114
|
+
title_parts.append(("class:separator", "----- fork from here (empty session) -----\n\n"))
|
|
115
|
+
elif is_last:
|
|
116
|
+
title_parts.append(("class:separator", "----- fork from here (entire session) -----\n\n"))
|
|
117
|
+
else:
|
|
118
|
+
title_parts.append(("class:separator", "----- fork from here -----\n\n"))
|
|
119
|
+
|
|
120
|
+
if not is_last:
|
|
121
|
+
# Second line: user message
|
|
122
|
+
title_parts.append(("class:msg", f"user: {_truncate(fp.user_message, 70)}\n"))
|
|
123
|
+
|
|
124
|
+
# Third line: tool call stats (if any)
|
|
125
|
+
if fp.tool_call_stats:
|
|
126
|
+
tool_parts = [f"{name} × {count}" for name, count in fp.tool_call_stats.items()]
|
|
127
|
+
title_parts.append(("class:meta", f"tools: {', '.join(tool_parts)}\n"))
|
|
128
|
+
|
|
129
|
+
# Fourth line: last assistant message summary (if any)
|
|
130
|
+
if fp.last_assistant_summary:
|
|
131
|
+
title_parts.append(("class:assistant", f"ai: {fp.last_assistant_summary}\n"))
|
|
132
|
+
|
|
133
|
+
# Empty line at the end
|
|
134
|
+
title_parts.append(("class:text", "\n"))
|
|
135
|
+
|
|
136
|
+
items.append(
|
|
137
|
+
SelectItem(
|
|
138
|
+
title=title_parts,
|
|
139
|
+
value=fp.history_index,
|
|
140
|
+
search_text=fp.user_message if not is_last else "fork entire conversation",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return items
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _select_fork_point_sync(fork_points: list[ForkPoint]) -> int | None | Literal["cancelled"]:
|
|
148
|
+
"""Interactive fork point selection (sync version for asyncio.to_thread).
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
- int: history index to fork at (exclusive)
|
|
152
|
+
- None: fork entire conversation
|
|
153
|
+
- "cancelled": user cancelled selection
|
|
154
|
+
"""
|
|
155
|
+
items = _build_select_items(fork_points)
|
|
156
|
+
if not items:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
# Default to the last option (fork entire conversation)
|
|
160
|
+
last_value = items[-1].value
|
|
161
|
+
|
|
162
|
+
# Non-interactive environments default to forking entire conversation
|
|
163
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
164
|
+
return last_value
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
result = select_one(
|
|
168
|
+
message="Select fork point (messages before this point will be included):",
|
|
169
|
+
items=items,
|
|
170
|
+
pointer="→",
|
|
171
|
+
style=FORK_SELECT_STYLE,
|
|
172
|
+
initial_value=last_value,
|
|
173
|
+
highlight_pointed_item=False,
|
|
174
|
+
)
|
|
175
|
+
if result is None:
|
|
176
|
+
return "cancelled"
|
|
177
|
+
return result
|
|
178
|
+
except KeyboardInterrupt:
|
|
179
|
+
return "cancelled"
|
|
3
180
|
|
|
4
181
|
|
|
5
182
|
class ForkSessionCommand(CommandABC):
|
|
@@ -13,6 +190,10 @@ class ForkSessionCommand(CommandABC):
|
|
|
13
190
|
def summary(self) -> str:
|
|
14
191
|
return "Fork the current session and show a resume-by-id command"
|
|
15
192
|
|
|
193
|
+
@property
|
|
194
|
+
def is_interactive(self) -> bool:
|
|
195
|
+
return True
|
|
196
|
+
|
|
16
197
|
async def run(self, agent: Agent, user_input: model.UserInputPayload) -> CommandResult:
|
|
17
198
|
del user_input # unused
|
|
18
199
|
|
|
@@ -26,13 +207,50 @@ class ForkSessionCommand(CommandABC):
|
|
|
26
207
|
)
|
|
27
208
|
return CommandResult(events=[event], persist_user_input=False, persist_events=False)
|
|
28
209
|
|
|
29
|
-
|
|
210
|
+
# Build fork points from conversation history
|
|
211
|
+
fork_points = _build_fork_points(agent.session.conversation_history)
|
|
212
|
+
|
|
213
|
+
if not fork_points:
|
|
214
|
+
# Only one user message, just fork entirely
|
|
215
|
+
new_session = agent.session.fork()
|
|
216
|
+
await new_session.wait_for_flush()
|
|
217
|
+
|
|
218
|
+
event = events.DeveloperMessageEvent(
|
|
219
|
+
session_id=agent.session.id,
|
|
220
|
+
item=model.DeveloperMessageItem(
|
|
221
|
+
content=f"Session forked successfully. New session id: {new_session.id}",
|
|
222
|
+
command_output=model.CommandOutput(
|
|
223
|
+
command_name=self.name,
|
|
224
|
+
ui_extra=model.SessionIdUIExtra(session_id=new_session.id),
|
|
225
|
+
),
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
return CommandResult(events=[event], persist_user_input=False, persist_events=False)
|
|
229
|
+
|
|
230
|
+
# Interactive selection
|
|
231
|
+
selected = await asyncio.to_thread(_select_fork_point_sync, fork_points)
|
|
232
|
+
|
|
233
|
+
if selected == "cancelled":
|
|
234
|
+
event = events.DeveloperMessageEvent(
|
|
235
|
+
session_id=agent.session.id,
|
|
236
|
+
item=model.DeveloperMessageItem(
|
|
237
|
+
content="(fork cancelled)",
|
|
238
|
+
command_output=model.CommandOutput(command_name=self.name),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
return CommandResult(events=[event], persist_user_input=False, persist_events=False)
|
|
242
|
+
|
|
243
|
+
# Perform the fork
|
|
244
|
+
new_session = agent.session.fork(until_index=selected)
|
|
30
245
|
await new_session.wait_for_flush()
|
|
31
246
|
|
|
247
|
+
# Build result message
|
|
248
|
+
fork_description = "entire conversation" if selected is None else f"up to message index {selected}"
|
|
249
|
+
|
|
32
250
|
event = events.DeveloperMessageEvent(
|
|
33
251
|
session_id=agent.session.id,
|
|
34
252
|
item=model.DeveloperMessageItem(
|
|
35
|
-
content=f"Session forked
|
|
253
|
+
content=f"Session forked ({fork_description}). New session id: {new_session.id}",
|
|
36
254
|
command_output=model.CommandOutput(
|
|
37
255
|
command_name=self.name,
|
|
38
256
|
ui_extra=model.SessionIdUIExtra(session_id=new_session.id),
|
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:
|