klaude-code 2.1.1__py3-none-any.whl → 2.3.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 (72) hide show
  1. klaude_code/app/__init__.py +1 -2
  2. klaude_code/app/runtime.py +13 -41
  3. klaude_code/cli/list_model.py +27 -10
  4. klaude_code/cli/main.py +42 -159
  5. klaude_code/config/assets/builtin_config.yaml +36 -14
  6. klaude_code/config/config.py +144 -7
  7. klaude_code/config/select_model.py +38 -13
  8. klaude_code/config/sub_agent_model_helper.py +217 -0
  9. klaude_code/const.py +2 -2
  10. klaude_code/core/agent_profile.py +71 -5
  11. klaude_code/core/executor.py +75 -0
  12. klaude_code/core/manager/llm_clients_builder.py +18 -12
  13. klaude_code/core/prompts/prompt-nano-banana.md +1 -0
  14. klaude_code/core/tool/shell/command_safety.py +4 -189
  15. klaude_code/core/tool/sub_agent_tool.py +2 -1
  16. klaude_code/core/turn.py +1 -1
  17. klaude_code/llm/anthropic/client.py +8 -5
  18. klaude_code/llm/anthropic/input.py +54 -29
  19. klaude_code/llm/google/client.py +2 -2
  20. klaude_code/llm/google/input.py +23 -2
  21. klaude_code/llm/openai_compatible/input.py +22 -13
  22. klaude_code/llm/openai_compatible/stream.py +1 -1
  23. klaude_code/llm/openrouter/input.py +37 -25
  24. klaude_code/llm/responses/client.py +1 -1
  25. klaude_code/llm/responses/input.py +96 -57
  26. klaude_code/protocol/commands.py +1 -2
  27. klaude_code/protocol/events/system.py +4 -0
  28. klaude_code/protocol/message.py +2 -2
  29. klaude_code/protocol/op.py +17 -0
  30. klaude_code/protocol/op_handler.py +5 -0
  31. klaude_code/protocol/sub_agent/AGENTS.md +28 -0
  32. klaude_code/protocol/sub_agent/__init__.py +10 -14
  33. klaude_code/protocol/sub_agent/image_gen.py +2 -1
  34. klaude_code/session/codec.py +2 -6
  35. klaude_code/session/session.py +9 -1
  36. klaude_code/skill/assets/create-plan/SKILL.md +3 -5
  37. klaude_code/tui/command/__init__.py +7 -10
  38. klaude_code/tui/command/clear_cmd.py +1 -1
  39. klaude_code/tui/command/command_abc.py +1 -2
  40. klaude_code/tui/command/copy_cmd.py +1 -2
  41. klaude_code/tui/command/fork_session_cmd.py +4 -4
  42. klaude_code/tui/command/model_cmd.py +6 -43
  43. klaude_code/tui/command/model_select.py +75 -15
  44. klaude_code/tui/command/refresh_cmd.py +1 -2
  45. klaude_code/tui/command/resume_cmd.py +3 -4
  46. klaude_code/tui/command/status_cmd.py +1 -1
  47. klaude_code/tui/command/sub_agent_model_cmd.py +190 -0
  48. klaude_code/tui/components/bash_syntax.py +1 -1
  49. klaude_code/tui/components/common.py +1 -1
  50. klaude_code/tui/components/developer.py +10 -15
  51. klaude_code/tui/components/metadata.py +2 -64
  52. klaude_code/tui/components/rich/cjk_wrap.py +3 -2
  53. klaude_code/tui/components/rich/status.py +49 -3
  54. klaude_code/tui/components/rich/theme.py +4 -2
  55. klaude_code/tui/components/sub_agent.py +25 -46
  56. klaude_code/tui/components/user_input.py +9 -21
  57. klaude_code/tui/components/welcome.py +99 -0
  58. klaude_code/tui/input/prompt_toolkit.py +14 -1
  59. klaude_code/tui/renderer.py +2 -3
  60. klaude_code/tui/runner.py +2 -2
  61. klaude_code/tui/terminal/selector.py +8 -18
  62. klaude_code/ui/__init__.py +0 -24
  63. klaude_code/ui/common.py +3 -2
  64. klaude_code/ui/core/display.py +2 -2
  65. {klaude_code-2.1.1.dist-info → klaude_code-2.3.0.dist-info}/METADATA +16 -81
  66. {klaude_code-2.1.1.dist-info → klaude_code-2.3.0.dist-info}/RECORD +68 -67
  67. klaude_code/tui/command/help_cmd.py +0 -51
  68. klaude_code/tui/command/prompt-commit.md +0 -82
  69. klaude_code/tui/command/release_notes_cmd.py +0 -85
  70. klaude_code/ui/exec_mode.py +0 -60
  71. {klaude_code-2.1.1.dist-info → klaude_code-2.3.0.dist-info}/WHEEL +0 -0
  72. {klaude_code-2.1.1.dist-info → klaude_code-2.3.0.dist-info}/entry_points.txt +0 -0
