klaude-code 1.2.6__py3-none-any.whl → 1.8.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 (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -1,16 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- import subprocess
4
3
  from pathlib import Path
5
4
 
6
- from klaude_code.command.command_abc import CommandABC, CommandResult
7
- from klaude_code.command.registry import register_command
8
- from klaude_code.core.agent import Agent
9
- from klaude_code.protocol import commands, events, model
10
- from klaude_code.session.export import build_export_html, get_default_export_path
5
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
6
+ from klaude_code.protocol import commands, model, op
11
7
 
12
8
 
13
- @register_command
14
9
  class ExportCommand(CommandABC):
15
10
  """Export the current session into a standalone HTML transcript."""
16
11
 
@@ -26,38 +21,26 @@ class ExportCommand(CommandABC):
26
21
  def support_addition_params(self) -> bool:
27
22
  return True
28
23
 
24
+ @property
25
+ def placeholder(self) -> str:
26
+ return "output path"
27
+
29
28
  @property
30
29
  def is_interactive(self) -> bool:
31
30
  return False
32
31
 
33
- async def run(self, raw: str, agent: Agent) -> CommandResult:
34
- try:
35
- output_path = self._resolve_output_path(raw, agent)
36
- html_doc = self._build_html(agent)
37
- output_path.parent.mkdir(parents=True, exist_ok=True)
38
- output_path.write_text(html_doc, encoding="utf-8")
39
- self._open_file(output_path)
40
- event = events.DeveloperMessageEvent(
41
- session_id=agent.session.id,
42
- item=model.DeveloperMessageItem(
43
- content=f"Session exported and opened: {output_path}",
44
- command_output=model.CommandOutput(command_name=self.name),
45
- ),
46
- )
47
- return CommandResult(events=[event])
48
- except Exception as exc: # pragma: no cover - safeguard for unexpected errors
49
- import traceback
50
-
51
- event = events.DeveloperMessageEvent(
52
- session_id=agent.session.id,
53
- item=model.DeveloperMessageItem(
54
- content=f"Failed to export session: {exc}\n{traceback.format_exc()}",
55
- command_output=model.CommandOutput(command_name=self.name, is_error=True),
56
- ),
57
- )
58
- return CommandResult(events=[event])
59
-
60
- def _resolve_output_path(self, raw: str, agent: Agent) -> Path:
32
+ async def run(self, agent: Agent, user_input: model.UserInputPayload) -> CommandResult:
33
+ output_path = self._normalize_output_path(user_input.text, agent)
34
+ return CommandResult(
35
+ operations=[
36
+ op.ExportSessionOperation(
37
+ session_id=agent.session.id,
38
+ output_path=str(output_path) if output_path is not None else None,
39
+ )
40
+ ]
41
+ )
42
+
43
+ def _normalize_output_path(self, raw: str, agent: Agent) -> Path | None:
61
44
  trimmed = raw.strip()
62
45
  if trimmed:
63
46
  candidate = Path(trimmed).expanduser()
@@ -66,21 +49,4 @@ class ExportCommand(CommandABC):
66
49
  if candidate.suffix.lower() != ".html":
67
50
  candidate = candidate.with_suffix(".html")
68
51
  return candidate
69
- return get_default_export_path(agent.session)
70
-
71
- def _open_file(self, path: Path) -> None:
72
- try:
73
- subprocess.run(["open", str(path)], check=True)
74
- except FileNotFoundError as exc: # pragma: no cover - depends on platform
75
- msg = "`open` command not found; please open the HTML manually."
76
- raise RuntimeError(msg) from exc
77
- except subprocess.CalledProcessError as exc: # pragma: no cover - depends on platform
78
- msg = f"Failed to open HTML with `open`: {exc}"
79
- raise RuntimeError(msg) from exc
80
-
81
- def _build_html(self, agent: Agent) -> str:
82
- profile = agent.profile
83
- system_prompt = (profile.system_prompt if profile else "") or ""
84
- tools = profile.tools if profile else []
85
- model_name = profile.llm_client.model_name if profile else "unknown"
86
- return build_export_html(agent.session, system_prompt, tools, model_name)
52
+ return None
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from rich.console import Console
10
+ from rich.text import Text
11
+
12
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
13
+ from klaude_code.protocol import commands, events, model
14
+ from klaude_code.session.export import build_export_html
15
+
16
+
17
+ class ExportOnlineCommand(CommandABC):
18
+ """Export and deploy the current session to surge.sh as a static webpage."""
19
+
20
+ @property
21
+ def name(self) -> commands.CommandName:
22
+ return commands.CommandName.EXPORT_ONLINE
23
+
24
+ @property
25
+ def summary(self) -> str:
26
+ return "Export and deploy session to surge.sh"
27
+
28
+ @property
29
+ def support_addition_params(self) -> bool:
30
+ return False
31
+
32
+ @property
33
+ def is_interactive(self) -> bool:
34
+ return False
35
+
36
+ async def run(self, agent: Agent, user_input: model.UserInputPayload) -> CommandResult:
37
+ del user_input # unused
38
+ # Check if npx or surge is available
39
+ surge_cmd = self._get_surge_command()
40
+ if not surge_cmd:
41
+ event = events.DeveloperMessageEvent(
42
+ session_id=agent.session.id,
43
+ item=model.DeveloperMessageItem(
44
+ content="surge.sh CLI not found. Install with: npm install -g surge",
45
+ command_output=model.CommandOutput(command_name=self.name, is_error=True),
46
+ ),
47
+ )
48
+ return CommandResult(events=[event])
49
+
50
+ try:
51
+ console = Console()
52
+ # Check login status inside status context since npx surge whoami can be slow
53
+ with console.status(Text("Checking surge.sh login status...", style="dim"), spinner_style="dim"):
54
+ logged_in = self._is_surge_logged_in(surge_cmd)
55
+
56
+ if not logged_in:
57
+ login_cmd = " ".join([*surge_cmd, "login"])
58
+ event = events.DeveloperMessageEvent(
59
+ session_id=agent.session.id,
60
+ item=model.DeveloperMessageItem(
61
+ content=f"Not logged in to surge.sh. Please run: {login_cmd}",
62
+ command_output=model.CommandOutput(command_name=self.name, is_error=True),
63
+ ),
64
+ )
65
+ return CommandResult(events=[event])
66
+
67
+ with console.status(Text("Deploying to surge.sh...", style="dim"), spinner_style="dim"):
68
+ html_doc = self._build_html(agent)
69
+ domain = self._generate_domain()
70
+ url = self._deploy_to_surge(surge_cmd, html_doc, domain)
71
+
72
+ event = events.DeveloperMessageEvent(
73
+ session_id=agent.session.id,
74
+ item=model.DeveloperMessageItem(
75
+ content=f"Session deployed to: {url}",
76
+ command_output=model.CommandOutput(command_name=self.name),
77
+ ),
78
+ )
79
+ return CommandResult(events=[event])
80
+ except Exception as exc:
81
+ import traceback
82
+
83
+ event = events.DeveloperMessageEvent(
84
+ session_id=agent.session.id,
85
+ item=model.DeveloperMessageItem(
86
+ content=f"Failed to deploy session: {exc}\n{traceback.format_exc()}",
87
+ command_output=model.CommandOutput(command_name=self.name, is_error=True),
88
+ ),
89
+ )
90
+ return CommandResult(events=[event])
91
+
92
+ def _get_surge_command(self) -> list[str] | None:
93
+ """Check if surge CLI is available, prefer npx if available."""
94
+ # Check for npx first (more common)
95
+ if shutil.which("npx"):
96
+ return ["npx", "surge"]
97
+ # Check for globally installed surge
98
+ if shutil.which("surge"):
99
+ return ["surge"]
100
+ return None
101
+
102
+ def _is_surge_logged_in(self, surge_cmd: list[str]) -> bool:
103
+ """Check if user is logged in to surge.sh via 'surge whoami'."""
104
+ try:
105
+ cmd = [*surge_cmd, "whoami"]
106
+ result = subprocess.run(
107
+ cmd,
108
+ capture_output=True,
109
+ text=True,
110
+ timeout=30,
111
+ )
112
+ # If logged in, whoami returns 0 and prints the email
113
+ # If not logged in, it returns non-zero or prints "Not Authenticated"
114
+ if result.returncode != 0:
115
+ return False
116
+ output = (result.stdout + result.stderr).lower()
117
+ if "not authenticated" in output or "not logged in" in output:
118
+ return False
119
+ return bool(result.stdout.strip())
120
+ except (subprocess.TimeoutExpired, OSError):
121
+ return False
122
+
123
+ def _generate_domain(self) -> str:
124
+ """Generate a random subdomain for surge.sh."""
125
+ random_suffix = secrets.token_hex(4)
126
+ return f"klaude-session-{random_suffix}.surge.sh"
127
+
128
+ def _deploy_to_surge(self, surge_cmd: list[str], html_content: str, domain: str) -> str:
129
+ """Deploy HTML content to surge.sh and return the URL."""
130
+ with tempfile.TemporaryDirectory() as tmpdir:
131
+ html_path = Path(tmpdir) / "index.html"
132
+ html_path.write_text(html_content, encoding="utf-8")
133
+
134
+ # Run surge with --domain flag
135
+ cmd = [*surge_cmd, tmpdir, "--domain", domain]
136
+ result = subprocess.run(
137
+ cmd,
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=60,
141
+ )
142
+
143
+ if result.returncode != 0:
144
+ error_msg = result.stderr or result.stdout or "Unknown error"
145
+ raise RuntimeError(f"Surge deployment failed: {error_msg}")
146
+
147
+ return f"https://{domain}"
148
+
149
+ def _build_html(self, agent: Agent) -> str:
150
+ profile = agent.profile
151
+ system_prompt = (profile.system_prompt if profile else "") or ""
152
+ tools = profile.tools if profile else []
153
+ model_name = profile.llm_client.model_name if profile else "unknown"
154
+ return build_export_html(agent.session, system_prompt, tools, model_name)
@@ -0,0 +1,267 @@
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
+
8
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
9
+ from klaude_code.protocol import commands, events, model
10
+ from klaude_code.ui.modes.repl.clipboard import copy_to_clipboard
11
+ from klaude_code.ui.terminal.selector import SelectItem, select_one
12
+
13
+ FORK_SELECT_STYLE = Style(
14
+ [
15
+ ("msg", ""),
16
+ ("meta", "fg:ansibrightblack"),
17
+ ("separator", "fg:ansibrightblack"),
18
+ ("assistant", "fg:ansiblue"),
19
+ ("pointer", "bold fg:ansigreen"),
20
+ ("search_prefix", "fg:ansibrightblack"),
21
+ ("search_success", "noinherit fg:ansigreen"),
22
+ ("search_none", "noinherit fg:ansired"),
23
+ ("question", "bold"),
24
+ ("text", ""),
25
+ ]
26
+ )
27
+
28
+
29
+ @dataclass
30
+ class ForkPoint:
31
+ """A fork point in conversation history."""
32
+
33
+ history_index: int | None # None means fork entire conversation
34
+ user_message: str
35
+ tool_call_stats: dict[str, int] # tool_name -> count
36
+ last_assistant_summary: str
37
+
38
+
39
+ def _truncate(text: str, max_len: int = 60) -> str:
40
+ """Truncate text to max_len, adding ellipsis if needed."""
41
+ text = text.replace("\n", " ").strip()
42
+ if len(text) <= max_len:
43
+ return text
44
+ return text[: max_len - 3] + "..."
45
+
46
+
47
+ def _build_fork_points(conversation_history: list[model.ConversationItem]) -> list[ForkPoint]:
48
+ """Build list of fork points from conversation history.
49
+
50
+ Fork points are:
51
+ - Each UserMessageItem position (for UI display, including first which would be empty session)
52
+ - The end of the conversation (fork entire conversation)
53
+ """
54
+ fork_points: list[ForkPoint] = []
55
+ user_indices: list[int] = []
56
+
57
+ for i, item in enumerate(conversation_history):
58
+ if isinstance(item, model.UserMessageItem):
59
+ user_indices.append(i)
60
+
61
+ # For each UserMessageItem, create a fork point at that position
62
+ for i, user_idx in enumerate(user_indices):
63
+ user_item = conversation_history[user_idx]
64
+ assert isinstance(user_item, model.UserMessageItem)
65
+
66
+ # Find the end of this "task" (next UserMessageItem or end of history)
67
+ next_user_idx = user_indices[i + 1] if i + 1 < len(user_indices) else len(conversation_history)
68
+
69
+ # Count tool calls by name and find last assistant message in this segment
70
+ tool_stats: dict[str, int] = {}
71
+ last_assistant_content = ""
72
+ for j in range(user_idx, next_user_idx):
73
+ item = conversation_history[j]
74
+ if isinstance(item, model.ToolCallItem):
75
+ tool_stats[item.name] = tool_stats.get(item.name, 0) + 1
76
+ elif isinstance(item, model.AssistantMessageItem) and item.content:
77
+ last_assistant_content = item.content
78
+
79
+ fork_points.append(
80
+ ForkPoint(
81
+ history_index=user_idx,
82
+ user_message=user_item.content or "(empty)",
83
+ tool_call_stats=tool_stats,
84
+ last_assistant_summary=_truncate(last_assistant_content) if last_assistant_content else "",
85
+ )
86
+ )
87
+
88
+ # Add the "fork entire conversation" option at the end
89
+ if user_indices:
90
+ fork_points.append(
91
+ ForkPoint(
92
+ history_index=None, # None means fork entire conversation
93
+ user_message="", # No specific message, this represents the end
94
+ tool_call_stats={},
95
+ last_assistant_summary="",
96
+ )
97
+ )
98
+
99
+ return fork_points
100
+
101
+
102
+ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int | None]]:
103
+ """Build SelectItem list from fork points."""
104
+ items: list[SelectItem[int | None]] = []
105
+
106
+ for i, fp in enumerate(fork_points):
107
+ is_first = i == 0
108
+ is_last = i == len(fork_points) - 1
109
+
110
+ # Build the title
111
+ title_parts: list[tuple[str, str]] = []
112
+
113
+ # First line: separator (with special markers for first/last fork points)
114
+ if is_first and not is_last:
115
+ title_parts.append(("class:separator", "----- fork from here (empty session) -----\n\n"))
116
+ elif is_last:
117
+ title_parts.append(("class:separator", "----- fork from here (entire session) -----\n\n"))
118
+ else:
119
+ title_parts.append(("class:separator", "----- fork from here -----\n\n"))
120
+
121
+ if not is_last:
122
+ # Second line: user message
123
+ title_parts.append(("class:msg", f"user: {_truncate(fp.user_message, 70)}\n"))
124
+
125
+ # Third line: tool call stats (if any)
126
+ if fp.tool_call_stats:
127
+ tool_parts = [f"{name} × {count}" for name, count in fp.tool_call_stats.items()]
128
+ title_parts.append(("class:meta", f"tools: {', '.join(tool_parts)}\n"))
129
+
130
+ # Fourth line: last assistant message summary (if any)
131
+ if fp.last_assistant_summary:
132
+ title_parts.append(("class:assistant", f"ai: {fp.last_assistant_summary}\n"))
133
+
134
+ # Empty line at the end
135
+ title_parts.append(("class:text", "\n"))
136
+
137
+ items.append(
138
+ SelectItem(
139
+ title=title_parts,
140
+ value=fp.history_index,
141
+ search_text=fp.user_message if not is_last else "fork entire conversation",
142
+ )
143
+ )
144
+
145
+ return items
146
+
147
+
148
+ def _select_fork_point_sync(fork_points: list[ForkPoint]) -> int | None | Literal["cancelled"]:
149
+ """Interactive fork point selection (sync version for asyncio.to_thread).
150
+
151
+ Returns:
152
+ - int: history index to fork at (exclusive)
153
+ - None: fork entire conversation
154
+ - "cancelled": user cancelled selection
155
+ """
156
+ items = _build_select_items(fork_points)
157
+ if not items:
158
+ return None
159
+
160
+ # Default to the last option (fork entire conversation)
161
+ last_value = items[-1].value
162
+
163
+ # Non-interactive environments default to forking entire conversation
164
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
165
+ return last_value
166
+
167
+ try:
168
+ result = select_one(
169
+ message="Select fork point (messages before this point will be included):",
170
+ items=items,
171
+ pointer="→",
172
+ style=FORK_SELECT_STYLE,
173
+ initial_value=last_value,
174
+ highlight_pointed_item=False,
175
+ )
176
+ if result is None:
177
+ return "cancelled"
178
+ return result
179
+ except KeyboardInterrupt:
180
+ return "cancelled"
181
+
182
+
183
+ class ForkSessionCommand(CommandABC):
184
+ """Fork current session to a new session id and show a resume command."""
185
+
186
+ @property
187
+ def name(self) -> commands.CommandName:
188
+ return commands.CommandName.FORK_SESSION
189
+
190
+ @property
191
+ def summary(self) -> str:
192
+ return "Fork the current session and show a resume-by-id command"
193
+
194
+ @property
195
+ def is_interactive(self) -> bool:
196
+ return True
197
+
198
+ async def run(self, agent: Agent, user_input: model.UserInputPayload) -> CommandResult:
199
+ del user_input # unused
200
+
201
+ if agent.session.messages_count == 0:
202
+ event = events.DeveloperMessageEvent(
203
+ session_id=agent.session.id,
204
+ item=model.DeveloperMessageItem(
205
+ content="(no messages to fork)",
206
+ command_output=model.CommandOutput(command_name=self.name),
207
+ ),
208
+ )
209
+ return CommandResult(events=[event], persist_user_input=False, persist_events=False)
210
+
211
+ # Build fork points from conversation history
212
+ fork_points = _build_fork_points(agent.session.conversation_history)
213
+
214
+ if not fork_points:
215
+ # Only one user message, just fork entirely
216
+ new_session = agent.session.fork()
217
+ await new_session.wait_for_flush()
218
+
219
+ resume_cmd = f"klaude --resume-by-id {new_session.id}"
220
+ copy_to_clipboard(resume_cmd)
221
+
222
+ event = events.DeveloperMessageEvent(
223
+ session_id=agent.session.id,
224
+ item=model.DeveloperMessageItem(
225
+ content=f"Session forked successfully. New session id: {new_session.id}",
226
+ command_output=model.CommandOutput(
227
+ command_name=self.name,
228
+ ui_extra=model.SessionIdUIExtra(session_id=new_session.id),
229
+ ),
230
+ ),
231
+ )
232
+ return CommandResult(events=[event], persist_user_input=False, persist_events=False)
233
+
234
+ # Interactive selection
235
+ selected = await asyncio.to_thread(_select_fork_point_sync, fork_points)
236
+
237
+ if selected == "cancelled":
238
+ event = events.DeveloperMessageEvent(
239
+ session_id=agent.session.id,
240
+ item=model.DeveloperMessageItem(
241
+ content="(fork cancelled)",
242
+ command_output=model.CommandOutput(command_name=self.name),
243
+ ),
244
+ )
245
+ return CommandResult(events=[event], persist_user_input=False, persist_events=False)
246
+
247
+ # Perform the fork
248
+ new_session = agent.session.fork(until_index=selected)
249
+ await new_session.wait_for_flush()
250
+
251
+ # Build result message
252
+ fork_description = "entire conversation" if selected is None else f"up to message index {selected}"
253
+
254
+ resume_cmd = f"klaude --resume-by-id {new_session.id}"
255
+ copy_to_clipboard(resume_cmd)
256
+
257
+ event = events.DeveloperMessageEvent(
258
+ session_id=agent.session.id,
259
+ item=model.DeveloperMessageItem(
260
+ content=f"Session forked ({fork_description}). New session id: {new_session.id}",
261
+ command_output=model.CommandOutput(
262
+ command_name=self.name,
263
+ ui_extra=model.SessionIdUIExtra(session_id=new_session.id),
264
+ ),
265
+ ),
266
+ )
267
+ return CommandResult(events=[event], persist_user_input=False, persist_events=False)
@@ -1,10 +1,7 @@
1
- from klaude_code.command.command_abc import CommandABC, CommandResult
2
- from klaude_code.command.registry import register_command
3
- from klaude_code.core.agent import Agent
1
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
4
2
  from klaude_code.protocol import commands, events, model
