Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/bot/handlers/callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ async def handle_callback_query(
action, param = data, None

# Route to appropriate handler
from .command import _handle_model_selection

async def _model_effort_handler(query, param, context):
await _handle_model_selection(query, f"{action}:{param}", context)

handlers = {
"cd": handle_cd_callback,
"action": handle_action_callback,
Expand All @@ -66,6 +71,8 @@ async def handle_callback_query(
"conversation": handle_conversation_callback,
"git": handle_git_callback,
"export": handle_export_callback,
"model": _model_effort_handler,
"effort": _model_effort_handler,
}

handler = handlers.get(action)
Expand Down
164 changes: 163 additions & 1 deletion src/bot/handlers/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
"• <code>/status</code> - Show session and usage status\n"
"• <code>/export</code> - Export session history\n"
"• <code>/actions</code> - Show context-aware quick actions\n"
"• <code>/git</code> - Git repository information\n\n"
"• <code>/git</code> - Git repository information\n"
"• <code>/model [name]</code> - View or switch Claude model\n\n"
"<b>Session Behavior:</b>\n"
"• Sessions are automatically maintained per project directory\n"
"• Switching directories with <code>/cd</code> resumes the session for that project\n"
Expand Down Expand Up @@ -1232,6 +1233,167 @@ async def git_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
logger.error("Error in git_command", error=str(e), user_id=user_id)


# Model IDs mapped to user-friendly labels
_MODELS = {
"opus": "claude-opus-4-6",
"sonnet": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001",
}

# Effort levels per model (Haiku doesn't support effort; "max" is Opus-only)
_EFFORT_BY_MODEL = {
"opus": ["low", "medium", "high", "max"],
"sonnet": ["low", "medium", "high"],
"haiku": [],
}


def _current_model_label(context: ContextTypes.DEFAULT_TYPE) -> str:
"""Return a human-friendly label for the active model + effort."""
override = context.user_data.get("model_override")
effort = context.user_data.get("effort_override")
# Reverse-map model ID to short name
model_id = override or ""
label = model_id
for short, full in _MODELS.items():
if full == model_id:
label = short.capitalize()
break
if not override:
settings = context.bot_data.get("settings")
server_model = getattr(settings, "claude_model", None) if settings else None
label = f"Default ({server_model or 'CLI default'})"
parts = [label]
if effort:
parts.append(f"effort={effort}")
return " | ".join(parts)


async def model_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /model command - show model selection keyboard."""
current = _current_model_label(context)

keyboard = [
[
InlineKeyboardButton("Opus", callback_data="model:opus"),
InlineKeyboardButton("Sonnet", callback_data="model:sonnet"),
InlineKeyboardButton("Haiku", callback_data="model:haiku"),
],
[InlineKeyboardButton("Reset to default", callback_data="model:default")],
]

await update.message.reply_text(
f"🤖 <b>Current:</b> {escape_html(current)}\n\n"
"Choose a model:\n"
"<i>⚠️ Switching will start a new session.</i>",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(keyboard),
)


async def _handle_model_selection(query, data: str, context) -> None:
"""Shared logic for model/effort selection (used by both callback routes)."""
if data.startswith("model:"):
choice = data.split(":", 1)[1]

if choice == "default":
context.user_data.pop("model_override", None)
context.user_data.pop("effort_override", None)
context.user_data["force_new_session"] = True
await query.edit_message_text(
"🤖 Model and effort reset to server defaults.\n"
"<i>Next message starts a fresh session.</i>",
parse_mode="HTML",
)
logger.info("Model override cleared", user_id=query.from_user.id)
return

model_id = _MODELS.get(choice)
if not model_id:
await query.edit_message_text("Unknown model.")
return

context.user_data["model_override"] = model_id
# Clear stale effort when switching models
context.user_data.pop("effort_override", None)
# Force new session so the model change takes effect immediately
context.user_data["force_new_session"] = True

logger.info(
"Model override set",
user_id=query.from_user.id,
model=model_id,
)

# Show effort level selection (if supported by this model)
effort_levels = _EFFORT_BY_MODEL.get(choice, [])
if not effort_levels:
# Model doesn't support effort (e.g. Haiku)
current = _current_model_label(context)
await query.edit_message_text(
f"🤖 <b>{escape_html(current)}</b> — ready.\n"
"<i>New session will start with your next message.</i>",
parse_mode="HTML",
)
return

rows = []
row = []
for level in effort_levels:
row.append(
InlineKeyboardButton(level.capitalize(), callback_data=f"effort:{level}")
)
if len(row) == 2:
rows.append(row)
row = []
if row:
rows.append(row)
rows.append(
[InlineKeyboardButton("Skip (keep current)", callback_data="effort:skip")]
)

await query.edit_message_text(
f"🤖 Model set to <b>{escape_html(choice.capitalize())}</b>.\n\n"
"Choose effort level:",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(rows),
)

elif data.startswith("effort:"):
level = data.split(":", 1)[1]

if level == "skip":
current = _current_model_label(context)
await query.edit_message_text(
f"🤖 <b>{escape_html(current)}</b> — ready.\n"
"<i>New session will start with your next message.</i>",
parse_mode="HTML",
)
return

all_effort_levels = {"low", "medium", "high", "max"}
if level in all_effort_levels:
context.user_data["effort_override"] = level
current = _current_model_label(context)
await query.edit_message_text(
f"🤖 <b>{escape_html(current)}</b> — ready.\n"
"<i>New session will start with your next message.</i>",
parse_mode="HTML",
)
logger.info(
"Effort override set",
user_id=query.from_user.id,
effort=level,
)


async def model_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle model and effort selection callbacks (agentic mode route)."""
query = update.callback_query
await query.answer()
await _handle_model_selection(query, query.data, context)


async def restart_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /restart command - gracefully restart the bot process.

Expand Down
8 changes: 8 additions & 0 deletions src/bot/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,8 @@ async def stream_handler(update_obj):
session_id=session_id,
on_stream=stream_handler,
force_new=force_new,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

# New session created successfully — clear the one-shot flag
Expand Down Expand Up @@ -824,6 +826,8 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
working_directory=current_dir,
user_id=user_id,
session_id=session_id,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

# Update session ID
Expand Down Expand Up @@ -951,6 +955,8 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
working_directory=current_dir,
user_id=user_id,
session_id=session_id,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

# Update session ID
Expand Down Expand Up @@ -1068,6 +1074,8 @@ async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
working_directory=current_dir,
user_id=user_id,
session_id=session_id,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

context.user_data["claude_session_id"] = claude_response.session_id
Expand Down
18 changes: 18 additions & 0 deletions src/bot/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ def _register_agentic_handlers(self, app: Application) -> None:
("status", self.agentic_status),
("verbose", self.agentic_verbose),
("repo", self.agentic_repo),
("model", command.model_command),
("restart", command.restart_command),
]
if self.settings.enable_project_threads:
Expand Down Expand Up @@ -364,6 +365,14 @@ def _register_agentic_handlers(self, app: Application) -> None:
)
)