@@ -140,6 +140,16 @@ class ModelEntry(BaseModel):
140
140
  provider: str
141
141
  model_params: llm_param.LLMConfigModelParameter
142
142
 
143
+ @property
144
+ def selector(self) -> str:
145
+ """Return a provider-qualified model selector.
146
+
147
+ This selector can be persisted in user config (e.g. ``sonnet@openrouter``)
148
+ and later resolved via :meth:`Config.get_model_config`.
149
+ """
150
+
151
+ return f"{self.model_name}@{self.provider}"
152
+
143
153
 
144
154
  class UserConfig(BaseModel):
145
155
  """User configuration (what gets saved to disk)."""
@@ -191,8 +201,103 @@ class Config(BaseModel):
191
201
  """Set the user config reference for saving."""
192
202
  object.__setattr__(self, "_user_config", user_config)
193
203
 
204
+ @classmethod
205
+ def _split_model_selector(cls, model_selector: str) -> tuple[str, str | None]:
206
+ """Split a model selector into (model_name, provider_name).
207
+
208
+ Supported forms:
209
+ - ``sonnet``: unqualified; caller should pick the first matching provider.
210
+ - ``sonnet@openrouter``: provider-qualified.
211
+
212
+ Note: the provider segment is normalized for backwards compatibility.
213
+ """
214
+
215
+ trimmed = model_selector.strip()
216
+ if "@" not in trimmed:
217
+ return trimmed, None
218
+
219
+ base, provider = trimmed.rsplit("@", 1)
220
+ base = base.strip()
221
+ provider = provider.strip()
222
+ if not base or not provider:
223
+ raise ValueError(f"Invalid model selector: {model_selector!r}")
224
+ return base, provider
225
+
226
+ def has_model_config_name(self, model_selector: str) -> bool:
227
+ """Return True if the selector points to a configured model.
228
+
229
+ This check is configuration-only: it does not require a valid API key or
230
+ OAuth login.
231
+ """
232
+
233
+ model_name, provider_name = self._split_model_selector(model_selector)
234
+ if provider_name is not None:
235
+ for provider in self.provider_list:
236
+ if provider.provider_name.casefold() != provider_name.casefold():
237
+ continue
238
+ return any(m.model_name == model_name for m in provider.model_list)
239
+ return False
240
+
241
+ return any(any(m.model_name == model_name for m in provider.model_list) for provider in self.provider_list)
242
+
243
+ def resolve_model_location(self, model_selector: str) -> tuple[str, str] | None:
244
+ """Resolve a selector to (model_name, provider_name), without auth checks.
245
+
246
+ - If the selector is provider-qualified, returns that provider.
247
+ - If unqualified, returns the first provider that defines the model.
248
+ """
249
+
250
+ model_name, provider_name = self._split_model_selector(model_selector)
251
+ if provider_name is not None:
252
+ for provider in self.provider_list:
253
+ if provider.provider_name.casefold() != provider_name.casefold():
254
+ continue
255
+ if any(m.model_name == model_name for m in provider.model_list):
256
+ return model_name, provider.provider_name
257
+ return None
258
+
259
+ for provider in self.provider_list:
260
+ if any(m.model_name == model_name for m in provider.model_list):
261
+ return model_name, provider.provider_name
262
+ return None
263
+
264
+ def resolve_model_location_prefer_available(self, model_selector: str) -> tuple[str, str] | None:
265
+ """Resolve a selector to (model_name, provider_name), preferring usable providers.
266
+
267
+ This uses the same availability logic as :meth:`get_model_config` (API-key
268
+ presence for non-OAuth protocols).
269
+ """
270
+
271
+ requested_model, requested_provider = self._split_model_selector(model_selector)
272
+
273
+ for provider in self.provider_list:
274
+ if requested_provider is not None and provider.provider_name.casefold() != requested_provider.casefold():
275
+ continue
276
+
277
+ api_key = provider.get_resolved_api_key()
278
+ if (
279
+ provider.protocol
280
+ not in {
281
+ llm_param.LLMClientProtocol.CODEX_OAUTH,
282
+ llm_param.LLMClientProtocol.CLAUDE_OAUTH,
283
+ llm_param.LLMClientProtocol.BEDROCK,
284
+ }
285
+ and not api_key
286
+ ):
287
+ continue
288
+
289
+ if any(m.model_name == requested_model for m in provider.model_list):
290
+ return requested_model, provider.provider_name
291
+
292
+ return None
293
+
194
294
  def get_model_config(self, model_name: str) -> llm_param.LLMConfigParameter:
