klaude-code 1.4.3__py3-none-any.whl → 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. klaude_code/cli/main.py +22 -11
  2. klaude_code/cli/runtime.py +171 -34
  3. klaude_code/command/__init__.py +4 -0
  4. klaude_code/command/fork_session_cmd.py +220 -2
  5. klaude_code/command/help_cmd.py +2 -1
  6. klaude_code/command/model_cmd.py +3 -5
  7. klaude_code/command/model_select.py +84 -0
  8. klaude_code/command/refresh_cmd.py +4 -4
  9. klaude_code/command/registry.py +23 -0
  10. klaude_code/command/resume_cmd.py +62 -2
  11. klaude_code/command/thinking_cmd.py +30 -199
  12. klaude_code/config/select_model.py +47 -97
  13. klaude_code/config/thinking.py +255 -0
  14. klaude_code/core/executor.py +53 -63
  15. klaude_code/llm/usage.py +1 -1
  16. klaude_code/protocol/commands.py +11 -0
  17. klaude_code/protocol/op.py +15 -0
  18. klaude_code/session/__init__.py +2 -2
  19. klaude_code/session/selector.py +65 -65
  20. klaude_code/session/session.py +18 -12
  21. klaude_code/ui/modes/repl/completers.py +27 -15
  22. klaude_code/ui/modes/repl/event_handler.py +24 -33
  23. klaude_code/ui/modes/repl/input_prompt_toolkit.py +393 -57
  24. klaude_code/ui/modes/repl/key_bindings.py +30 -10
  25. klaude_code/ui/modes/repl/renderer.py +1 -1
  26. klaude_code/ui/renderers/developer.py +2 -2
  27. klaude_code/ui/renderers/metadata.py +11 -6
  28. klaude_code/ui/renderers/user_input.py +18 -1
  29. klaude_code/ui/rich/markdown.py +41 -9
  30. klaude_code/ui/rich/status.py +83 -22
  31. klaude_code/ui/rich/theme.py +2 -2
  32. klaude_code/ui/terminal/notifier.py +42 -0
  33. klaude_code/ui/terminal/selector.py +488 -136
  34. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/METADATA +1 -1
  35. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/RECORD +37 -35
  36. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/WHEEL +0 -0
  37. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/entry_points.txt +0 -0
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.session import Session, resume_select_session
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
- if not sys.stdout.isatty():
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
- sys.stdout.write(f"\033]0;{title}\007")
24
- sys.stdout.flush()
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 = select_model_from_config(preferred=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 = select_model_from_config()
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.config.select_model import select_model_from_config
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 = select_model_from_config(preferred=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 = resume_select_session()
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 = select_model_from_config()
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
@@ -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
- # Submit the input content directly
236
- await components.executor.submit_and_wait(
237
- op.UserInputOperation(input=UserInputPayload(text=input_content), session_id=session_id)
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
- # Submit user input operation - directly use the payload from iter_inputs
349
- # Use dynamic session_id lookup to handle /clear creating new sessions
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
- submission_id = await components.executor.submit(
352
- op.UserInputOperation(input=user_input, session_id=active_session_id)
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
- # If it's an interactive command (e.g., /model), avoid starting the ESC monitor
355
- # to prevent TTY conflicts with interactive prompt_toolkit UIs.
356
- if has_interactive_command(user_input.text):
357
- await components.executor.wait_for(submission_id)
358
- else:
359
- # Esc monitor for long-running, interruptible operations
360
- async def _on_esc_interrupt() -> None:
361
- await components.executor.submit(op.InterruptOperation(target_session_id=_get_active_session_id()))
362
-
363
- stop_event, esc_task = start_esc_interrupt_monitor(_on_esc_interrupt)
364
- # Wait for this specific task to complete before accepting next input
365
- try:
366
- await components.executor.wait_for(submission_id)
367
- finally:
368
- # Stop ESC monitor and wait for it to finish cleaning up TTY
369
- stop_event.set()
370
- with contextlib.suppress(Exception):
371
- await esc_task
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)
@@ -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
- new_session = agent.session.fork()
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 successfully. New session id: {new_session.id}",
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),
@@ -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
  ]
@@ -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.config.select_model import select_model_from_config
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(select_model_from_config, preferred=user_input.text)
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: