remote-coder 0.4.1__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 (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. remote_coder-0.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ from app.jobs.schemas import Job, JobStatus
4
+ from app.projects.registry import ProjectRecord
5
+ from app.telegram.commands.base import (
6
+ CommandContext,
7
+ ConfirmableCommand,
8
+ InlineButton,
9
+ TelegramCommand,
10
+ TelegramMessage,
11
+ _button_rows,
12
+ _cmd_evt,
13
+ _confirmation_buttons_enabled,
14
+ _job_button_label,
15
+ effective_project_name_for_chat,
16
+ )
17
+ from app.telegram.confirmations import PendingConfirmation
18
+
19
+
20
+ class ClearCommand(ConfirmableCommand):
21
+ name = "/clear"
22
+ description = "Clean branches, worktrees, or conversation memory after confirmation"
23
+
24
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
25
+ tokens = message.text.strip().split()
26
+ if len(tokens) == 1:
27
+ return "Choose what to clear. Confirmation with y/Y is required before running."
28
+ if len(tokens) != 2 or tokens[1] not in {"branch", "memory", "worktrees"}:
29
+ return "Usage: /clear branch or /clear worktrees or /clear memory"
30
+
31
+ action = tokens[1]
32
+ if action == "memory" and ctx.conversation_store is None:
33
+ return "Memory store is not configured."
34
+
35
+ ctx.confirmation_store.set(
36
+ effective_project_name_for_chat(ctx, message.chat_id),
37
+ message.chat_id,
38
+ PendingConfirmation(command_name=self.name, action=action),
39
+ )
40
+
41
+ if action == "branch":
42
+ summary = "Delete remote-* branches and their linked worktrees in this bot project."
43
+ elif action == "worktrees":
44
+ summary = "Clean managed worktrees and prune stale entries in this bot project."
45
+ else:
46
+ summary = "Delete only this chat's conversation memory in this bot project."
47
+ if _confirmation_buttons_enabled(ctx):
48
+ return f"Pending action: {summary}\nChoose whether to run it."
49
+ return (
50
+ f"Pending action: {summary}\n"
51
+ "Send y or Y to run it. Any other response cancels it."
52
+ )
53
+
54
+ def get_inline_buttons(
55
+ self,
56
+ message: TelegramMessage | None = None,
57
+ ctx: CommandContext | None = None,
58
+ ) -> list[list[InlineButton]] | None:
59
+ if message is None or ctx is None:
60
+ return None
61
+ tokens = message.text.strip().split()
62
+ if len(tokens) == 1:
63
+ return [
64
+ [
65
+ InlineButton("branch", "/clear branch"),
66
+ InlineButton("worktrees", "/clear worktrees"),
67
+ InlineButton("memory", "/clear memory"),
68
+ ],
69
+ ]
70
+ if not _confirmation_buttons_enabled(ctx):
71
+ return None
72
+ pending = ctx.confirmation_store.get(
73
+ effective_project_name_for_chat(ctx, message.chat_id),
74
+ message.chat_id,
75
+ )
76
+ if pending is None or pending.command_name != self.name:
77
+ return None
78
+ return [[InlineButton("Yes", "Y"), InlineButton("No", "n")]]
79
+
80
+ def confirm(
81
+ self,
82
+ message: TelegramMessage,
83
+ ctx: CommandContext,
84
+ pending: PendingConfirmation,
85
+ ) -> str:
86
+ if message.text.strip() not in {"y", "Y"}:
87
+ if pending.action == "branch":
88
+ target = "Branch cleanup"
89
+ elif pending.action == "worktrees":
90
+ target = "Worktree cleanup"
91
+ else:
92
+ target = "Memory cleanup"
93
+ return f"{target} was cancelled."
94
+
95
+ _cmd_evt.info("clear confirmed action=%s", pending.action, chat_id=message.chat_id)
96
+ if pending.action == "branch":
97
+ return self._clear_branches(ctx)
98
+ if pending.action == "worktrees":
99
+ return self._clear_worktrees(ctx)
100
+ if pending.action == "memory":
101
+ return self._clear_memory(ctx, message.chat_id)
102
+ return "Unknown clear action."
103
+
104
+ def _bound_project_record(self, ctx: CommandContext) -> ProjectRecord | None:
105
+ name = ctx.project_name
106
+ if not name:
107
+ return None
108
+ return ctx.project_registry.get(name)
109
+
110
+ def _clear_branches(self, ctx: CommandContext) -> str:
111
+ p = self._bound_project_record(ctx)
112
+ if p is None:
113
+ return "No project is bound to this bot or the project was not found in the registry."
114
+ if not p.enabled:
115
+ return f"Project is disabled: {p.name}"
116
+
117
+ try:
118
+ ctx.git_service.checkout_integrate_branch(p.root_path)
119
+ remote_branches = ctx.git_service.list_remote_branches_matching(
120
+ p.root_path, ctx.git_remote_name, "remote-"
121
+ )
122
+ local_branches = ctx.git_service.list_local_branches_matching(p.root_path, "remote-")
123
+ if remote_branches:
124
+ ctx.git_service.delete_remote_branches(p.root_path, ctx.git_remote_name, remote_branches)
125
+ if local_branches:
126
+ ctx.git_service.remove_linked_worktrees_for_branches(p.root_path, local_branches)
127
+ ctx.git_service.delete_local_branches(p.root_path, local_branches)
128
+ return (
129
+ f"{p.name}: remote {len(remote_branches)}, local {len(local_branches)} deleted "
130
+ f"({ctx.git_remote_name})"
131
+ )
132
+ except RuntimeError as exc:
133
+ return f"{p.name}: failed - {exc}"
134
+
135
+ def _clear_memory(self, ctx: CommandContext, chat_id: int) -> str:
136
+ if ctx.conversation_store is None:
137
+ return "Memory store is not configured."
138
+ project_name = ctx.project_name
139
+ if not project_name:
140
+ return "No project is bound to this bot."
141
+ entries_removed, links_removed = ctx.conversation_store.delete_chat_memory(
142
+ project=project_name, chat_id=chat_id
143
+ )
144
+ return (
145
+ f"Deleted this chat's conversation memory. "
146
+ f"(project={project_name}, entries {entries_removed}, branch links {links_removed})"
147
+ )
148
+
149
+ def _clear_worktrees(self, ctx: CommandContext) -> str:
150
+ p = self._bound_project_record(ctx)
151
+ if p is None:
152
+ return "No project is bound to this bot or the project was not found in the registry."
153
+ if not p.enabled:
154
+ return f"Project is disabled: {p.name}"
155
+
156
+ try:
157
+ removed_count = ctx.git_service.cleanup_managed_worktrees(
158
+ p.root_path,
159
+ p.worktree_base_dir,
160
+ branch_prefix="remote-",
161
+ )
162
+ return f"{p.name}: {removed_count} worktrees deleted, stale entries pruned"
163
+ except RuntimeError as exc:
164
+ return f"{p.name}: failed - {exc}"
165
+
166
+
167
+ class StopCommand(TelegramCommand):
168
+ name = "/stop"
169
+ description = "Choose and stop a running job"
170
+
171
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
172
+ tokens = message.text.strip().split(maxsplit=1)
173
+ if len(tokens) < 2:
174
+ jobs = self._list_cancellable_jobs(message, ctx)
175
+ if not jobs:
176
+ return "No running job can be stopped."
177
+ return "Choose a job to stop."
178
+ job_id = tokens[1].strip()
179
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
180
+ existing = ctx.job_store.get(job_id)
181
+ if existing is not None and project_name and existing.request.project != project_name:
182
+ return f"Job not found: {job_id}"
183
+ if ctx.job_manager is None:
184
+ return "Job cancellation is not available."
185
+ success = ctx.job_manager.cancel(job_id)
186
+ if success:
187
+ return f"Stop requested: {job_id}"
188
+ job = ctx.job_store.get(job_id)
189
+ if not job:
190
+ return f"Job not found: {job_id}"
191
+ return f"Cannot stop job: {job_id} (current status: {job.status.value})"
192
+
193
+ def get_inline_buttons(
194
+ self,
195
+ message: TelegramMessage | None = None,
196
+ ctx: CommandContext | None = None,
197
+ ) -> list[list[InlineButton]] | None:
198
+ if message is None or ctx is None:
199
+ return None
200
+ if len(message.text.strip().split()) != 1:
201
+ return None
202
+ jobs = self._list_cancellable_jobs(message, ctx)
203
+ if not jobs:
204
+ return None
205
+ return _button_rows(
206
+ [InlineButton(_job_button_label(job), f"/stop {job.id}") for job in jobs],
207
+ per_row=1,
208
+ )
209
+
210
+ @staticmethod
211
+ def _list_cancellable_jobs(message: TelegramMessage, ctx: CommandContext) -> list[Job]:
212
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
213
+ if not project_name:
214
+ return []
215
+ return [
216
+ job
217
+ for job in ctx.job_store.list_recent_for_project_chat(
218
+ project_name, message.chat_id, 20
219
+ )
220
+ if job.status in {JobStatus.QUEUED, JobStatus.RUNNING}
221
+ ]
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ from app.jobs.schemas import FixKind, Job, JobMode, JobRequest
4
+ from app.telegram.commands.base import (
5
+ CommandContext,
6
+ ConfirmableCommand,
7
+ InlineButton,
8
+ TelegramMessage,
9
+ _button_rows,
10
+ _confirmation_buttons_enabled,
11
+ effective_project_name_for_chat,
12
+ format_usage,
13
+ )
14
+ from app.telegram.confirmations import PendingConfirmation
15
+
16
+ FIX_SOURCE_AWAIT_ACTION = "fix_source_await_instruction"
17
+ FIX_COMMIT_PENDING_ACTION = "fix_commit"
18
+ FIX_SOURCE_PENDING_ACTION = "fix_source"
19
+
20
+
21
+ def _fix_job_button_label(job: Job) -> str:
22
+ short_hash = (job.commit_hash or "")[:8]
23
+ branch = job.branch or "-"
24
+ return f"{job.id} ({branch}) [{short_hash}]"
25
+
26
+
27
+ class FixCommand(ConfirmableCommand):
28
+ name = "/fix"
29
+ menu_text = "Choose what to fix."
30
+ description = "Re-do the commit or source of a previous job"
31
+
32
+ _MAX_CANDIDATES = 8
33
+
34
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
35
+ tokens = message.text.strip().split()
36
+ if ctx.job_manager is None:
37
+ return "Fix feature is not available."
38
+ if len(tokens) == 1:
39
+ return "Choose what to fix."
40
+ if len(tokens) == 2:
41
+ kind = tokens[1].lower()
42
+ if kind not in {"commit", "source"}:
43
+ return format_usage("/fix", "/fix commit", "/fix source")
44
+ candidates = self._list_candidates(message, ctx)
45
+ if not candidates:
46
+ return "No job is available to fix."
47
+ return "Choose a job to fix."
48
+ if len(tokens) >= 3:
49
+ kind = tokens[1].lower()
50
+ if kind not in {"commit", "source"}:
51
+ return format_usage("/fix", "/fix commit", "/fix source")
52
+ job_id = tokens[2].strip()
53
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
54
+ if not project_name:
55
+ return "No project is registered."
56
+ target_job = ctx.job_store.get(job_id)
57
+ if target_job is None or not ctx.job_manager.is_fix_candidate(
58
+ target_job, project_name, message.chat_id
59
+ ):
60
+ return f"Job cannot be used as a fix target: {job_id}"
61
+ if kind == "commit":
62
+ return self._start_commit_fix(message, ctx, target_job)
63
+ return self._start_source_fix(message, ctx, target_job)
64
+ return format_usage("/fix", "/fix commit", "/fix source")
65
+
66
+ def _start_commit_fix(
67
+ self, message: TelegramMessage, ctx: CommandContext, target_job: Job
68
+ ) -> str:
69
+ assert ctx.job_manager is not None
70
+ prepared_message = ctx.job_manager.build_fix_commit_preview(target_job)
71
+ ctx.confirmation_store.set(
72
+ effective_project_name_for_chat(ctx, message.chat_id),
73
+ message.chat_id,
74
+ PendingConfirmation(
75
+ command_name=self.name,
76
+ action=FIX_COMMIT_PENDING_ACTION,
77
+ target_job_id=target_job.id,
78
+ prepared_payload=prepared_message,
79
+ ),
80
+ )
81
+ lines = [
82
+ f"Commit message preview (Job {target_job.id}, branch {target_job.branch})",
83
+ "",
84
+ prepared_message,
85
+ "",
86
+ "Send y/Y to apply, or n/N to cancel (or use buttons).",
87
+ ]
88
+ return "\n".join(lines)
89
+
90
+ def _start_source_fix(
91
+ self, message: TelegramMessage, ctx: CommandContext, target_job: Job
92
+ ) -> str:
93
+ ctx.confirmation_store.set(
94
+ effective_project_name_for_chat(ctx, message.chat_id),
95
+ message.chat_id,
96
+ PendingConfirmation(
97
+ command_name=self.name,
98
+ action=FIX_SOURCE_AWAIT_ACTION,
99
+ target_job_id=target_job.id,
100
+ ),
101
+ )
102
+ return (
103
+ f"Send the fix instruction for Job {target_job.id}. "
104
+ "The next message will be used as the instruction."
105
+ )
106
+
107
+ def confirm(
108
+ self,
109
+ message: TelegramMessage,
110
+ ctx: CommandContext,
111
+ pending: PendingConfirmation,
112
+ ) -> str:
113
+ if pending.action == FIX_COMMIT_PENDING_ACTION:
114
+ return self._confirm_commit(message, ctx, pending)
115
+ if pending.action == FIX_SOURCE_PENDING_ACTION:
116
+ return self._confirm_source(message, ctx, pending)
117
+ return "Could not process the pending confirmation."
118
+
119
+ def _confirm_commit(
120
+ self,
121
+ message: TelegramMessage,
122
+ ctx: CommandContext,
123
+ pending: PendingConfirmation,
124
+ ) -> str:
125
+ if message.text.strip() not in {"y", "Y"}:
126
+ return "Cancelled the commit message fix."
127
+ if ctx.job_manager is None or not pending.target_job_id or not pending.prepared_payload:
128
+ return "Could not process the pending confirmation."
129
+ target_job = ctx.job_store.get(pending.target_job_id)
130
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
131
+ if target_job is None or not project_name or not ctx.job_manager.is_fix_candidate(
132
+ target_job, project_name, message.chat_id
133
+ ):
134
+ return "Fix target job is no longer available."
135
+ request = JobRequest(
136
+ project=project_name,
137
+ model=target_job.request.model,
138
+ model_id=target_job.request.model_id,
139
+ instruction=target_job.request.instruction,
140
+ mode=JobMode.AGENT_FIX,
141
+ fix_kind=FixKind.COMMIT,
142
+ parent_job_id=target_job.id,
143
+ branch=target_job.branch,
144
+ chat_id=message.chat_id,
145
+ requested_by=message.user_id,
146
+ )
147
+ result_job = ctx.job_manager.execute_fix_job(
148
+ request, prepared_message=pending.prepared_payload
149
+ )
150
+ if result_job.status.value == "succeeded":
151
+ return (
152
+ f"Commit message updated.\n"
153
+ f"- Job: {result_job.id}\n"
154
+ f"- Branch: {result_job.branch}\n"
155
+ f"- New commit: {result_job.commit_hash}"
156
+ )
157
+ return f"Commit message fix failed: {result_job.error or 'unknown'}"
158
+
159
+ def _confirm_source(
160
+ self,
161
+ message: TelegramMessage,
162
+ ctx: CommandContext,
163
+ pending: PendingConfirmation,
164
+ ) -> str:
165
+ # Source-mode confirmation is routed by the webhook (background task);
166
+ # see app/telegram/webhook.py for the actual execution.
167
+ _ = (message, ctx, pending)
168
+ return "Started the fix job in the background."
169
+
170
+ def get_inline_buttons(
171
+ self,
172
+ message: TelegramMessage | None = None,
173
+ ctx: CommandContext | None = None,
174
+ ) -> list[list[InlineButton]] | None:
175
+ if message is None or ctx is None:
176
+ return None
177
+ tokens = message.text.strip().split()
178
+ if len(tokens) == 1:
179
+ return [
180
+ [
181
+ InlineButton("Fix commit", "/fix commit"),
182
+ InlineButton("Fix source", "/fix source"),
183
+ ],
184
+ ]
185
+ if len(tokens) == 2:
186
+ kind = tokens[1].lower()
187
+ if kind not in {"commit", "source"}:
188
+ return None
189
+ candidates = self._list_candidates(message, ctx)
190
+ if not candidates:
191
+ return None
192
+ return _button_rows(
193
+ [
194
+ InlineButton(_fix_job_button_label(job), f"/fix {kind} {job.id}")
195
+ for job in candidates
196
+ ],
197
+ per_row=1,
198
+ )
199
+ if not _confirmation_buttons_enabled(ctx):
200
+ return None
201
+ pending = ctx.confirmation_store.get(
202
+ effective_project_name_for_chat(ctx, message.chat_id),
203
+ message.chat_id,
204
+ )
205
+ if pending is None or pending.command_name != self.name:
206
+ return None
207
+ if pending.action != FIX_COMMIT_PENDING_ACTION:
208
+ return None
209
+ return [[InlineButton("Yes", "Y"), InlineButton("No", "n")]]
210
+
211
+ def _list_candidates(self, message: TelegramMessage, ctx: CommandContext) -> list[Job]:
212
+ if ctx.job_manager is None:
213
+ return []
214
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
215
+ if not project_name:
216
+ return []
217
+ return ctx.job_manager.list_fix_candidates(
218
+ project_name, message.chat_id, limit=self._MAX_CANDIDATES
219
+ )
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from app.ai.model_catalog import format_model_selection, get_model_options, is_valid_model_id
4
+ from app.models import ModelName
5
+ from app.telegram.commands.base import (
6
+ MODEL_USAGE,
7
+ CommandContext,
8
+ InlineButton,
9
+ TelegramCommand,
10
+ TelegramMessage,
11
+ _button_rows,
12
+ effective_model_selection_for_chat,
13
+ effective_project_name_for_chat,
14
+ format_usage,
15
+ )
16
+ from app.telegram.i18n import ui_message
17
+ from app.telegram.model_preferences import ModelPreference
18
+
19
+
20
+ class ModelCommand(TelegramCommand):
21
+ name = "/model"
22
+ menu_text = ui_message("model.menu", "Choose a model.")
23
+ description = "Show or change this chat's default AI model"
24
+
25
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
26
+ tokens = message.text.strip().split()
27
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
28
+ current = effective_model_selection_for_chat(ctx, message.chat_id, project_name)
29
+ if len(tokens) == 1:
30
+ return ui_message(
31
+ "model.settings",
32
+ "Model settings\n\n- Current default model: {selection}",
33
+ selection=format_model_selection(current.provider, current.model_id),
34
+ )
35
+ if len(tokens) == 2 and tokens[1] in {model.value for model in ModelName}:
36
+ selected = ModelName(tokens[1])
37
+ ctx.model_preferences.set(project_name, message.chat_id, selected)
38
+ return ui_message(
39
+ "model.provider_selected",
40
+ "Model provider selected.\n\n- Default model: {provider}\n- Choose a specific model.",
41
+ provider=selected.value,
42
+ )
43
+ if len(tokens) == 3 and tokens[1] in {model.value for model in ModelName}:
44
+ selected = ModelName(tokens[1])
45
+ model_id = tokens[2]
46
+ if not is_valid_model_id(selected, model_id):
47
+ usage = format_usage(
48
+ "/model",
49
+ f"/model {MODEL_USAGE}",
50
+ f"/model {MODEL_USAGE} <model_id>",
51
+ )
52
+ return ui_message(
53
+ "model.unknown_specific",
54
+ "Unknown specific model: {model_id}\n\n{usage}",
55
+ model_id=model_id,
56
+ usage=usage,
57
+ )
58
+ ctx.model_preferences.set_selection(
59
+ project_name,
60
+ message.chat_id,
61
+ ModelPreference(selected, model_id),
62
+ )
63
+ return ui_message(
64
+ "model.updated",
65
+ "Model setting updated.\n\n- Default model: {selection}",
66
+ selection=format_model_selection(selected, model_id),
67
+ )
68
+ return format_usage("/model", f"/model {MODEL_USAGE}", f"/model {MODEL_USAGE} <model_id>")
69
+
70
+ def get_inline_buttons(
71
+ self,
72
+ message: TelegramMessage | None = None,
73
+ ctx: CommandContext | None = None,
74
+ ) -> list[list[InlineButton]] | None:
75
+ tokens = message.text.strip().split() if message is not None else []
76
+ if len(tokens) <= 1:
77
+ return [
78
+ [
79
+ InlineButton("claude", "/model claude"),
80
+ InlineButton("codex", "/model codex"),
81
+ InlineButton("gemini", "/model gemini"),
82
+ ]
83
+ ]
84
+ if len(tokens) == 2 and tokens[1] in {model.value for model in ModelName}:
85
+ provider = ModelName(tokens[1])
86
+ return _button_rows(
87
+ [
88
+ InlineButton(option.label, f"/model {provider.value} {option.value}")
89
+ for option in get_model_options(provider)
90
+ ],
91
+ per_row=1,
92
+ )
93
+ return None