295
+ requested_model, requested_provider = self._split_model_selector(model_name)
296
+
195
297
  for provider in self.provider_list:
298
+ if requested_provider is not None and provider.provider_name.casefold() != requested_provider.casefold():
299
+ continue
300
+
196
301
  # Resolve ${ENV_VAR} syntax for api_key
197
302
  api_key = provider.get_resolved_api_key()
198
303
 
@@ -206,15 +311,22 @@ class Config(BaseModel):
206
311
  }
207
312
  and not api_key
208
313
  ):
314
+ # When provider is explicitly requested, fail fast with a clearer error.
315
+ if requested_provider is not None:
316
+ raise ValueError(
317
+ f"Provider '{provider.provider_name}' is not available (missing API key) for: {model_name}"
318
+ )
209
319
  continue
320
+
210
321
  for model in provider.model_list:
211
- if model.model_name == model_name:
212
- provider_dump = provider.model_dump(exclude={"model_list"})
213
- provider_dump["api_key"] = api_key
214
- return llm_param.LLMConfigParameter(
215
- **provider_dump,
216
- **model.model_params.model_dump(),
217
- )
322
+ if model.model_name != requested_model:
323
+ continue
324
+ provider_dump = provider.model_dump(exclude={"model_list"})
325
+ provider_dump["api_key"] = api_key
326
+ return llm_param.LLMConfigParameter(
327
+ **provider_dump,
328
+ **model.model_params.model_dump(),
329
+ )
218
330
 
219
331
  raise ValueError(f"Unknown model: {model_name}")
220
332
 
@@ -235,6 +347,27 @@ class Config(BaseModel):
235
347
  for model in provider.model_list
236
348
  ]
237
349
 
350
+ def has_available_image_model(self) -> bool:
351
+ """Check if any image generation model is available."""
352
+ for entry in self.iter_model_entries(only_available=True):
353
+ if entry.model_params.modalities and "image" in entry.model_params.modalities:
354
+ return True
355
+ return False
356
+
357
+ def get_first_available_nano_banana_model(self) -> str | None:
358
+ """Get the first available nano-banana model, or None."""
359
+ for entry in self.iter_model_entries(only_available=True):
360
+ if "nano-banana" in entry.model_name:
361
+ return entry.model_name
362
+ return None
363
+
364
+ def get_first_available_image_model(self) -> str | None:
365
+ """Get the first available image generation model, or None."""
366
+ for entry in self.iter_model_entries(only_available=True):
367
+ if entry.model_params.modalities and "image" in entry.model_params.modalities:
368
+ return entry.model_name
369
+ return None
370
+
238
371
  async def save(self) -> None:
