klaude-code 1.2.17__py3-none-any.whl → 1.2.19__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 (70) hide show
  1. klaude_code/cli/config_cmd.py +1 -1
  2. klaude_code/cli/debug.py +1 -1
  3. klaude_code/cli/main.py +45 -31
  4. klaude_code/cli/runtime.py +49 -13
  5. klaude_code/{version.py → cli/self_update.py} +110 -2
  6. klaude_code/command/__init__.py +4 -1
  7. klaude_code/command/clear_cmd.py +2 -7
  8. klaude_code/command/command_abc.py +33 -5
  9. klaude_code/command/debug_cmd.py +79 -0
  10. klaude_code/command/diff_cmd.py +2 -6
  11. klaude_code/command/export_cmd.py +7 -7
  12. klaude_code/command/export_online_cmd.py +9 -8
  13. klaude_code/command/help_cmd.py +4 -9
  14. klaude_code/command/model_cmd.py +10 -6
  15. klaude_code/command/prompt_command.py +2 -6
  16. klaude_code/command/refresh_cmd.py +2 -7
  17. klaude_code/command/registry.py +69 -26
  18. klaude_code/command/release_notes_cmd.py +2 -6
  19. klaude_code/command/status_cmd.py +2 -7
  20. klaude_code/command/terminal_setup_cmd.py +2 -6
  21. klaude_code/command/thinking_cmd.py +16 -10
  22. klaude_code/config/select_model.py +81 -5
  23. klaude_code/const/__init__.py +1 -1
  24. klaude_code/core/executor.py +257 -110
  25. klaude_code/core/manager/__init__.py +2 -4
  26. klaude_code/core/prompts/prompt-claude-code.md +1 -1
  27. klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
  28. klaude_code/core/prompts/prompt-sub-agent-web.md +8 -5
  29. klaude_code/core/reminders.py +9 -35
  30. klaude_code/core/task.py +9 -7
  31. klaude_code/core/tool/file/read_tool.md +1 -1
  32. klaude_code/core/tool/file/read_tool.py +41 -12
  33. klaude_code/core/tool/memory/skill_loader.py +12 -10
  34. klaude_code/core/tool/shell/bash_tool.py +22 -2
  35. klaude_code/core/tool/tool_registry.py +1 -1
  36. klaude_code/core/tool/tool_runner.py +26 -23
  37. klaude_code/core/tool/truncation.py +23 -9
  38. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  39. klaude_code/core/tool/web/web_fetch_tool.py +36 -1
  40. klaude_code/core/turn.py +28 -0
  41. klaude_code/llm/anthropic/client.py +25 -9
  42. klaude_code/llm/openai_compatible/client.py +5 -2
  43. klaude_code/llm/openrouter/client.py +7 -3
  44. klaude_code/llm/responses/client.py +6 -1
  45. klaude_code/protocol/commands.py +1 -0
  46. klaude_code/protocol/sub_agent/web.py +3 -2
  47. klaude_code/session/session.py +35 -15
  48. klaude_code/session/templates/export_session.html +45 -32
  49. klaude_code/trace/__init__.py +20 -2
  50. klaude_code/ui/modes/repl/completers.py +231 -73
  51. klaude_code/ui/modes/repl/event_handler.py +8 -6
  52. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  53. klaude_code/ui/modes/repl/renderer.py +2 -2
  54. klaude_code/ui/renderers/common.py +54 -0
  55. klaude_code/ui/renderers/developer.py +2 -3
  56. klaude_code/ui/renderers/errors.py +1 -1
  57. klaude_code/ui/renderers/metadata.py +12 -5
  58. klaude_code/ui/renderers/thinking.py +24 -8
  59. klaude_code/ui/renderers/tools.py +82 -14
  60. klaude_code/ui/rich/code_panel.py +112 -0
  61. klaude_code/ui/rich/markdown.py +3 -4
  62. klaude_code/ui/rich/status.py +0 -2
  63. klaude_code/ui/rich/theme.py +10 -1
  64. klaude_code/ui/utils/common.py +0 -18
  65. {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/METADATA +32 -7
  66. {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/RECORD +69 -68
  67. klaude_code/core/manager/agent_manager.py +0 -132
  68. /klaude_code/{config → cli}/list_model.py +0 -0
  69. {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/WHEEL +0 -0
  70. {klaude_code-1.2.17.dist-info → klaude_code-1.2.19.dist-info}/entry_points.txt +0 -0
@@ -5,15 +5,14 @@ import shutil
5
5
  import subprocess
6
6
  import tempfile
7
7
  from pathlib import Path
8
- from typing import TYPE_CHECKING
9
8
 
10
- from klaude_code.command.command_abc import CommandABC, CommandResult
9
+ from rich.console import Console
10
+ from rich.text import Text
11
+
12
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
11
13
  from klaude_code.protocol import commands, events, model
12
14
  from klaude_code.session.export import build_export_html
13
15
 
14
- if TYPE_CHECKING:
15
- from klaude_code.core.agent import Agent
16
-
17
16
 
18
17
  class ExportOnlineCommand(CommandABC):
19
18
  """Export and deploy the current session to surge.sh as a static webpage."""
@@ -60,9 +59,11 @@ class ExportOnlineCommand(CommandABC):
60
59
  return CommandResult(events=[event])
61
60
 
62
61
  try:
63
- html_doc = self._build_html(agent)
64
- domain = self._generate_domain()
65
- url = self._deploy_to_surge(surge_cmd, html_doc, domain)
62
+ console = Console()
63
+ with console.status(Text("Deploying to surge.sh...", style="dim"), spinner_style="dim"):
64
+ html_doc = self._build_html(agent)
65
+ domain = self._generate_domain()
66
+ url = self._deploy_to_surge(surge_cmd, html_doc, domain)
66
67
 
67
68
  event = events.DeveloperMessageEvent(
68
69
  session_id=agent.session.id,
@@ -1,11 +1,6 @@
1
- from typing import TYPE_CHECKING
2
-
3
- from klaude_code.command.command_abc import CommandABC, CommandResult
1
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
4
2
  from klaude_code.protocol import commands, events, model
5
3
 
6
- if TYPE_CHECKING:
7
- from klaude_code.core.agent import Agent
8
-
9
4
 
10
5
  class HelpCommand(CommandABC):
11
6
  """Display help information for all available slash commands."""
@@ -18,7 +13,7 @@ class HelpCommand(CommandABC):
18
13
  def summary(self) -> str:
19
14
  return "Show help and available commands"
20
15
 
21
- async def run(self, raw: str, agent: "Agent") -> CommandResult:
16
+ async def run(self, raw: str, agent: Agent) -> CommandResult:
22
17
  lines: list[str] = [
23
18
  """
24
19
  Usage:
@@ -39,8 +34,8 @@ Available slash commands:"""
39
34
 
40
35
  if commands:
41
36
  for cmd_name, cmd_obj in sorted(commands.items()):
42
- additional_instructions = " \\[additional instructions]" if cmd_obj.support_addition_params else ""
43
- lines.append(f" [b]/{cmd_name}[/b]{additional_instructions} — {cmd_obj.summary}")
37
+ placeholder = f" \\[{cmd_obj.placeholder}]" if cmd_obj.support_addition_params else ""
38
+ lines.append(f" [b]/{cmd_name}[/b]{placeholder} — {cmd_obj.summary}")
44
39
 
45
40
  event = events.DeveloperMessageEvent(
46
41
  session_id=agent.session.id,
@@ -1,13 +1,9 @@
1
1
  import asyncio
2
- from typing import TYPE_CHECKING
3
2
 
4
- from klaude_code.command.command_abc import CommandABC, CommandResult, InputAction
3
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult, InputAction
5
4
  from klaude_code.config.select_model import select_model_from_config
6
5
  from klaude_code.protocol import commands, events, model
7
6
 
8
- if TYPE_CHECKING:
9
- from klaude_code.core.agent import Agent
10
-
11
7
 
12
8
  class ModelCommand(CommandABC):
13
9
  """Display or change the model configuration."""
@@ -24,7 +20,15 @@ class ModelCommand(CommandABC):
24
20
  def is_interactive(self) -> bool:
25
21
  return True
26
22
 
27
- async def run(self, raw: str, agent: "Agent") -> CommandResult:
23
+ @property
24
+ def support_addition_params(self) -> bool:
25
+ return True
26
+
27
+ @property
28
+ def placeholder(self) -> str:
29
+ return "model name"
30
+
31
+ async def run(self, raw: str, agent: Agent) -> CommandResult:
28
32
  selected_model = await asyncio.to_thread(select_model_from_config, preferred=raw)
29
33
 
30
34
  current_model = agent.profile.llm_client.model_name if agent.profile else None
@@ -1,15 +1,11 @@
1
1
  from importlib.resources import files
2
- from typing import TYPE_CHECKING
3
2
 
4
3
  import yaml
5
4
 
6
- from klaude_code.command.command_abc import CommandABC, CommandResult, InputAction
5
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult, InputAction
7
6
  from klaude_code.protocol import commands
8
7
  from klaude_code.trace import log_debug
9
8
 
10
- if TYPE_CHECKING:
11
- from klaude_code.core.agent import Agent
12
-
13
9
 
14
10
  class PromptCommand(CommandABC):
15
11
  """Command that loads a prompt from a markdown file."""
@@ -59,7 +55,7 @@ class PromptCommand(CommandABC):
59
55
  def support_addition_params(self) -> bool:
60
56
  return True
61
57
 
62
- async def run(self, raw: str, agent: "Agent") -> CommandResult:
58
+ async def run(self, raw: str, agent: Agent) -> CommandResult:
63
59
  self._ensure_loaded()
64
60
  template_content = self._content or ""
65
61
  user_input = raw.strip() or "<none>"
@@ -1,11 +1,6 @@
1
- from typing import TYPE_CHECKING
2
-
3
- from klaude_code.command.command_abc import CommandABC, CommandResult
1
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
4
2
  from klaude_code.protocol import commands, events
5
3
 
6
- if TYPE_CHECKING:
7
- from klaude_code.core.agent import Agent
8
-
9
4
 
10
5
  class RefreshTerminalCommand(CommandABC):
11
6
  """Refresh terminal display"""
@@ -22,7 +17,7 @@ class RefreshTerminalCommand(CommandABC):
22
17
  def is_interactive(self) -> bool:
23
18
  return True
24
19
 
25
- async def run(self, raw: str, agent: "Agent") -> CommandResult:
20
+ async def run(self, raw: str, agent: Agent) -> CommandResult:
26
21
  import os
27
22
 
28
23
  os.system("cls" if os.name == "nt" else "clear")
@@ -1,19 +1,79 @@
1
1
  from importlib.resources import files
2
2
  from typing import TYPE_CHECKING
3
3
 
4
- from klaude_code.command.command_abc import CommandResult, InputAction
4
+ from klaude_code.command.command_abc import Agent, CommandResult, InputAction
5
5
  from klaude_code.command.prompt_command import PromptCommand
6
6
  from klaude_code.protocol import commands, events, model
7
7
  from klaude_code.trace import log_debug
8
8
 
9
9
  if TYPE_CHECKING:
10
- from klaude_code.core.agent import Agent
11
-
12
10
  from .command_abc import CommandABC
13
11
 
14
12
  _COMMANDS: dict[commands.CommandName | str, "CommandABC"] = {}
15
13
 
16
14
 
15
+ def _command_key_to_str(key: commands.CommandName | str) -> str:
16
+ if isinstance(key, commands.CommandName):
17
+ return key.value
18
+ return key
19
+
20
+
21
+ def _resolve_command_key(command_name_raw: str) -> commands.CommandName | str | None:
22
+ """Resolve raw command token to a registered command key.
23
+
24
+ Resolution order:
25
+ 1) Exact match
26
+ 2) Enum conversion (for standard commands)
27
+ 3) Prefix match (supports abbreviations like `exp` -> `export`)
28
+
29
+ Prefix match rules:
30
+ - If there's exactly one prefix match, use it.
31
+ - If multiple matches exist and one command name is a prefix of all others,
32
+ treat it as the base command and use it (e.g. `export` over `export-online`).
33
+ - Otherwise, consider it ambiguous and return None.
34
+ """
35
+
36
+ if not command_name_raw:
37
+ return None
38
+
39
+ # Exact string match (works for both Enum and str keys because CommandName is a str Enum)
40
+ if command_name_raw in _COMMANDS:
41
+ return command_name_raw
42
+
43
+ # Enum conversion for standard commands
44
+ try:
45
+ enum_key = commands.CommandName(command_name_raw)
46
+ except ValueError:
47
+ enum_key = None
48
+ else:
49
+ if enum_key in _COMMANDS:
50
+ return enum_key
51
+
52
+ # Prefix match across all registered names
53
+ matching_keys: list[commands.CommandName | str] = []
54
+ matching_names: list[str] = []
55
+ for key in _COMMANDS:
56
+ key_str = _command_key_to_str(key)
57
+ if key_str.startswith(command_name_raw):
58
+ matching_keys.append(key)
59
+ matching_names.append(key_str)
60
+
61
+ if len(matching_keys) == 1:
62
+ return matching_keys[0]
63
+
64
+ if len(matching_keys) > 1:
65
+ # Prefer the base command when one is a prefix of all other matches.
66
+ base_matches = [
67
+ key
68
+ for key, key_name in zip(matching_keys, matching_names, strict=True)
69
+ if all(other.startswith(key_name) for other in matching_names if other != key_name)
70
+ ]
71
+ if len(base_matches) == 1:
72
+ return base_matches[0]
73
+
74
+ return None
75
+
76
+
17
77
  def register(cmd: "CommandABC") -> None:
18
78
  """Register a command instance. Order of registration determines display order."""
19
79
  _COMMANDS[cmd.name] = cmd
@@ -47,10 +107,10 @@ def get_commands() -> dict[commands.CommandName | str, "CommandABC"]:
47
107
 
48
108
  def is_slash_command_name(name: str) -> bool:
49
109
  _ensure_commands_loaded()
50
- return name in _COMMANDS
110
+ return _resolve_command_key(name) is not None
51
111
 
52
112
 
53
- async def dispatch_command(raw: str, agent: "Agent") -> CommandResult:
113
+ async def dispatch_command(raw: str, agent: Agent) -> CommandResult:
54
114
  _ensure_commands_loaded()
55
115
  # Detect command name
56
116
  if not raw.startswith("/"):
@@ -60,21 +120,7 @@ async def dispatch_command(raw: str, agent: "Agent") -> CommandResult:
60
120
  command_name_raw = splits[0][1:]
61
121
  rest = " ".join(splits[1:]) if len(splits) > 1 else ""
62
122
 
63
- # Try to match against registered commands (both Enum and string keys)
64
- command_key = None
65
-
66
- # First try exact string match
67
- if command_name_raw in _COMMANDS:
68
- command_key = command_name_raw
69
- else:
70
- # Then try Enum conversion for standard commands
71
- try:
72
- enum_key = commands.CommandName(command_name_raw)
73
- if enum_key in _COMMANDS:
74
- command_key = enum_key
75
- except ValueError:
76
- pass
77
-
123
+ command_key = _resolve_command_key(command_name_raw)
78
124
  if command_key is None:
79
125
  return CommandResult(actions=[InputAction.run_agent(raw)])
80
126
 
@@ -108,11 +154,8 @@ def has_interactive_command(raw: str) -> bool:
108
154
  return False
109
155
  splits = raw.split(" ", maxsplit=1)
110
156
  command_name_raw = splits[0][1:]
111
- try:
112
- command_name = commands.CommandName(command_name_raw)
113
- except ValueError:
114
- return False
115
- if command_name not in _COMMANDS:
157
+ command_key = _resolve_command_key(command_name_raw)
158
+ if command_key is None:
116
159
  return False
117
- command = _COMMANDS[command_name]
160
+ command = _COMMANDS[command_key]
118
161
  return command.is_interactive
@@ -1,12 +1,8 @@
1
1
  from pathlib import Path
2
- from typing import TYPE_CHECKING
3
2
 
4
- from klaude_code.command.command_abc import CommandABC, CommandResult
3
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
5
4
  from klaude_code.protocol import commands, events, model
6
5
 
7
- if TYPE_CHECKING:
8
- from klaude_code.core.agent import Agent
9
-
10
6
 
11
7
  def _read_changelog() -> str:
12
8
  """Read CHANGELOG.md from project root."""
@@ -72,7 +68,7 @@ class ReleaseNotesCommand(CommandABC):
72
68
  def summary(self) -> str:
73
69
  return "Show the latest release notes"
74
70
 
75
- async def run(self, raw: str, agent: "Agent") -> CommandResult:
71
+ async def run(self, raw: str, agent: Agent) -> CommandResult:
76
72
  changelog = _read_changelog()
77
73
  content = _extract_releases(changelog, count=10)
78
74
 
@@ -1,12 +1,7 @@
1
- from typing import TYPE_CHECKING
2
-
3
- from klaude_code.command.command_abc import CommandABC, CommandResult
1
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
4
2
  from klaude_code.protocol import commands, events, model
5
3
  from klaude_code.session.session import Session
6
4
 
7
- if TYPE_CHECKING:
8
- from klaude_code.core.agent import Agent
9
-
10
5
 
11
6
  class AggregatedUsage(model.BaseModel):
12
7
  """Aggregated usage statistics including per-model breakdown."""
@@ -137,7 +132,7 @@ class StatusCommand(CommandABC):
137
132
  def summary(self) -> str:
138
133
  return "Show session usage statistics"
139
134
 
140
- async def run(self, raw: str, agent: "Agent") -> CommandResult:
135
+ async def run(self, raw: str, agent: Agent) -> CommandResult:
141
136
  session = agent.session
142
137
  aggregated = accumulate_session_usage(session)
143
138
 
@@ -1,14 +1,10 @@
1
1
  import os
2
2
  import subprocess
3
3
  from pathlib import Path
4
- from typing import TYPE_CHECKING
5
4
 
6
- from klaude_code.command.command_abc import CommandABC, CommandResult
5
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
7
6
  from klaude_code.protocol import commands, events, model
8
7
 
9
- if TYPE_CHECKING:
10
- from klaude_code.core.agent import Agent
11
-
12
8
 
13
9
  class TerminalSetupCommand(CommandABC):
14
10
  """Setup shift+enter newline functionality in terminal"""
@@ -25,7 +21,7 @@ class TerminalSetupCommand(CommandABC):
25
21
  def is_interactive(self) -> bool:
26
22
  return False
27
23
 
28
- async def run(self, raw: str, agent: "Agent") -> CommandResult:
24
+ async def run(self, raw: str, agent: Agent) -> CommandResult:
29
25
  term_program = os.environ.get("TERM_PROGRAM", "").lower()
30
26
 
31
27
  try:
@@ -1,18 +1,14 @@
1
1
  import asyncio
2
- from typing import TYPE_CHECKING
3
2
 
4
3
  import questionary
5
4
 
6
- from klaude_code.command.command_abc import CommandABC, CommandResult
5
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
7
6
  from klaude_code.protocol import commands, events, llm_param, model
8
7
 
9
- if TYPE_CHECKING:
10
- from klaude_code.core.agent import Agent
11
-
12
-
13
8
  # Thinking level options for different protocols
14
- RESPONSES_LEVELS = ["minimal", "low", "medium", "high"]
15
- RESPONSES_GPT51_LEVELS = ["none", "minimal", "low", "medium", "high"]
9
+ RESPONSES_LEVELS = ["low", "medium", "high"]
10
+ RESPONSES_GPT51_LEVELS = ["none", "low", "medium", "high"]
11
+ RESPONSES_GPT52_LEVELS = ["none", "low", "medium", "high", "xhigh"]
16
12
  RESPONSES_CODEX_MAX_LEVELS = ["medium", "high", "xhigh"]
17
13
 
18
14
  ANTHROPIC_LEVELS: list[tuple[str, int | None]] = [
@@ -35,7 +31,14 @@ def _is_gpt51_model(model_name: str | None) -> bool:
35
31
  """Check if the model is GPT-5.1."""
36
32
  if not model_name:
37
33
  return False
38
- return model_name.lower() in ["gpt5.1", "openai/gpt-5.1", "gpt-5.1-codex-2025-11-13"]
34
+ return model_name.lower() in ["gpt-5.1", "openai/gpt-5.1", "gpt-5.1-codex-2025-11-13"]
35
+
36
+
37
+ def _is_gpt52_model(model_name: str | None) -> bool:
38
+ """Check if the model is GPT-5.2."""
39
+ if not model_name:
40
+ return False
41
+ return model_name.lower() in ["gpt-5.2", "openai/gpt-5.2"]
39
42
 
40
43
 
41
44
  def _is_codex_max_model(model_name: str | None) -> bool:
@@ -49,6 +52,8 @@ def _get_levels_for_responses(model_name: str | None) -> list[str]:
49
52
  """Get thinking levels for responses protocol."""
50
53
  if _is_codex_max_model(model_name):
51
54
  return RESPONSES_CODEX_MAX_LEVELS
55
+ if _is_gpt52_model(model_name):
56
+ return RESPONSES_GPT52_LEVELS
52
57
  if _is_gpt51_model(model_name):
53
58
  return RESPONSES_GPT51_LEVELS
54
59
  return RESPONSES_LEVELS
@@ -164,7 +169,7 @@ class ThinkingCommand(CommandABC):
164
169
  def is_interactive(self) -> bool:
165
170
  return True
166
171
 
167
- async def run(self, raw: str, agent: "Agent") -> CommandResult:
172
+ async def run(self, raw: str, agent: Agent) -> CommandResult:
168
173
  if not agent.profile:
169
174
  return self._no_change_result(agent, "No profile configured")
170
175
 
@@ -201,6 +206,7 @@ class ThinkingCommand(CommandABC):
201
206
 
202
207
  # Apply the new thinking configuration
203
208
  config.thinking = new_thinking
209
+ agent.session.model_thinking = new_thinking
204
210
  new_status = _format_current_thinking(config)
205
211
 
206
212
  return CommandResult(
@@ -1,35 +1,111 @@
1
- from klaude_code.config.config import load_config
1
+ from klaude_code.config.config import ModelConfig, load_config
2
2
  from klaude_code.trace import log
3
3
 
4
4
 
5
+ def _normalize_model_key(value: str) -> str:
6
+ """Normalize a model identifier for loose matching.
7
+
8
+ This enables aliases like:
9
+ - gpt52 -> gpt-5.2
10
+ - gpt5.2 -> gpt-5.2
11
+
12
+ Strategy: case-fold + keep only alphanumeric characters.
13
+ """
14
+
15
+ return "".join(ch for ch in value.casefold() if ch.isalnum())
16
+
17
+
5
18
  def select_model_from_config(preferred: str | None = None) -> str | None:
6
19
  """
7
20
  Interactive single-choice model selector.
8
21
  for `--select-model`
22
+
23
+ If preferred is provided:
24
+ - Exact match: return immediately
25
+ - Single partial match (case-insensitive): return immediately
26
+ - Otherwise: fall through to interactive selection
9
27
  """
10
28
  config = load_config()
11
29
  assert config is not None
12
- models = sorted(config.model_list, key=lambda m: m.model_name.lower())
30
+ models: list[ModelConfig] = sorted(config.model_list, key=lambda m: m.model_name.lower())
13
31
 
14
32
  if not models:
15
33
  raise ValueError("No models configured. Please update your config.yaml")
16
34
 
17
35
  names: list[str] = [m.model_name for m in models]
18
36
 
37
+ # Try to match preferred model name
38
+ filtered_models = models
39
+ if preferred and preferred.strip():
40
+ preferred = preferred.strip()
41
+ # Exact match
42
+ if preferred in names:
43
+ return preferred
44
+
45
+ preferred_lower = preferred.lower()
46
+ # Case-insensitive exact match (model_name or model_params.model)
47
+ exact_ci_matches = [
48
+ m
49
+ for m in models
50
+ if preferred_lower == m.model_name.lower() or preferred_lower == (m.model_params.model or "").lower()
51
+ ]
52
+ if len(exact_ci_matches) == 1:
53
+ return exact_ci_matches[0].model_name
54
+
55
+ # Normalized matching (e.g. gpt52 == gpt-5.2, gpt52 in gpt-5.2-2025-...)
56
+ preferred_norm = _normalize_model_key(preferred)
57
+ normalized_matches: list[ModelConfig] = []
58
+ if preferred_norm:
59
+ normalized_matches = [
60
+ m
61
+ for m in models
62
+ if preferred_norm == _normalize_model_key(m.model_name)
63
+ or preferred_norm == _normalize_model_key(m.model_params.model or "")
64
+ ]
65
+ if len(normalized_matches) == 1:
66
+ return normalized_matches[0].model_name
67
+
68
+ if not normalized_matches and len(preferred_norm) >= 4:
69
+ normalized_matches = [
70
+ m
71
+ for m in models
72
+ if preferred_norm in _normalize_model_key(m.model_name)
73
+ or preferred_norm in _normalize_model_key(m.model_params.model or "")
74
+ ]
75
+ if len(normalized_matches) == 1:
76
+ return normalized_matches[0].model_name
77
+
78
+ # Partial match (case-insensitive) on model_name or model_params.model.
79
+ # If normalized matching found candidates (even if multiple), prefer those as the filter set.
80
+ matches = normalized_matches or [
81
+ m
82
+ for m in models
83
+ if preferred_lower in m.model_name.lower() or preferred_lower in (m.model_params.model or "").lower()
84
+ ]
85
+ if len(matches) == 1:
86
+ return matches[0].model_name
87
+ if matches:
88
+ # Multiple matches: filter the list for interactive selection
89
+ filtered_models = matches
90
+ else:
91
+ # No matches: show all models without filter hint
92
+ preferred = None
93
+
19
94
  try:
20
95
  import questionary
21
96
 
22
97
  choices: list[questionary.Choice] = []
23
98
 
24
- max_model_name_length = max(len(m.model_name) for m in models)
25
- for m in models:
99
+ max_model_name_length = max(len(m.model_name) for m in filtered_models)
100
+ for m in filtered_models:
26
101
  star = "★ " if m.model_name == config.main_model else " "
27
102
  title = f"{star}{m.model_name:<{max_model_name_length}} → {m.model_params.model or 'N/A'} @ {m.provider}"
28
103
  choices.append(questionary.Choice(title=title, value=m.model_name))
29
104
 
30
105
  try:
106
+ message = f"Select a model (filtered by '{preferred}'):" if preferred else "Select a model:"
31
107
  result = questionary.select(
32
- message="Select a model:",
108
+ message=message,
33
109
  choices=choices,
34
110
  pointer="→",
35
111
  instruction="↑↓ to move • Enter to select",
@@ -50,7 +50,7 @@ READ_CHAR_LIMIT_PER_LINE = 2000
50
50
  READ_GLOBAL_LINE_CAP = 2000
51
51
 
52
52
  # Maximum total characters to read
53
- READ_MAX_CHARS = 60000
53
+ READ_MAX_CHARS = 50000
54
54
 
55
55
  # Maximum file size in KB for text files
56
56
  READ_MAX_KB = 256