# Model/effort selection callbacks
app.add_handler(
CallbackQueryHandler(
self._inject_deps(command.model_callback),
pattern=r"^(model|effort):",
)
)

# Only cd: callbacks (for project selection), scoped by pattern
app.add_handler(
CallbackQueryHandler(
Expand Down Expand Up @@ -392,6 +401,7 @@ def _register_classic_handlers(self, app: Application) -> None:
("export", command.export_session),
("actions", command.quick_actions),
("git", command.git_command),
("model", command.model_command),
("restart", command.restart_command),
]
if self.settings.enable_project_threads:
Expand Down Expand Up @@ -436,6 +446,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
BotCommand("status", "Show session status"),
BotCommand("verbose", "Set output verbosity (0/1/2)"),
BotCommand("repo", "List repos / switch workspace"),
BotCommand("model", "Switch Claude model and effort"),
BotCommand("restart", "Restart the bot"),
]
if self.settings.enable_project_threads:
Expand All @@ -456,6 +467,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
BotCommand("export", "Export current session"),
BotCommand("actions", "Show quick actions"),
BotCommand("git", "Git repository commands"),
BotCommand("model", "Switch Claude model and effort"),
BotCommand("restart", "Restart the bot"),
]
if self.settings.enable_project_threads:
Expand Down Expand Up @@ -990,6 +1002,8 @@ async def agentic_text(
on_stream=on_stream,
force_new=force_new,
interrupt_event=interrupt_event,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

# New session created successfully — clear the one-shot flag
Expand Down Expand Up @@ -1240,6 +1254,8 @@ async def agentic_document(
session_id=session_id,
on_stream=on_stream,
force_new=force_new,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

if force_new:
Expand Down Expand Up @@ -1439,6 +1455,8 @@ async def _handle_agentic_media_message(
session_id=session_id,
on_stream=on_stream,
force_new=force_new,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)
finally:
heartbeat.cancel()
Expand Down
10 changes: 10 additions & 0 deletions src/claude/facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ async def run_command(
on_stream: Optional[Callable[[StreamUpdate], None]] = None,
force_new: bool = False,
interrupt_event: Optional["asyncio.Event"] = None,
model_override: Optional[str] = None,
effort_override: Optional[str] = None,
) -> ClaudeResponse:
"""Run Claude Code command with full integration."""
logger.info(
Expand Down Expand Up @@ -88,6 +90,8 @@ async def run_command(
continue_session=should_continue,
stream_callback=on_stream,
interrupt_event=interrupt_event,
model_override=model_override,
effort_override=effort_override,
)
except Exception as resume_error:
# If resume failed (e.g., session expired/missing on Claude's side),
Expand All @@ -113,6 +117,8 @@ async def run_command(
continue_session=False,
stream_callback=on_stream,
interrupt_event=interrupt_event,
model_override=model_override,
effort_override=effort_override,
)
else:
raise
Expand Down Expand Up @@ -157,6 +163,8 @@ async def _execute(
continue_session: bool = False,
stream_callback: Optional[Callable] = None,
interrupt_event: Optional[asyncio.Event] = None,
model_override: Optional[str] = None,
effort_override: Optional[str] = None,
) -> ClaudeResponse:
"""Execute command via SDK."""
return await self.sdk_manager.execute_command(
Expand All @@ -166,6 +174,8 @@ async def _execute(
continue_session=continue_session,
stream_callback=stream_callback,
interrupt_event=interrupt_event,
model_override=model_override,
effort_override=effort_override,
)

async def _find_resumable_session(
Expand Down
5 changes: 4 additions & 1 deletion src/claude/sdk_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ async def execute_command(
continue_session: bool = False,
stream_callback: Optional[Callable[[StreamUpdate], None]] = None,
interrupt_event: Optional[asyncio.Event] = None,
model_override: Optional[str] = None,
effort_override: Optional[str] = None,
) -> ClaudeResponse:
"""Execute Claude Code command via SDK."""
start_time = asyncio.get_event_loop().time()
Expand Down Expand Up @@ -199,7 +201,7 @@ def _stderr_callback(line: str) -> None:
# Build Claude Agent options
options = ClaudeAgentOptions(
max_turns=self.config.claude_max_turns,
model=self.config.claude_model or None,
model=model_override or self.config.claude_model or None,
max_budget_usd=self.config.claude_max_cost_per_request,
cwd=str(working_directory),
allowed_tools=sdk_allowed_tools,
Expand All @@ -212,6 +214,7 @@ def _stderr_callback(line: str) -> None:
"excludedCommands": self.config.sandbox_excluded_commands or [],
},
system_prompt=base_prompt,
effort=effort_override,
setting_sources=["project"],
stderr=_stderr_callback,
)
Expand Down
Loading