239
372
  """Save user config to file (excludes builtin providers).
240
373
 
@@ -418,6 +551,10 @@ def create_example_config() -> bool:
418
551
  header = "# Example configuration for klaude-code\n"
419
552
  header += "# Copy this file to klaude-config.yaml and modify as needed.\n"
420
553
  header += "# Run `klaude list` to see available models.\n"
554
+ header += "# Tip: you can pick a provider explicitly with `model@provider` (e.g. `sonnet@openrouter`).\n"
555
+ header += (
556
+ "# If you omit `@provider` (e.g. `sonnet`), klaude picks the first configured provider with credentials.\n"
557
+ )
421
558
  header += "#\n"
422
559
  header += "# Built-in providers (anthropic, openai, openrouter, deepseek) are available automatically.\n"
423
560
  header += "# Just set the corresponding API key environment variable to use them.\n\n"
@@ -50,7 +50,8 @@ def match_model_from_config(preferred: str | None = None) -> ModelMatchResult:
50
50
 
51
51
  # Only show models from providers with valid API keys
52
52
  models: list[ModelEntry] = sorted(
53
- config.iter_model_entries(only_available=True), key=lambda m: m.model_name.lower()
53
+ config.iter_model_entries(only_available=True),
54
+ key=lambda m: (m.model_name.lower(), m.provider.lower()),
54
55
  )
55
56
 
56
57
  if not models:
@@ -62,26 +63,42 @@ def match_model_from_config(preferred: str | None = None) -> ModelMatchResult:
62
63
  error_message="No models available",
63
64
  )
64
65
 
65
- names: list[str] = [m.model_name for m in models]
66
+ selectors: list[str] = [m.selector for m in models]
66
67
 
67
68
  # Try to match preferred model name
68
69
  filter_hint = preferred
69
70
  if preferred and preferred.strip():
70
71
  preferred = preferred.strip()
71
- # Exact match
72
- if preferred in names:
72
+
73
+ # Exact match on selector (e.g. sonnet@openrouter)
74
+ if preferred in selectors:
73
75
  return ModelMatchResult(matched_model=preferred, filtered_models=models, filter_hint=None)
74
76
 
77
+ # Exact match on base model name (e.g. sonnet)
78
+ exact_base_matches = [m for m in models if m.model_name == preferred]
79
+ if len(exact_base_matches) == 1:
80
+ return ModelMatchResult(
81
+ matched_model=exact_base_matches[0].selector,
82
+ filtered_models=models,
83
+ filter_hint=None,
84
+ )
85
+ if len(exact_base_matches) > 1:
86
+ return ModelMatchResult(matched_model=None, filtered_models=exact_base_matches, filter_hint=filter_hint)
87
+
75
88
  preferred_lower = preferred.lower()
76
- # Case-insensitive exact match (model_name or model_params.model)
89
+ # Case-insensitive exact match (selector/model_name/model_params.model)
77
90
  exact_ci_matches = [
78
91
  m
79
92
  for m in models
80
- if preferred_lower == m.model_name.lower() or preferred_lower == (m.model_params.model or "").lower()
93
+ if preferred_lower == m.selector.lower()
94
+ or preferred_lower == m.model_name.lower()
95
+ or preferred_lower == (m.model_params.model or "").lower()
81
96
  ]
82
97
  if len(exact_ci_matches) == 1:
83
98
  return ModelMatchResult(
84
- matched_model=exact_ci_matches[0].model_name, filtered_models=models, filter_hint=None
99
+ matched_model=exact_ci_matches[0].selector,
100
+ filtered_models=models,
101
+ filter_hint=None,
85
102
  )
86
103
 
87
104
  # Normalized matching (e.g. gpt52 == gpt-5.2, gpt52 in gpt-5.2-2025-...)
@@ -91,24 +108,30 @@ def match_model_from_config(preferred: str | None = None) -> ModelMatchResult:
91
108
  normalized_matches = [
92
109
  m
93
110
  for m in models
94
- if preferred_norm == _normalize_model_key(m.model_name)
111
+ if preferred_norm == _normalize_model_key(m.selector)
112
+ or preferred_norm == _normalize_model_key(m.model_name)
95
113
  or preferred_norm == _normalize_model_key(m.model_params.model or "")
96
114
  ]
97
115
  if len(normalized_matches) == 1:
98
116
  return ModelMatchResult(
99
- matched_model=normalized_matches[0].model_name, filtered_models=models, filter_hint=None
117
+ matched_model=normalized_matches[0].selector,
118
+ filtered_models=models,
119
+ filter_hint=None,
100
120
  )
101
121
 
102
122
  if not normalized_matches and len(preferred_norm) >= 4:
103
123
  normalized_matches = [
104
124
  m
105
125
  for m in models
106
- if preferred_norm in _normalize_model_key(m.model_name)
126
+ if preferred_norm in _normalize_model_key(m.selector)
127
+ or preferred_norm in _normalize_model_key(m.model_name)
107
128
  or preferred_norm in _normalize_model_key(m.model_params.model or "")
108
129
  ]
109
130
  if len(normalized_matches) == 1:
110
131
  return ModelMatchResult(
111
- matched_model=normalized_matches[0].model_name, filtered_models=models, filter_hint=None
132
+ matched_model=normalized_matches[0].selector,
133
+ filtered_models=models,
134
+ filter_hint=None,
112
135
  )
113
136
 
114
137
  # Partial match (case-insensitive) on model_name or model_params.model.
@@ -116,10 +139,12 @@ def match_model_from_config(preferred: str | None = None) -> ModelMatchResult:
116
139
  matches = normalized_matches or [
117
140
  m
118
141
  for m in models
119
- if preferred_lower in m.model_name.lower() or preferred_lower in (m.model_params.model or "").lower()
142
+ if preferred_lower in m.selector.lower()
143
+ or preferred_lower in m.model_name.lower()
144
+ or preferred_lower in (m.model_params.model or "").lower()
120
145
  ]
121
146
  if len(matches) == 1:
122
- return ModelMatchResult(matched_model=matches[0].model_name, filtered_models=models, filter_hint=None)
147
+ return ModelMatchResult(matched_model=matches[0].selector, filtered_models=models, filter_hint=None)
123
148
  if matches:
124
149
  # Multiple matches: filter the list for interactive selection
125
150
  return ModelMatchResult(matched_model=None, filtered_models=matches, filter_hint=filter_hint)
@@ -0,0 +1,217 @@
1
+ """Helper for sub-agent model availability and selection logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from klaude_code.protocol.sub_agent import (
9
+ AVAILABILITY_IMAGE_MODEL,
10
+ SubAgentProfile,
11
+ get_sub_agent_profile,
12
+ get_sub_agent_profile_by_tool,
13
+ iter_sub_agent_profiles,
14
+ sub_agent_tool_names,
15
+ )
16
+ from klaude_code.protocol.tools import SubAgentType
17
+
18
+ if TYPE_CHECKING:
19
+ from klaude_code.config.config import Config, ModelEntry
20
+
21
+
22
+ @dataclass
23
+ class SubAgentModelInfo:
24
+ """Sub-agent and its current model configuration."""
25
+
26
+ profile: SubAgentProfile
27
+ # Explicitly configured model selector (from config), if any.
28
+ configured_model: str | None
29
+
30
+ # Effective model name used by this sub-agent.
31
+ # - When configured_model is set: equals configured_model.
32
+ # - When requirement-based default applies (e.g. ImageGen): resolved model.
33
+ # - When inheriting from main agent: None.
34
+ effective_model: str | None
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class EmptySubAgentModelBehavior:
39
+ """Human-facing description for an unset (empty) sub-agent model config."""
40
+
41
+ # Summary text for UI (kept UI-framework agnostic).
42
+ description: str
43
+
44
+ # Best-effort resolved model name (if any). For ImageGen this is usually the
45
+ # first available image model; for other sub-agents it's the main model.
46
+ resolved_model_name: str | None
47
+
48
+
49
+ class SubAgentModelHelper:
50
+ """Centralized logic for sub-agent availability and model selection."""
51
+
52
+ def __init__(self, config: Config) -> None:
53
+ self._config = config
54
+
55
+ def check_availability_requirement(self, requirement: str | None) -> bool:
56
+ """Check if a sub-agent's availability requirement is met.
57
+
58
+ Args:
59
+ requirement: The availability requirement constant (e.g., AVAILABILITY_IMAGE_MODEL).
60
+
61
+ Returns:
62
+ True if the requirement is met or if there's no requirement.
63
+ """
64
+ if requirement is None:
65
+ return True
66
+
67
+ if requirement == AVAILABILITY_IMAGE_MODEL:
68
+ return self._config.has_available_image_model()
69
+
70
+ return True
71
+
72
+ def resolve_model_for_requirement(self, requirement: str | None) -> str | None:
73
+ """Resolve the model name for a given availability requirement.
74
+
75
+ Args:
76
+ requirement: The availability requirement constant.
77
+
78
+ Returns:
79
+ The model name if found, None otherwise.
80
+ """
81
+ if requirement == AVAILABILITY_IMAGE_MODEL:
82
+ return self._config.get_first_available_image_model()
83
+ return None
84
+
85
+ def resolve_default_model_override(self, sub_agent_type: str) -> str | None:
86
+ """Resolve the default model override for a sub-agent when unset.
87
+
88
+ Returns:
89
+ - None for sub-agents that default to inheriting the main agent.
90
+ - A model name for sub-agents that require a dedicated model (e.g. ImageGen).
91
+
92
+ Note: This intentionally ignores any explicit user config; callers use this
93
+ when they want the *unset* behavior.
94
+ """
95
+
96
+ profile = get_sub_agent_profile(sub_agent_type)
97
+ if profile.availability_requirement is None:
98
+ return None
99
+ return self.resolve_model_for_requirement(profile.availability_requirement)
100
+
101
+ def describe_empty_model_config_behavior(
102
+ self,
103
+ sub_agent_type: str,
104
+ *,
105
+ main_model_name: str,
106
+ ) -> EmptySubAgentModelBehavior:
107
+ """Describe what happens when a sub-agent model is not configured.
108
+
109
+ Most sub-agents default to inheriting the main model.
110
+
111
+ Sub-agents with an availability requirement (e.g. ImageGen) do NOT
112
+ inherit from the main model; instead they auto-resolve a suitable model
113
+ (currently: the first available image model).
114
+ """
115
+
116
+ profile = get_sub_agent_profile(sub_agent_type)
117
+
118
+ requirement = profile.availability_requirement
119
+ if requirement is None:
120
+ return EmptySubAgentModelBehavior(
121
+ description=f"inherit from main agent: {main_model_name}",
122
+ resolved_model_name=main_model_name,
123
+ )
124
+
125
+ resolved = self.resolve_model_for_requirement(requirement)
126
+ if requirement == AVAILABILITY_IMAGE_MODEL:
127
+ if resolved:
128
+ return EmptySubAgentModelBehavior(
129
+ description=f"auto-select first available image model: {resolved}",
130
+ resolved_model_name=resolved,
131
+ )
132
+ return EmptySubAgentModelBehavior(
133
+ description="auto-select first available image model",
134
+ resolved_model_name=None,
135
+ )
136
+
137
+ if resolved:
138
+ return EmptySubAgentModelBehavior(
139
+ description=f"auto-select model for requirement '{requirement}': {resolved}",
140
+ resolved_model_name=resolved,
141
+ )
142
+ return EmptySubAgentModelBehavior(
143
+ description=f"auto-select model for requirement '{requirement}'",
144
+ resolved_model_name=None,
145
+ )
146
+
147
+ def get_available_sub_agents(self) -> list[SubAgentModelInfo]:
148
+ """Return all available sub-agents with their current model config.
149
+
150
+ Only returns sub-agents that:
151
+ 1. Are enabled by default
152
+ 2. Have their availability requirements met
153
+
154
+ For sub-agents without explicit config, resolves model based on availability_requirement.
155
+ """
156
+ result: list[SubAgentModelInfo] = []
157
+ for profile in iter_sub_agent_profiles(enabled_only=True):
158
+ if not self.check_availability_requirement(profile.availability_requirement):
159
+ continue
160
+ configured_model = self._config.sub_agent_models.get(profile.name)
161
+ effective_model = configured_model
162
+ if not effective_model and profile.availability_requirement:
163
+ effective_model = self.resolve_model_for_requirement(profile.availability_requirement)
164
+ result.append(
165
+ SubAgentModelInfo(
166
+ profile=profile,
167
+ configured_model=configured_model,
168
+ effective_model=effective_model,
169
+ )
170
+ )
171
+ return result
172
+
173
+ def get_selectable_models(self, sub_agent_type: str) -> list[ModelEntry]:
174
+ """Return selectable models for a specific sub-agent type.
175
+
176
+ For sub-agents with availability_requirement (e.g., ImageGen):
177
+ - Only returns models matching the requirement (e.g., image models)
178
+
179
+ For other sub-agents:
180
+ - Returns all available models
181
+ """
182
+ profile = get_sub_agent_profile(sub_agent_type)
183
+ all_models = self._config.iter_model_entries(only_available=True)
184
+
185
+ if profile.availability_requirement == AVAILABILITY_IMAGE_MODEL:
186
+ return [m for m in all_models if m.model_params.modalities and "image" in m.model_params.modalities]
187
+
188
+ return all_models
189
+
190
+ def get_enabled_sub_agent_tool_names(self) -> list[str]:
191
+ """Return sub-agent tool names that should be added to main agent's tool list."""
192
+ result: list[str] = []
193
+ for name in sub_agent_tool_names(enabled_only=True):
194
+ profile = get_sub_agent_profile_by_tool(name)
195
+ if profile is not None and self.check_availability_requirement(profile.availability_requirement):
196
+ result.append(name)
197
+ return result
198
+
199
+ def get_enabled_sub_agent_types(self) -> set[SubAgentType]:
200
+ """Return set of sub-agent types that are enabled and available."""
201
+ enabled: set[SubAgentType] = set()
202
+ for name in sub_agent_tool_names(enabled_only=True):
203
+ profile = get_sub_agent_profile_by_tool(name)
204
+ if profile is not None and self.check_availability_requirement(profile.availability_requirement):
205
+ enabled.add(profile.name)
206
+ return enabled
207
+
208
+ def build_sub_agent_client_configs(self) -> dict[SubAgentType, str]:
209
+ """Return model names for each sub-agent that needs a dedicated client."""
210
+ result: dict[SubAgentType, str] = {}
211
+ for profile in iter_sub_agent_profiles():
212
+ model_name = self._config.sub_agent_models.get(profile.name)
213
+ if not model_name and profile.availability_requirement:
214
+ model_name = self.resolve_model_for_requirement(profile.availability_requirement)
215
+ if model_name:
216
+ result[profile.name] = model_name
217
+ return result
klaude_code/const.py CHANGED
@@ -123,7 +123,7 @@ TAB_EXPAND_WIDTH = 8 # Tab expansion width for text rendering
123
123
  DIFF_PREFIX_WIDTH = 4 # Width of line number prefix in diff display