5
3
 
6
4
 
7
- @register_command
8
5
  class HelpCommand(CommandABC):
9
6
  """Display help information for all available slash commands."""
10
7
 
@@ -16,7 +13,8 @@ class HelpCommand(CommandABC):
16
13
  def summary(self) -> str:
17
14
  return "Show help and available commands"
18
15
 
19
- async def run(self, raw: str, agent: Agent) -> CommandResult:
16
+ async def run(self, agent: Agent, user_input: model.UserInputPayload) -> CommandResult:
17
+ del user_input # unused
20
18
  lines: list[str] = [
21
19
  """
22
20
  Usage:
@@ -24,8 +22,9 @@ Usage:
24
22
  [b]esc[/b] to interrupt agent task
25
23
  [b]shift-enter[/b] or [b]ctrl-j[/b] for new line
26
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
27
27
  [b]--continue[/b] or [b]--resume[/b] to continue an old session
28
- [b]--select-model[/b] to switch model
29
28
 
30
29
  Available slash commands:"""
31
30
  ]
@@ -37,8 +36,8 @@ Available slash commands:"""
37
36
 
38
37
  if commands:
39
38
  for cmd_name, cmd_obj in sorted(commands.items()):
40
- additional_instructions = " \\[additional instructions]" if cmd_obj.support_addition_params else ""
41
- lines.append(f" [b]/{cmd_name}[/b]{additional_instructions} — {cmd_obj.summary}")
39
+ placeholder = f" \\[{cmd_obj.placeholder}]" if cmd_obj.support_addition_params else ""
40
+ lines.append(f" [b]/{cmd_name}[/b]{placeholder} — {cmd_obj.summary}")
42
41
 
43
42
  event = events.DeveloperMessageEvent(
44
43
  session_id=agent.session.id,
@@ -1,13 +1,47 @@
1
1
  import asyncio
2
2
 
3
- from klaude_code.command.command_abc import CommandABC, CommandResult, InputAction
4
- from klaude_code.command.registry import register_command
5
- from klaude_code.config import select_model_from_config
6
- from klaude_code.core.agent import Agent
7
- from klaude_code.protocol import commands, events, model
3
+ from prompt_toolkit.styles import Style
4
+
5
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
6
+ from klaude_code.command.model_select import select_model_interactive
7
+ from klaude_code.protocol import commands, events, model, op
8
+ from klaude_code.ui.terminal.selector import SelectItem, select_one
9
+
10
+ SELECT_STYLE = Style(
11
+ [
12
+ ("instruction", "ansibrightblack"),
13
+ ("pointer", "ansigreen"),
14
+ ("highlighted", "ansigreen"),
15
+ ("text", "ansibrightblack"),
16
+ ("question", "bold"),
17
+ ]
18
+ )
19
+
20
+
21
+ def _confirm_change_default_model_sync(selected_model: str) -> bool:
22
+ items: list[SelectItem[bool]] = [
23
+ SelectItem(title=[("class:text", "No (session only)\n")], value=False, search_text="No"),
24
+ SelectItem(
25
+ title=[("class:text", "Yes (save as default main_model in ~/.klaude/klaude-config.yaml)\n")],
26
+ value=True,
27
+ search_text="Yes",
28
+ ),
29
+ ]
30
+
31
+ try:
32
+ result = select_one(
33
+ message=f"Save '{selected_model}' as default model?",
34
+ items=items,
35
+ pointer="→",
36
+ style=SELECT_STYLE,
37
+ use_search_filter=False,
38
+ )
39
+ except KeyboardInterrupt:
40
+ return False
41
+
42
+ return bool(result)
8
43
 
9
44
 
10
- @register_command
11
45
  class ModelCommand(CommandABC):
12
46
  """Display or change the model configuration."""
13
47
 
@@ -23,8 +57,16 @@ class ModelCommand(CommandABC):
23
57
  def is_interactive(self) -> bool:
24
58
  return True
25
59
 
26
- async def run(self, raw: str, agent: Agent) -> CommandResult:
27
- selected_model = await asyncio.to_thread(select_model_from_config, preferred=raw)
60
+ @property
61
+ def support_addition_params(self) -> bool:
62
+ return True
63
+
64
+ @property
65
+ def placeholder(self) -> str:
66
+ return "model name"
67
+
68
+ async def run(self, agent: Agent, user_input: model.UserInputPayload) -> CommandResult:
69
+ selected_model = await asyncio.to_thread(select_model_interactive, preferred=user_input.text)
28
70
 
29
71
  current_model = agent.profile.llm_client.model_name if agent.profile else None
30
72
  if selected_model is None or selected_model == current_model:
@@ -39,5 +81,13 @@ class ModelCommand(CommandABC):
39
81
  )
40
82
  ]
41
83
  )
42
-
43
- return CommandResult(actions=[InputAction.change_model(selected_model)])
84
+ save_as_default = await asyncio.to_thread(_confirm_change_default_model_sync, selected_model)
85
+ return CommandResult(
86
+ operations=[
87
+ op.ChangeModelOperation(
88
+ session_id=agent.session.id,
89
+ model_name=selected_model,
90
+ save_as_default=save_as_default,
91
+ )
92
+ ]
93
+ )