124
124
  MAX_DIFF_LINES = 500 # Maximum lines to show in diff output
125
125
  INVALID_TOOL_CALL_MAX_LENGTH = 200 # Maximum length for invalid tool call display
126
- TRUNCATE_DISPLAY_MAX_LINE_LENGTH = 200 # Maximum line length for truncated display
126
+ TRUNCATE_DISPLAY_MAX_LINE_LENGTH = 500 # Maximum line length for truncated display
127
127
  TRUNCATE_DISPLAY_MAX_LINES = 4 # Maximum lines for truncated display
128
128
  MIN_HIDDEN_LINES_FOR_INDICATOR = 5 # Minimum hidden lines before showing truncation indicator
129
129
  SUB_AGENT_RESULT_MAX_LINES = 10 # Maximum lines for sub-agent result display
@@ -155,7 +155,7 @@ MARKDOWN_RIGHT_MARGIN = 2 # Right margin (columns) for markdown rendering
155
155
  STATUS_HINT_TEXT = " (esc to interrupt)" # Status hint text shown after spinner
156
156
 
157
157
  # Spinner status texts
158
- STATUS_WAITING_TEXT = "Awaiting …"
158
+ STATUS_WAITING_TEXT = "Connecting …"
159
159
  STATUS_THINKING_TEXT = "Reasoning …"
160
160
  STATUS_COMPOSING_TEXT = "Generating"
161
161
 
@@ -7,8 +7,12 @@ from dataclasses import dataclass
7
7
  from functools import cache
8
8
  from importlib.resources import files
9
9
  from pathlib import Path
10
- from typing import Any, Protocol
10
+ from typing import TYPE_CHECKING, Any, Protocol
11
11
 
12
+ if TYPE_CHECKING:
13
+ from klaude_code.config.config import Config
14
+
15
+ from klaude_code.config.sub_agent_model_helper import SubAgentModelHelper
12
16
  from klaude_code.core.reminders import (
13
17
  at_file_reader_reminder,
14
18
  empty_todo_reminder,
@@ -23,7 +27,7 @@ from klaude_code.core.tool.report_back_tool import ReportBackTool
23
27
  from klaude_code.core.tool.tool_registry import get_tool_schemas
24
28
  from klaude_code.llm import LLMClientABC
25
29
  from klaude_code.protocol import llm_param, message, tools
26
- from klaude_code.protocol.sub_agent import get_sub_agent_profile, sub_agent_tool_names
30
+ from klaude_code.protocol.sub_agent import get_sub_agent_profile
27
31
  from klaude_code.session import Session
28
32
 
29
33
  type Reminder = Callable[[Session], Awaitable[message.DeveloperMessage | None]]
@@ -58,6 +62,9 @@ PROMPT_FILES: dict[str, str] = {
58
62
  }
59
63
 
60
64
 
65
+ NANO_BANANA_SYSTEM_PROMPT_PATH = "prompts/prompt-nano-banana.md"
66
+
67
+
61
68
  STRUCTURED_OUTPUT_PROMPT = """\
62
69
 
63
70
  # Structured Output
@@ -166,12 +173,14 @@ def load_system_prompt(
166
173
  def load_agent_tools(
167
174
  model_name: str,
168
175
  sub_agent_type: tools.SubAgentType | None = None,
176
+ config: Config | None = None,
169
177
  ) -> list[llm_param.ToolSchema]:
170
178
  """Get tools for an agent based on model and agent type.
171
179
 
172
180
  Args:
173
181
  model_name: The model name.
174
182
  sub_agent_type: If None, returns main agent tools. Otherwise returns sub-agent tools.
183
+ config: Config for checking sub-agent availability (e.g., image model availability).
175
184
  """
176
185
 
177
186
  if sub_agent_type is not None:
@@ -180,13 +189,20 @@ def load_agent_tools(
180
189
 
181
190
  # Main agent tools
182
191
  if "gpt-5" in model_name:
183
- tool_names = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
192
+ tool_names: list[str] = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
184
193
  elif "gemini-3" in model_name:
185
194
  tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE]
186
195
  else:
187
196
  tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE, tools.TODO_WRITE]
188
197
 
189
- tool_names.extend(sub_agent_tool_names(enabled_only=True, model_name=model_name))
198
+ if config is not None:
199
+ helper = SubAgentModelHelper(config)
200
+ tool_names.extend(helper.get_enabled_sub_agent_tool_names())
201
+ else:
202
+ from klaude_code.protocol.sub_agent import sub_agent_tool_names
203
+
204
+ tool_names.extend(sub_agent_tool_names(enabled_only=True))
205
+
190
206
  tool_names.extend([tools.MERMAID])
191
207
  # tool_names.extend([tools.MEMORY])
192
208
  return get_tool_schemas(tool_names)
@@ -246,10 +262,17 @@ class ModelProfileProvider(Protocol):
246
262
  output_schema: dict[str, Any] | None = None,
247
263
  ) -> AgentProfile: ...
248
264
 
265
+ def enabled_sub_agent_types(self) -> set[tools.SubAgentType]:
266
+ """Return set of sub-agent types enabled for this provider."""
267
+ ...
268
+
249
269
 
250
270
  class DefaultModelProfileProvider(ModelProfileProvider):
251
271
  """Default provider backed by global prompts/tool/reminder registries."""
252
272
 
273
+ def __init__(self, config: Config | None = None) -> None:
274
+ self._config = config
275
+
253
276
  def build_profile(
254
277
  self,
255
278
  llm_client: LLMClientABC,
@@ -261,13 +284,25 @@ class DefaultModelProfileProvider(ModelProfileProvider):
261
284
  profile = AgentProfile(
262
285
  llm_client=llm_client,
263
286
  system_prompt=load_system_prompt(model_name, llm_client.protocol, sub_agent_type),
264
- tools=load_agent_tools(model_name, sub_agent_type),
287
+ tools=load_agent_tools(model_name, sub_agent_type, config=self._config),
265
288
  reminders=load_agent_reminders(model_name, sub_agent_type),
266
289
  )
267
290
  if output_schema:
268
291
  return with_structured_output(profile, output_schema)
269
292
  return profile
270
293
 
294
+ def enabled_sub_agent_types(self) -> set[tools.SubAgentType]:
295
+ if self._config is None:
296
+ from klaude_code.protocol.sub_agent import get_sub_agent_profile_by_tool, sub_agent_tool_names
297
+
298
+ return {
299
+ profile.name
300
+ for name in sub_agent_tool_names(enabled_only=True)
301
+ if (profile := get_sub_agent_profile_by_tool(name)) is not None
302
+ }
303
+ helper = SubAgentModelHelper(self._config)
304
+ return helper.get_enabled_sub_agent_types()
305
+
271
306
 
272
307
  class VanillaModelProfileProvider(ModelProfileProvider):
273
308
  """Provider that strips prompts, reminders, and tools for vanilla mode."""
@@ -289,3 +324,34 @@ class VanillaModelProfileProvider(ModelProfileProvider):
289
324
  if output_schema:
290
325
  return with_structured_output(profile, output_schema)
291
326
  return profile
327
+
328
+ def enabled_sub_agent_types(self) -> set[tools.SubAgentType]:
329
+ return set()
330
+
331
+
332
+ class NanoBananaModelProfileProvider(ModelProfileProvider):
333
+ """Provider for the Nano Banana image generation model.
334
+
335
+ This mode uses a dedicated system prompt and strips all tools/reminders.
336
+ """
337
+
338
+ def build_profile(
339
+ self,
340
+ llm_client: LLMClientABC,
341
+ sub_agent_type: tools.SubAgentType | None = None,
342
+ *,
343
+ output_schema: dict[str, Any] | None = None,
344
+ ) -> AgentProfile:
345
+ del sub_agent_type
346
+ profile = AgentProfile(
347
+ llm_client=llm_client,
348
+ system_prompt=_load_prompt_by_path(NANO_BANANA_SYSTEM_PROMPT_PATH),
349
+ tools=[],
350
+ reminders=[],
351
+ )
352
+ if output_schema:
353
+ return with_structured_output(profile, output_schema)
354
+ return profile
355
+
356
+ def enabled_sub_agent_types(self) -> set[tools.SubAgentType]:
357
+ return set()