@grinev/opencode-telegram-bot 0.9.2 → 0.10.1

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.
package/README.md CHANGED
@@ -11,9 +11,9 @@ Run AI coding tasks, monitor progress, switch models, and manage sessions from y
11
11
 
12
12
  No open ports, no exposed APIs. The bot communicates with your local OpenCode server and the Telegram Bot API only.
13
13
 
14
- Languages: English (`en`), Deutsch (`de`), Español (`es`), Русский (`ru`), 简体中文 (`zh`)
14
+ Platforms: macOS, Windows, Linux
15
15
 
16
- Quick start: `npx @grinev/opencode-telegram-bot`
16
+ Languages: English (`en`), Deutsch (`de`), Español (`es`), Русский (`ru`), 简体中文 (`zh`)
17
17
 
18
18
  <p align="center">
19
19
  <img src="assets/screencast.gif" width="45%" alt="OpenCode Telegram Bot screencast" />
@@ -24,7 +24,7 @@ Quick start: `npx @grinev/opencode-telegram-bot`
24
24
  - **Remote coding** — send prompts to OpenCode from anywhere, receive complete results with code sent as files
25
25
  - **Session management** — create new sessions or continue existing ones, just like in the TUI
26
26
  - **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
27
- - **Model switching** — pick any model from your OpenCode favorites directly in the chat
27
+ - **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
28
28
  - **Agent modes** — switch between Plan and Build modes on the fly
29
29
  - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
30
30
  - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
@@ -68,6 +68,8 @@ The fastest way — run directly with `npx`:
68
68
  npx @grinev/opencode-telegram-bot
69
69
  ```
70
70
 
71
+ > Quick start is for npm usage. You do not need to clone this repository. If you run this command from the source directory (repository root), it may fail with `opencode-telegram: not found`. To run from sources, use the [Development](#development) section.
72
+
71
73
  On first launch, an interactive wizard will guide you through the configuration — it asks for interface language first, then your bot token, user ID, and OpenCode API URL. After that, you're ready to go. Open your bot in Telegram and start sending tasks.
72
74
 
73
75
  #### Alternative: Global Install
@@ -85,11 +87,11 @@ opencode-telegram config
85
87
 
86
88
  ## Supported Platforms
87
89
 
88
- | Platform | Status |
89
- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
90
- | macOS | Fully supported |
91
- | Windows | Fully supported |
92
- | Linux | Experimental should work, but has not been extensively tested. You may need additional steps such as granting execute permissions. |
90
+ | Platform | Status |
91
+ | -------- | -------------------------------------------- |
92
+ | macOS | Fully supported |
93
+ | Windows | Fully supported |
94
+ | Linux | Fully supported (tested on Ubuntu 24.04 LTS) |
93
95
 
94
96
  ## Bot Commands
95
97
 
@@ -136,8 +138,8 @@ When installed via npm, the configuration wizard handles the initial setup. The
136
138
  | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
137
139
  | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
138
140
  | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `ru`, `zh`) | No | `en` |
139
- | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
140
- | `PROJECTS_LIST_LIMIT` | Max projects shown in `/projects` | No | `10` |
141
+ | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
142
+ | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
141
143
  | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
142
144
  | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
143
145
  | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
@@ -176,15 +178,14 @@ If STT variables are not set, voice/audio transcription is disabled and the bot
176
178
 
177
179
  ### Model Configuration
178
180
 
179
- The bot picks up your **favorite models** from OpenCode. To add a model to favorites:
181
+ The model picker uses OpenCode local model state (`favorite` + `recent`):
180
182
 
181
- 1. Open OpenCode TUI (`opencode`)
182
- 2. Go to model selection
183
- 3. Hover over the model you want and press **Ctrl+F** to add it to favorites
183
+ - Favorites are shown first, then recent
184
+ - Models already in favorites are not duplicated in recent
185
+ - Current model is marked with `✅`
186
+ - Default model from `OPENCODE_MODEL_PROVIDER` + `OPENCODE_MODEL_ID` is always included in favorites
184
187
 
185
- These favorites will appear in the model picker opened from the bottom Telegram keyboard.
186
-
187
- A free model (`opencode/big-pickle`) is configured as the default fallback — if you haven't set up any favorites yet, the bot will use it automatically.
188
+ To add a model to favorites, open OpenCode TUI (`opencode`), go to model selection, and press **Cmd+F/Ctrl+F** on the model.
188
189
 
189
190
  ## Security
190
191
 
@@ -240,6 +241,7 @@ npm run dev
240
241
  **No models in model picker**
241
242
 
242
243
  - Add models to your OpenCode favorites: open OpenCode TUI, go to model selection, press **Ctrl+F** on desired models
244
+ - Verify `OPENCODE_MODEL_PROVIDER` and `OPENCODE_MODEL_ID` point to an available model in your setup
243
245
 
244
246
  **Linux: permission denied errors**
245
247
 
@@ -250,6 +252,10 @@ npm run dev
250
252
 
251
253
  Please follow commit and release note conventions in [CONTRIBUTING.md](CONTRIBUTING.md).
252
254
 
255
+ ## Community
256
+
257
+ Have questions, want to share your experience using the bot, or have an idea for a feature? Join the conversation in [GitHub Discussions](https://github.com/grinev/opencode-telegram-bot/discussions).
258
+
253
259
  ## License
254
260
 
255
261
  [MIT](LICENSE) © Ruslan Grinev
@@ -8,15 +8,9 @@ import { logger } from "../utils/logger.js";
8
8
  * @returns Array of available agents (filtered by mode and hidden flag)
9
9
  */
10
10
  export async function getAvailableAgents() {
11
- const project = getCurrentProject();
12
- if (!project) {
13
- logger.warn("[AgentManager] Cannot get agents: no project selected");
14
- return [];
15
- }
16
11
  try {
17
- const { data: agents, error } = await opencodeClient.app.agents({
18
- directory: project.worktree,
19
- });
12
+ const project = getCurrentProject();
13
+ const { data: agents, error } = await opencodeClient.app.agents(project ? { directory: project.worktree } : undefined);
20
14
  if (error) {
21
15
  logger.error("[AgentManager] Failed to fetch agents:", error);
22
16
  return [];
@@ -34,9 +28,11 @@ export async function getAvailableAgents() {
34
28
  return [];
35
29
  }
36
30
  }
31
+ const DEFAULT_AGENT = "build";
37
32
  /**
38
- * Get current agent from last session message or settings
39
- * @returns Current agent name or undefined
33
+ * Get current agent from last session message or settings.
34
+ * Falls back to "build" if nothing is stored.
35
+ * @returns Current agent name
40
36
  */
41
37
  export async function fetchCurrentAgent() {
42
38
  const storedAgent = getCurrentAgent();
@@ -44,7 +40,7 @@ export async function fetchCurrentAgent() {
44
40
  const project = getCurrentProject();
45
41
  if (!session || !project) {
46
42
  // No active session, return stored agent from settings
47
- return storedAgent;
43
+ return storedAgent ?? DEFAULT_AGENT;
48
44
  }
49
45
  try {
50
46
  const { data: messages, error } = await opencodeClient.session.messages({
@@ -54,7 +50,7 @@ export async function fetchCurrentAgent() {
54
50
  });
55
51
  if (error || !messages || messages.length === 0) {
56
52
  logger.debug("[AgentManager] No messages found, using stored agent");
57
- return storedAgent;
53
+ return storedAgent ?? DEFAULT_AGENT;
58
54
  }
59
55
  const lastAgent = messages[0].info.agent;
60
56
  logger.debug(`[AgentManager] Current agent from session: ${lastAgent}`);
@@ -68,11 +64,11 @@ export async function fetchCurrentAgent() {
68
64
  if (lastAgent && lastAgent !== storedAgent) {
69
65
  setCurrentAgent(lastAgent);
70
66
  }
71
- return lastAgent || storedAgent;
67
+ return lastAgent || storedAgent || DEFAULT_AGENT;
72
68
  }
73
69
  catch (err) {
74
70
  logger.error("[AgentManager] Error fetching current agent:", err);
75
- return storedAgent;
71
+ return storedAgent ?? DEFAULT_AGENT;
76
72
  }
77
73
  }
78
74
  /**
@@ -11,11 +11,12 @@ import { getStoredModel } from "../../model/manager.js";
11
11
  import { formatVariantForButton } from "../../variant/manager.js";
12
12
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
13
13
  import { createMainKeyboard } from "../utils/keyboard.js";
14
- import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
14
+ import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
15
15
  import { logger } from "../../utils/logger.js";
16
16
  import { t } from "../../i18n/index.js";
17
17
  import { config } from "../../config.js";
18
18
  const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
19
+ const PROJECT_PAGE_CALLBACK_PREFIX = "projects:page:";
19
20
  function formatProjectButtonLabel(label, isActive) {
20
21
  const prefix = isActive ? "✅ " : "";
21
22
  const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
@@ -36,29 +37,84 @@ export function buildProjectButtonLabel(index, worktree) {
36
37
  const folderName = getProjectFolderName(worktree);
37
38
  return `${index + 1}. ${folderName} [${worktree}]`;
38
39
  }
40
+ export function parseProjectPageCallback(data) {
41
+ if (!data.startsWith(PROJECT_PAGE_CALLBACK_PREFIX)) {
42
+ return null;
43
+ }
44
+ const rawPage = data.slice(PROJECT_PAGE_CALLBACK_PREFIX.length);
45
+ if (!/^\d+$/.test(rawPage)) {
46
+ return null;
47
+ }
48
+ return Number.parseInt(rawPage, 10);
49
+ }
50
+ export function calculateProjectsPaginationRange(totalProjects, page, pageSize) {
51
+ const safePageSize = Math.max(1, pageSize);
52
+ const totalPages = Math.max(1, Math.ceil(totalProjects / safePageSize));
53
+ const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
54
+ const startIndex = normalizedPage * safePageSize;
55
+ const endIndex = Math.min(startIndex + safePageSize, totalProjects);
56
+ return {
57
+ page: normalizedPage,
58
+ totalPages,
59
+ startIndex,
60
+ endIndex,
61
+ };
62
+ }
63
+ function buildProjectsMenuText(currentProjectName, page, totalPages) {
64
+ const baseText = currentProjectName
65
+ ? t("projects.select_with_current", {
66
+ project: currentProjectName,
67
+ })
68
+ : t("projects.select");
69
+ if (totalPages <= 1) {
70
+ return baseText;
71
+ }
72
+ return `${baseText}\n\n${t("projects.page_indicator", {
73
+ current: String(page + 1),
74
+ total: String(totalPages),
75
+ })}`;
76
+ }
77
+ function buildProjectsKeyboard(projects, page) {
78
+ const keyboard = new InlineKeyboard();
79
+ const currentProject = getCurrentProject();
80
+ const pageSize = config.bot.projectsListLimit;
81
+ const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateProjectsPaginationRange(projects.length, page, pageSize);
82
+ projects.slice(startIndex, endIndex).forEach((project, index) => {
83
+ const isActive = currentProject &&
84
+ (project.id === currentProject.id || project.worktree === currentProject.worktree);
85
+ const label = buildProjectButtonLabel(startIndex + index, project.worktree);
86
+ const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
87
+ keyboard.text(labelWithCheck, `project:${project.id}`).row();
88
+ });
89
+ if (totalPages > 1) {
90
+ if (normalizedPage > 0) {
91
+ keyboard.text(t("projects.prev_page"), `${PROJECT_PAGE_CALLBACK_PREFIX}${normalizedPage - 1}`);
92
+ }
93
+ if (normalizedPage < totalPages - 1) {
94
+ keyboard.text(t("projects.next_page"), `${PROJECT_PAGE_CALLBACK_PREFIX}${normalizedPage + 1}`);
95
+ }
96
+ }
97
+ return keyboard;
98
+ }
99
+ function buildProjectsMenuView(projects, page) {
100
+ const currentProject = getCurrentProject();
101
+ const pageSize = config.bot.projectsListLimit;
102
+ const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(projects.length, page, pageSize);
103
+ const currentProjectName = currentProject?.name || currentProject?.worktree || null;
104
+ return {
105
+ text: buildProjectsMenuText(currentProjectName, normalizedPage, totalPages),
106
+ keyboard: buildProjectsKeyboard(projects, normalizedPage),
107
+ };
108
+ }
39
109
  export async function projectsCommand(ctx) {
40
110
  try {
41
111
  await syncSessionDirectoryCache();
42
112
  const projects = await getProjects();
43
- const projectsToShow = projects.slice(0, config.bot.projectsListLimit);
44
- if (projectsToShow.length === 0) {
113
+ if (projects.length === 0) {
45
114
  await ctx.reply(t("projects.empty"));
46
115
  return;
47
116
  }
48
- const keyboard = new InlineKeyboard();
49
- const currentProject = getCurrentProject();
50
- projectsToShow.forEach((project, index) => {
51
- const isActive = currentProject &&
52
- (project.id === currentProject.id || project.worktree === currentProject.worktree);
53
- const label = buildProjectButtonLabel(index, project.worktree);
54
- const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
55
- keyboard.text(labelWithCheck, `project:${project.id}`).row();
56
- });
57
- const text = currentProject
58
- ? t("projects.select_with_current", {
59
- project: currentProject.name || currentProject.worktree,
60
- })
61
- : t("projects.select");
117
+ const { text, keyboard } = buildProjectsMenuView(projects, 0);
62
118
  await replyWithInlineMenu(ctx, {
63
119
  menuKind: "project",
64
120
  text,
@@ -72,7 +128,35 @@ export async function projectsCommand(ctx) {
72
128
  }
73
129
  export async function handleProjectSelect(ctx) {
74
130
  const callbackQuery = ctx.callbackQuery;
75
- if (!callbackQuery?.data || !callbackQuery.data.startsWith("project:")) {
131
+ if (!callbackQuery?.data) {
132
+ return false;
133
+ }
134
+ const page = parseProjectPageCallback(callbackQuery.data);
135
+ if (page !== null) {
136
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
137
+ if (!isActiveMenu) {
138
+ return true;
139
+ }
140
+ try {
141
+ const projects = await getProjects();
142
+ if (projects.length === 0) {
143
+ await ctx.answerCallbackQuery();
144
+ await ctx.reply(t("projects.empty"));
145
+ return true;
146
+ }
147
+ const { text, keyboard } = buildProjectsMenuView(projects, page);
148
+ await ctx.answerCallbackQuery();
149
+ await ctx.editMessageText(text, {
150
+ reply_markup: appendInlineMenuCancelButton(keyboard, "project"),
151
+ });
152
+ }
153
+ catch (error) {
154
+ logger.error("[Bot] Error switching projects page:", error);
155
+ await ctx.answerCallbackQuery({ text: t("projects.page_load_error") });
156
+ }
157
+ return true;
158
+ }
159
+ if (!callbackQuery.data.startsWith("project:")) {
76
160
  return false;
77
161
  }
78
162
  const projectId = callbackQuery.data.replace("project:", "");
@@ -6,45 +6,105 @@ import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
6
  import { summaryAggregator } from "../../summary/aggregator.js";
7
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
8
8
  import { keyboardManager } from "../../keyboard/manager.js";
9
- import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
9
+ import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
10
10
  import { logger } from "../../utils/logger.js";
11
11
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
12
12
  import { config } from "../../config.js";
13
13
  import { getDateLocale, t } from "../../i18n/index.js";
14
+ const SESSION_CALLBACK_PREFIX = "session:";
15
+ const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
16
+ const SESSION_FETCH_EXTRA_COUNT = 1;
17
+ function buildSessionPageCallback(page) {
18
+ return `${SESSION_PAGE_CALLBACK_PREFIX}${page}`;
19
+ }
20
+ function parseSessionPageCallback(data) {
21
+ if (!data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
22
+ return null;
23
+ }
24
+ const rawPage = data.slice(SESSION_PAGE_CALLBACK_PREFIX.length);
25
+ const page = Number(rawPage);
26
+ if (!Number.isInteger(page) || page < 0) {
27
+ return null;
28
+ }
29
+ return page;
30
+ }
31
+ function parseSessionIdCallback(data) {
32
+ if (!data.startsWith(SESSION_CALLBACK_PREFIX)) {
33
+ return null;
34
+ }
35
+ if (data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
36
+ return null;
37
+ }
38
+ const sessionId = data.slice(SESSION_CALLBACK_PREFIX.length);
39
+ return sessionId.length > 0 ? sessionId : null;
40
+ }
41
+ function formatSessionsSelectText(page) {
42
+ if (page === 0) {
43
+ return t("sessions.select");
44
+ }
45
+ return t("sessions.select_page", { page: page + 1 });
46
+ }
47
+ async function loadSessionPage(directory, page, pageSize) {
48
+ const startIndex = page * pageSize;
49
+ const endExclusive = startIndex + pageSize;
50
+ const { data: sessions, error } = await opencodeClient.session.list({
51
+ directory,
52
+ limit: endExclusive + SESSION_FETCH_EXTRA_COUNT,
53
+ });
54
+ if (error || !sessions) {
55
+ throw error || new Error("No data received from server");
56
+ }
57
+ const hasNext = sessions.length > endExclusive;
58
+ const pagedSessions = sessions.slice(startIndex, endExclusive);
59
+ logger.debug(`[Sessions] Loaded page=${page + 1}, startIndex=${startIndex}, endExclusive=${endExclusive}, pageSize=${pageSize}, items=${pagedSessions.length}, hasNext=${hasNext}`);
60
+ return {
61
+ sessions: pagedSessions,
62
+ hasNext,
63
+ page,
64
+ };
65
+ }
66
+ function buildSessionsKeyboard(pageData, pageSize) {
67
+ const keyboard = new InlineKeyboard();
68
+ const localeForDate = getDateLocale();
69
+ const pageStartIndex = pageData.page * pageSize;
70
+ pageData.sessions.forEach((session, index) => {
71
+ const date = new Date(session.time.created).toLocaleDateString(localeForDate);
72
+ const label = `${pageStartIndex + index + 1}. ${session.title} (${date})`;
73
+ keyboard.text(label, `${SESSION_CALLBACK_PREFIX}${session.id}`).row();
74
+ });
75
+ if (pageData.page > 0) {
76
+ keyboard.text(t("sessions.button.prev_page"), buildSessionPageCallback(pageData.page - 1));
77
+ }
78
+ if (pageData.hasNext) {
79
+ keyboard.text(t("sessions.button.next_page"), buildSessionPageCallback(pageData.page + 1));
80
+ }
81
+ if (pageData.page > 0 || pageData.hasNext) {
82
+ keyboard.row();
83
+ }
84
+ return keyboard;
85
+ }
14
86
  export async function sessionsCommand(ctx) {
15
87
  try {
16
- const maxSessions = config.bot.sessionsListLimit;
88
+ const pageSize = config.bot.sessionsListLimit;
17
89
  const currentProject = getCurrentProject();
18
90
  if (!currentProject) {
19
91
  await ctx.reply(t("sessions.project_not_selected"));
20
92
  return;
21
93
  }
22
94
  logger.debug(`[Sessions] Fetching sessions for directory: ${currentProject.worktree}`);
23
- const { data: sessions, error } = await opencodeClient.session.list({
24
- directory: currentProject.worktree,
25
- limit: maxSessions,
26
- });
27
- if (error || !sessions) {
28
- throw error || new Error("No data received from server");
29
- }
30
- logger.debug(`[Sessions] Found ${sessions.length} sessions`);
31
- sessions.forEach((session) => {
95
+ const firstPage = await loadSessionPage(currentProject.worktree, 0, pageSize);
96
+ logger.debug(`[Sessions] Found ${firstPage.sessions.length} sessions on page 1`);
97
+ firstPage.sessions.forEach((session) => {
32
98
  logger.debug(`[Sessions] Session: ${session.title} | ${session.directory}`);
33
99
  });
34
- if (sessions.length === 0) {
100
+ if (firstPage.sessions.length === 0) {
35
101
  await ctx.reply(t("sessions.empty"));
36
102
  return;
37
103
  }
38
- const keyboard = new InlineKeyboard();
39
- const localeForDate = getDateLocale();
40
- sessions.forEach((session, index) => {
41
- const date = new Date(session.time.created).toLocaleDateString(localeForDate);
42
- const label = `${index + 1}. ${session.title} (${date})`;
43
- keyboard.text(label, `session:${session.id}`).row();
44
- });
104
+ const keyboard = buildSessionsKeyboard(firstPage, pageSize);
45
105
  await replyWithInlineMenu(ctx, {
46
106
  menuKind: "session",
47
- text: t("sessions.select"),
107
+ text: formatSessionsSelectText(firstPage.page),
48
108
  keyboard,
49
109
  });
50
110
  }
@@ -55,10 +115,11 @@ export async function sessionsCommand(ctx) {
55
115
  }
56
116
  export async function handleSessionSelect(ctx) {
57
117
  const callbackQuery = ctx.callbackQuery;
58
- if (!callbackQuery?.data || !callbackQuery.data.startsWith("session:")) {
118
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
59
119
  return false;
60
120
  }
61
- const sessionId = callbackQuery.data.replace("session:", "");
121
+ const page = parseSessionPageCallback(callbackQuery.data);
122
+ const sessionId = parseSessionIdCallback(callbackQuery.data);
62
123
  const isActiveMenu = await ensureActiveInlineMenu(ctx, "session");
63
124
  if (!isActiveMenu) {
64
125
  return true;
@@ -71,6 +132,31 @@ export async function handleSessionSelect(ctx) {
71
132
  await ctx.reply(t("sessions.select_project_first"));
72
133
  return true;
73
134
  }
135
+ if (page !== null) {
136
+ try {
137
+ const pageSize = config.bot.sessionsListLimit;
138
+ const pageData = await loadSessionPage(currentProject.worktree, page, pageSize);
139
+ if (pageData.sessions.length === 0) {
140
+ await ctx.answerCallbackQuery({ text: t("sessions.page_empty_callback") });
141
+ return true;
142
+ }
143
+ const keyboard = buildSessionsKeyboard(pageData, pageSize);
144
+ appendInlineMenuCancelButton(keyboard, "session");
145
+ await ctx.editMessageText(formatSessionsSelectText(pageData.page), {
146
+ reply_markup: keyboard,
147
+ });
148
+ await ctx.answerCallbackQuery();
149
+ }
150
+ catch (error) {
151
+ logger.error("[Sessions] Error loading sessions page:", error);
152
+ await ctx.answerCallbackQuery({ text: t("sessions.page_load_error_callback") });
153
+ }
154
+ return true;
155
+ }
156
+ if (!sessionId) {
157
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
158
+ return true;
159
+ }
74
160
  const { data: session, error } = await opencodeClient.session.get({
75
161
  sessionID: sessionId,
76
162
  directory: currentProject.worktree,
@@ -6,6 +6,8 @@ import { getAgentDisplayName } from "../../agent/types.js";
6
6
  import { fetchCurrentModel } from "../../model/manager.js";
7
7
  import { formatModelForDisplay } from "../../model/types.js";
8
8
  import { processManager } from "../../process/manager.js";
9
+ import { keyboardManager } from "../../keyboard/manager.js";
10
+ import { pinnedMessageManager } from "../../pinned/manager.js";
9
11
  import { logger } from "../../utils/logger.js";
10
12
  import { t } from "../../i18n/index.js";
11
13
  import { sendMessageWithMarkdownFallback } from "../utils/send-with-markdown-fallback.js";
@@ -59,16 +61,33 @@ export async function statusCommand(ctx) {
59
61
  message += `\n${t("status.session_not_selected")}\n`;
60
62
  message += t("status.session_hint");
61
63
  }
64
+ if (ctx.chat) {
65
+ if (!pinnedMessageManager.isInitialized()) {
66
+ pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
67
+ }
68
+ // Fetch context limit if not yet loaded (e.g. fresh bot start)
69
+ if (pinnedMessageManager.getContextLimit() === 0) {
70
+ await pinnedMessageManager.refreshContextLimit();
71
+ }
72
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
73
+ }
74
+ // Sync current context (tokens used + limit) into keyboard state
75
+ const contextInfo = pinnedMessageManager.getContextInfo();
76
+ if (contextInfo) {
77
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
78
+ }
79
+ const keyboard = keyboardManager.getKeyboard();
62
80
  if (ctx.chat) {
63
81
  await sendMessageWithMarkdownFallback({
64
82
  api: ctx.api,
65
83
  chatId: ctx.chat.id,
66
84
  text: message,
85
+ options: { reply_markup: keyboard },
67
86
  parseMode: "Markdown",
68
87
  });
69
88
  }
70
89
  else {
71
- await ctx.reply(message);
90
+ await ctx.reply(message, { reply_markup: keyboard });
72
91
  }
73
92
  }
74
93
  catch (error) {
@@ -95,14 +95,24 @@ export async function buildAgentSelectionMenu(currentAgent) {
95
95
  * @param ctx grammY context
96
96
  */
97
97
  export async function showAgentSelectionMenu(ctx) {
98
- const currentAgent = await fetchCurrentAgent();
99
- const keyboard = await buildAgentSelectionMenu(currentAgent);
100
- const text = currentAgent
101
- ? t("agent.menu.current", { name: getAgentDisplayName(currentAgent) })
102
- : t("agent.menu.select");
103
- await replyWithInlineMenu(ctx, {
104
- menuKind: "agent",
105
- text,
106
- keyboard,
107
- });
98
+ try {
99
+ const currentAgent = await fetchCurrentAgent();
100
+ const keyboard = await buildAgentSelectionMenu(currentAgent);
101
+ if (keyboard.inline_keyboard.length === 0) {
102
+ await ctx.reply(t("agent.menu.empty"));
103
+ return;
104
+ }
105
+ const text = currentAgent
106
+ ? t("agent.menu.current", { name: getAgentDisplayName(currentAgent) })
107
+ : t("agent.menu.select");
108
+ await replyWithInlineMenu(ctx, {
109
+ menuKind: "agent",
110
+ text,
111
+ keyboard,
112
+ });
113
+ }
114
+ catch (err) {
115
+ logger.error("[AgentHandler] Error showing agent menu:", err);
116
+ await ctx.reply(t("agent.menu.error"));
117
+ }
108
118
  }
@@ -1,5 +1,5 @@
1
1
  import { InlineKeyboard } from "grammy";
2
- import { selectModel, getFavoriteModels, fetchCurrentModel } from "../../model/manager.js";
2
+ import { selectModel, fetchCurrentModel, getModelSelectionLists } from "../../model/manager.js";
3
3
  import { formatModelForDisplay } from "../../model/types.js";
4
4
  import { formatVariantForButton } from "../../variant/manager.js";
5
5
  import { logger } from "../../utils/logger.js";
@@ -7,19 +7,18 @@ import { createMainKeyboard } from "../utils/keyboard.js";
7
7
  import { getStoredAgent } from "../../agent/manager.js";
8
8
  import { pinnedMessageManager } from "../../pinned/manager.js";
9
9
  import { keyboardManager } from "../../keyboard/manager.js";
10
- import { config } from "../../config.js";
11
10
  import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
12
11
  import { t } from "../../i18n/index.js";
13
- function isOnlyConfigDefaultModel(models) {
14
- if (models.length !== 1) {
15
- return false;
12
+ function buildModelSelectionMenuText(modelLists) {
13
+ const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
14
+ if (modelLists.favorites.length === 0) {
15
+ lines.push(t("model.menu.favorites_empty"));
16
16
  }
17
- const defaultProviderID = config.opencode.model.provider;
18
- const defaultModelID = config.opencode.model.modelId;
19
- if (!defaultProviderID || !defaultModelID) {
20
- return false;
17
+ lines.push(t("model.menu.recent_title"));
18
+ if (modelLists.recent.length === 0) {
19
+ lines.push(t("model.menu.recent_empty"));
21
20
  }
22
- return models[0].providerID === defaultProviderID && models[0].modelID === defaultModelID;
21
+ return lines.join("\n");
23
22
  }
24
23
  /**
25
24
  * Handle model selection callback
@@ -91,27 +90,30 @@ export async function handleModelSelect(ctx) {
91
90
  }
92
91
  }
93
92
  /**
94
- * Build inline keyboard with favorite models
93
+ * Build inline keyboard with favorite and recent models
95
94
  * @param currentModel Current model for highlighting
96
95
  * @returns InlineKeyboard with model selection buttons
97
96
  */
98
- export async function buildModelSelectionMenu(currentModel, favoriteModels) {
97
+ export async function buildModelSelectionMenu(currentModel, modelLists) {
99
98
  const keyboard = new InlineKeyboard();
100
- const favorites = favoriteModels ?? (await getFavoriteModels());
101
- if (favorites.length === 0) {
102
- logger.warn("[ModelHandler] No favorite models found");
99
+ const lists = modelLists ?? (await getModelSelectionLists());
100
+ const favorites = lists.favorites;
101
+ const recent = lists.recent;
102
+ if (favorites.length === 0 && recent.length === 0) {
103
+ logger.warn("[ModelHandler] No model choices found in favorites/recent");
103
104
  return keyboard;
104
105
  }
105
- // Add button for each favorite model
106
- favorites.forEach((model) => {
106
+ const addButton = (model, prefix) => {
107
107
  const isActive = currentModel &&
108
108
  model.providerID === currentModel.providerID &&
109
109
  model.modelID === currentModel.modelID;
110
110
  // Inline buttons use full model ID without truncation
111
- const label = `${model.providerID}/${model.modelID}`;
111
+ const label = `${prefix} ${model.providerID}/${model.modelID}`;
112
112
  const labelWithCheck = isActive ? `✅ ${label}` : label;
113
113
  keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
114
- });
114
+ };
115
+ favorites.forEach((model) => addButton(model, "⭐"));
116
+ recent.forEach((model) => addButton(model, "🕘"));
115
117
  return keyboard;
116
118
  }
117
119
  /**
@@ -121,22 +123,18 @@ export async function buildModelSelectionMenu(currentModel, favoriteModels) {
121
123
  export async function showModelSelectionMenu(ctx) {
122
124
  try {
123
125
  const currentModel = fetchCurrentModel();
124
- const favorites = await getFavoriteModels();
125
- const keyboard = await buildModelSelectionMenu(currentModel, favorites);
126
+ const modelLists = await getModelSelectionLists();
127
+ const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
126
128
  if (keyboard.inline_keyboard.length === 0) {
127
129
  await ctx.reply(t("model.menu.empty"));
128
130
  return;
129
131
  }
130
- const displayName = formatModelForDisplay(currentModel.providerID, currentModel.modelID);
131
- const text = t("model.menu.current", { name: displayName });
132
+ const text = buildModelSelectionMenuText(modelLists);
132
133
  await replyWithInlineMenu(ctx, {
133
134
  menuKind: "model",
134
135
  text,
135
136
  keyboard,
136
137
  });
137
- if (isOnlyConfigDefaultModel(favorites)) {
138
- await ctx.reply(t("model.menu.favorites_hint"));
139
- }
140
138
  }
141
139
  catch (err) {
142
140
  logger.error("[ModelHandler] Error showing model menu:", err);
@@ -80,7 +80,10 @@ async function downloadTelegramFile(ctx, fileId) {
80
80
  logger.debug(`[Voice] Downloading file: ${file.file_path} (${file.file_size ?? "?"} bytes)`);
81
81
  const buffer = await downloadTelegramFileByUrl(fileUrl);
82
82
  // Extract filename from file_path (e.g., "voice/file_123.oga" -> "file_123.oga")
83
- const filename = file.file_path.split("/").pop() || "audio.ogg";
83
+ let filename = file.file_path.split("/").pop() || "audio.ogg";
84
+ if (filename.endsWith(".oga")) {
85
+ filename = filename.slice(0, -4) + ".ogg";
86
+ }
84
87
  logger.debug(`[Voice] Downloaded file: ${filename} (${buffer.length} bytes)`);
85
88
  return { buffer, filename };
86
89
  }
@@ -1,3 +1,3 @@
1
- export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s.+$/;
1
+ export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s[\s\S]+$/;
2
2
  // Keep support for both legacy "💭" and current "💡" prefix.
3
3
  export const VARIANT_BUTTON_TEXT_PATTERN = /^(💡|💭)\s.+$/;
package/dist/i18n/de.js CHANGED
@@ -70,14 +70,23 @@ export const de = {
70
70
  "projects.empty": "📭 Keine Projekte gefunden.\n\nÖffne ein Verzeichnis in OpenCode und erstelle mindestens eine Sitzung, dann erscheint es hier.",
71
71
  "projects.select": "Projekt auswählen:",
72
72
  "projects.select_with_current": "Projekt auswählen:\n\nAktuell: 🏗 {project}",
73
+ "projects.page_indicator": "Seite {current}/{total}",
74
+ "projects.prev_page": "⬅️ Zurück",
75
+ "projects.next_page": "Weiter ➡️",
73
76
  "projects.fetch_error": "🔴 OpenCode-Server ist nicht verfügbar oder beim Laden der Projekte ist ein Fehler aufgetreten.",
77
+ "projects.page_load_error": "Diese Seite konnte nicht geladen werden. Bitte versuche es erneut.",
74
78
  "projects.selected": "✅ Projekt ausgewählt: {project}\n\n📋 Sitzung wurde zurückgesetzt. Nutze /sessions oder /new für dieses Projekt.",
75
79
  "projects.select_error": "🔴 Projekt konnte nicht ausgewählt werden.",
76
80
  "sessions.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.",
77
81
  "sessions.empty": "📭 Keine Sitzungen gefunden.\n\nErstelle eine neue Sitzung mit /new.",
78
82
  "sessions.select": "Sitzung auswählen:",
83
+ "sessions.select_page": "Sitzung auswählen (Seite {page}):",
79
84
  "sessions.fetch_error": "🔴 OpenCode-Server ist nicht verfügbar oder beim Laden der Sitzungen ist ein Fehler aufgetreten.",
80
85
  "sessions.select_project_first": "🔴 Projekt ist nicht ausgewählt. Nutze /projects.",
86
+ "sessions.page_empty_callback": "Auf dieser Seite gibt es keine Sitzungen",
87
+ "sessions.page_load_error_callback": "Diese Seite kann nicht geladen werden. Bitte versuche es erneut.",
88
+ "sessions.button.prev_page": "⬅️ Zurück",
89
+ "sessions.button.next_page": "Weiter ➡️",
81
90
  "sessions.loading_context": "⏳ Lade Kontext und letzte Nachrichten...",
82
91
  "sessions.selected": "✅ Sitzung ausgewählt: {title}",
83
92
  "sessions.select_error": "🔴 Sitzung konnte nicht ausgewählt werden.",
@@ -115,12 +124,19 @@ export const de = {
115
124
  "agent.change_error_callback": "Modus konnte nicht geändert werden",
116
125
  "agent.menu.current": "Aktueller Modus: {name}\n\nModus auswählen:",
117
126
  "agent.menu.select": "Arbeitsmodus auswählen:",
127
+ "agent.menu.empty": "⚠️ Keine verfügbaren Agenten",
128
+ "agent.menu.error": "🔴 Agentenliste konnte nicht geladen werden",
118
129
  "model.changed_callback": "Modell geändert: {name}",
119
130
  "model.changed_message": "✅ Modell geändert zu: {name}",
120
131
  "model.change_error_callback": "Modell konnte nicht geändert werden",
121
132
  "model.menu.empty": "⚠️ Keine verfügbaren Modelle",
133
+ "model.menu.select": "Modell auswählen:",
122
134
  "model.menu.current": "Aktuelles Modell: {name}\n\nModell auswählen:",
123
- "model.menu.favorites_hint": "ℹ️ Die Modellliste wird aus Favoriten in OpenCode CLI erstellt.",
135
+ "model.menu.favorites_title": " Favoriten (Füge Modelle in OpenCode CLI zu den Favoriten hinzu)",
136
+ "model.menu.favorites_empty": "— Leer.",
137
+ "model.menu.recent_title": "🕘 Zuletzt verwendet",
138
+ "model.menu.recent_empty": "— Leer.",
139
+ "model.menu.favorites_hint": "ℹ️ Füge Modelle in OpenCode CLI zu den Favoriten hinzu, damit sie oben angezeigt werden.",
124
140
  "model.menu.error": "🔴 Modellliste konnte nicht geladen werden",
125
141
  "variant.model_not_selected_callback": "Fehler: Modell ist nicht ausgewählt",
126
142
  "variant.changed_callback": "Variante geändert: {name}",
package/dist/i18n/en.js CHANGED
@@ -70,14 +70,23 @@ export const en = {
70
70
  "projects.empty": "📭 No projects found.\n\nOpen a directory in OpenCode and create at least one session, then it will appear here.",
71
71
  "projects.select": "Select a project:",
72
72
  "projects.select_with_current": "Select a project:\n\nCurrent: 🏗 {project}",
73
+ "projects.page_indicator": "Page {current}/{total}",
74
+ "projects.prev_page": "⬅️ Previous",
75
+ "projects.next_page": "Next ➡️",
73
76
  "projects.fetch_error": "🔴 OpenCode Server is unavailable or an error occurred while loading projects.",
77
+ "projects.page_load_error": "Cannot load this page. Please try again.",
74
78
  "projects.selected": "✅ Project selected: {project}\n\n📋 Session was reset. Use /sessions or /new for this project.",
75
79
  "projects.select_error": "🔴 Failed to select project.",
76
80
  "sessions.project_not_selected": "🏗 Project is not selected.\n\nFirst select a project with /projects.",
77
81
  "sessions.empty": "📭 No sessions found.\n\nCreate a new session with /new.",
78
82
  "sessions.select": "Select a session:",
83
+ "sessions.select_page": "Select a session (page {page}):",
79
84
  "sessions.fetch_error": "🔴 OpenCode Server is unavailable or an error occurred while loading sessions.",
80
85
  "sessions.select_project_first": "🔴 Project is not selected. Use /projects.",
86
+ "sessions.page_empty_callback": "No sessions on this page",
87
+ "sessions.page_load_error_callback": "Cannot load this page. Please try again.",
88
+ "sessions.button.prev_page": "⬅️ Prev",
89
+ "sessions.button.next_page": "Next ➡️",
81
90
  "sessions.loading_context": "⏳ Loading context and latest messages...",
82
91
  "sessions.selected": "✅ Session selected: {title}",
83
92
  "sessions.select_error": "🔴 Failed to select session.",
@@ -115,12 +124,19 @@ export const en = {
115
124
  "agent.change_error_callback": "Failed to change mode",
116
125
  "agent.menu.current": "Current mode: {name}\n\nSelect mode:",
117
126
  "agent.menu.select": "Select work mode:",
127
+ "agent.menu.empty": "⚠️ No available agents",
128
+ "agent.menu.error": "🔴 Failed to get agents list",
118
129
  "model.changed_callback": "Model changed: {name}",
119
130
  "model.changed_message": "✅ Model changed to: {name}",
120
131
  "model.change_error_callback": "Failed to change model",
121
132
  "model.menu.empty": "⚠️ No available models",
133
+ "model.menu.select": "Select model:",
122
134
  "model.menu.current": "Current model: {name}\n\nSelect model:",
123
- "model.menu.favorites_hint": "ℹ️ The model list is built from favorites in OpenCode CLI.",
135
+ "model.menu.favorites_title": " Favorites (Add models to favorites in OpenCode CLI)",
136
+ "model.menu.favorites_empty": "— Empty.",
137
+ "model.menu.recent_title": "🕘 Recent",
138
+ "model.menu.recent_empty": "— Empty.",
139
+ "model.menu.favorites_hint": "ℹ️ Add models to favorites in OpenCode CLI to keep them at the top.",
124
140
  "model.menu.error": "🔴 Failed to get models list",
125
141
  "variant.model_not_selected_callback": "Error: model is not selected",
126
142
  "variant.changed_callback": "Variant changed: {name}",
package/dist/i18n/es.js CHANGED
@@ -70,14 +70,23 @@ export const es = {
70
70
  "projects.empty": "📭 No se encontraron proyectos.\n\nAbre un directorio en OpenCode y crea al menos una sesión; entonces aparecerá aquí.",
71
71
  "projects.select": "Selecciona un proyecto:",
72
72
  "projects.select_with_current": "Selecciona un proyecto:\n\nActual: 🏗 {project}",
73
+ "projects.page_indicator": "Página {current}/{total}",
74
+ "projects.prev_page": "⬅️ Anterior",
75
+ "projects.next_page": "Siguiente ➡️",
73
76
  "projects.fetch_error": "🔴 OpenCode Server no está disponible u ocurrió un error al cargar los proyectos.",
77
+ "projects.page_load_error": "No se puede cargar esta página. Inténtalo de nuevo.",
74
78
  "projects.selected": "✅ Proyecto seleccionado: {project}\n\n📋 La sesión se reinició. Usa /sessions o /new para este proyecto.",
75
79
  "projects.select_error": "🔴 No se pudo seleccionar el proyecto.",
76
80
  "sessions.project_not_selected": "🏗 No hay un proyecto seleccionado.\n\nPrimero selecciona un proyecto con /projects.",
77
81
  "sessions.empty": "📭 No se encontraron sesiones.\n\nCrea una sesión nueva con /new.",
78
82
  "sessions.select": "Selecciona una sesión:",
83
+ "sessions.select_page": "Selecciona una sesión (página {page}):",
79
84
  "sessions.fetch_error": "🔴 OpenCode Server no está disponible u ocurrió un error al cargar las sesiones.",
80
85
  "sessions.select_project_first": "🔴 No hay un proyecto seleccionado. Usa /projects.",
86
+ "sessions.page_empty_callback": "No hay sesiones en esta página",
87
+ "sessions.page_load_error_callback": "No se puede cargar esta página. Inténtalo de nuevo.",
88
+ "sessions.button.prev_page": "⬅️ Anterior",
89
+ "sessions.button.next_page": "Siguiente ➡️",
81
90
  "sessions.loading_context": "⏳ Cargando contexto y los últimos mensajes...",
82
91
  "sessions.selected": "✅ Sesión seleccionada: {title}",
83
92
  "sessions.select_error": "🔴 No se pudo seleccionar la sesión.",
@@ -115,12 +124,19 @@ export const es = {
115
124
  "agent.change_error_callback": "No se pudo cambiar el modo",
116
125
  "agent.menu.current": "Modo actual: {name}\n\nSelecciona el modo:",
117
126
  "agent.menu.select": "Selecciona el modo de trabajo:",
127
+ "agent.menu.empty": "⚠️ No hay agentes disponibles",
128
+ "agent.menu.error": "🔴 No se pudo obtener la lista de agentes",
118
129
  "model.changed_callback": "Modelo cambiado: {name}",
119
130
  "model.changed_message": "✅ Modelo cambiado a: {name}",
120
131
  "model.change_error_callback": "No se pudo cambiar el modelo",
121
132
  "model.menu.empty": "⚠️ No hay modelos disponibles",
133
+ "model.menu.select": "Selecciona el modelo:",
122
134
  "model.menu.current": "Modelo actual: {name}\n\nSelecciona el modelo:",
123
- "model.menu.favorites_hint": "ℹ️ La lista de modelos se genera a partir de favoritos en OpenCode CLI.",
135
+ "model.menu.favorites_title": " Favoritos (Agrega modelos a favoritos en OpenCode CLI)",
136
+ "model.menu.favorites_empty": "— Vacío.",
137
+ "model.menu.recent_title": "🕘 Recientes",
138
+ "model.menu.recent_empty": "— Vacío.",
139
+ "model.menu.favorites_hint": "ℹ️ Agrega modelos a favoritos en OpenCode CLI para mantenerlos arriba de la lista.",
124
140
  "model.menu.error": "🔴 No se pudo obtener la lista de modelos",
125
141
  "variant.model_not_selected_callback": "Error: no hay un modelo seleccionado",
126
142
  "variant.changed_callback": "Variante cambiada: {name}",
package/dist/i18n/ru.js CHANGED
@@ -70,14 +70,23 @@ export const ru = {
70
70
  "projects.empty": "📭 Проектов нет.\n\nОткройте директорию в OpenCode и создайте хотя бы одну сессию, после этого она появится здесь.",
71
71
  "projects.select": "Выберите проект:",
72
72
  "projects.select_with_current": "Выберите проект:\n\nТекущий: 🏗 {project}",
73
+ "projects.page_indicator": "Страница {current}/{total}",
74
+ "projects.prev_page": "⬅️ Назад",
75
+ "projects.next_page": "Вперёд ➡️",
73
76
  "projects.fetch_error": "🔴 OpenCode Server недоступен или произошла ошибка при получении списка проектов.",
77
+ "projects.page_load_error": "Не удалось загрузить эту страницу. Попробуйте снова.",
74
78
  "projects.selected": "✅ Проект выбран: {project}\n\n📋 Сессия сброшена. Используйте /sessions или /new для работы с этим проектом.",
75
79
  "projects.select_error": "🔴 Ошибка при выборе проекта.",
76
80
  "sessions.project_not_selected": "🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.",
77
81
  "sessions.empty": "📭 Сессий нет.\n\nСоздайте новую сессию командой /new.",
78
82
  "sessions.select": "Выберите сессию:",
83
+ "sessions.select_page": "Выберите сессию (страница {page}):",
79
84
  "sessions.fetch_error": "🔴 OpenCode Server недоступен или произошла ошибка при получении списка сессий.",
80
85
  "sessions.select_project_first": "🔴 Проект не выбран. Используйте /projects.",
86
+ "sessions.page_empty_callback": "На этой странице нет сессий",
87
+ "sessions.page_load_error_callback": "Не удалось загрузить эту страницу. Пожалуйста, попробуйте снова.",
88
+ "sessions.button.prev_page": "⬅️ Назад",
89
+ "sessions.button.next_page": "Вперёд ➡️",
81
90
  "sessions.loading_context": "⏳ Загружаю контекст и последние сообщения...",
82
91
  "sessions.selected": "✅ Сессия выбрана: {title}",
83
92
  "sessions.select_error": "🔴 Ошибка при выборе сессии.",
@@ -115,12 +124,19 @@ export const ru = {
115
124
  "agent.change_error_callback": "Ошибка при смене режима",
116
125
  "agent.menu.current": "Текущий режим: {name}\n\nВыберите режим:",
117
126
  "agent.menu.select": "Выберите режим работы:",
127
+ "agent.menu.empty": "⚠️ Нет доступных агентов",
128
+ "agent.menu.error": "🔴 Не удалось получить список агентов",
118
129
  "model.changed_callback": "Модель изменена: {name}",
119
130
  "model.changed_message": "✅ Модель изменена на: {name}",
120
131
  "model.change_error_callback": "Ошибка при смене модели",
121
132
  "model.menu.empty": "⚠️ Нет доступных моделей",
133
+ "model.menu.select": "Выберите модель:",
122
134
  "model.menu.current": "Текущая модель: {name}\n\nВыберите модель:",
123
- "model.menu.favorites_hint": "ℹ️ Список моделей формируется из favorites в OpenCode CLI.",
135
+ "model.menu.favorites_title": " Избранное (Добавляйте модели в избранное через OpenCode CLI)",
136
+ "model.menu.favorites_empty": "— Список пуст.",
137
+ "model.menu.recent_title": "🕘 Недавние",
138
+ "model.menu.recent_empty": "— Список пуст.",
139
+ "model.menu.favorites_hint": "ℹ️ Добавляйте модели в избранное через OpenCode CLI, чтобы они были вверху списка.",
124
140
  "model.menu.error": "🔴 Не удалось получить список моделей",
125
141
  "variant.model_not_selected_callback": "Ошибка: модель не выбрана",
126
142
  "variant.changed_callback": "Вариант изменен: {name}",
package/dist/i18n/zh.js CHANGED
@@ -70,14 +70,23 @@ export const zh = {
70
70
  "projects.empty": "📭 未找到项目。\n\n在 OpenCode 中打开一个目录并至少创建一个会话,然后它会出现在这里。",
71
71
  "projects.select": "请选择一个项目:",
72
72
  "projects.select_with_current": "请选择一个项目:\n\n当前:🏗 {project}",
73
+ "projects.page_indicator": "第 {current}/{total} 页",
74
+ "projects.prev_page": "⬅️ 上一页",
75
+ "projects.next_page": "下一页 ➡️",
73
76
  "projects.fetch_error": "🔴 OpenCode 服务器不可用,或加载项目时发生错误。",
77
+ "projects.page_load_error": "无法加载此页面。请重试。",
74
78
  "projects.selected": "✅ 已选择项目:{project}\n\n📋 会话已重置。请在此项目中使用 /sessions 或 /new。",
75
79
  "projects.select_error": "🔴 选择项目失败。",
76
80
  "sessions.project_not_selected": "🏗 未选择项目。\n\n请先使用 /projects 选择一个项目。",
77
81
  "sessions.empty": "📭 未找到会话。\n\n使用 /new 创建新会话。",
78
82
  "sessions.select": "请选择一个会话:",
83
+ "sessions.select_page": "请选择一个会话(第 {page} 页):",
79
84
  "sessions.fetch_error": "🔴 OpenCode 服务器不可用,或加载会话时发生错误。",
80
85
  "sessions.select_project_first": "🔴 未选择项目。使用 /projects。",
86
+ "sessions.page_empty_callback": "这一页没有会话",
87
+ "sessions.page_load_error_callback": "无法加载此页面。请重试。",
88
+ "sessions.button.prev_page": "⬅️ 上一页",
89
+ "sessions.button.next_page": "下一页 ➡️",
81
90
  "sessions.loading_context": "⏳ 正在加载上下文和最新消息...",
82
91
  "sessions.selected": "✅ 已选择会话:{title}",
83
92
  "sessions.select_error": "🔴 选择会话失败。",
@@ -115,12 +124,19 @@ export const zh = {
115
124
  "agent.change_error_callback": "切换模式失败",
116
125
  "agent.menu.current": "当前模式:{name}\n\n请选择模式:",
117
126
  "agent.menu.select": "请选择工作模式:",
127
+ "agent.menu.empty": "⚠️ 没有可用的代理",
128
+ "agent.menu.error": "🔴 获取代理列表失败",
118
129
  "model.changed_callback": "模型已更改:{name}",
119
130
  "model.changed_message": "✅ 模型已切换为:{name}",
120
131
  "model.change_error_callback": "切换模型失败",
121
132
  "model.menu.empty": "⚠️ 没有可用模型",
133
+ "model.menu.select": "请选择模型:",
122
134
  "model.menu.current": "当前模型:{name}\n\n请选择模型:",
123
- "model.menu.favorites_hint": "ℹ️ 模型列表来自 OpenCode CLI 的收藏(favorites)。",
135
+ "model.menu.favorites_title": " 收藏(可在 OpenCode CLI 中将模型加入收藏)",
136
+ "model.menu.favorites_empty": "— 列表为空。",
137
+ "model.menu.recent_title": "🕘 最近使用",
138
+ "model.menu.recent_empty": "— 列表为空。",
139
+ "model.menu.favorites_hint": "ℹ️ 可在 OpenCode CLI 中将模型加入收藏,使其显示在列表顶部。",
124
140
  "model.menu.error": "🔴 获取模型列表失败",
125
141
  "variant.model_not_selected_callback": "错误:未选择模型",
126
142
  "variant.changed_callback": "变体已更改:{name}",
@@ -34,6 +34,20 @@ function normalizeFavoriteModels(state) {
34
34
  modelID: model.modelID,
35
35
  }));
36
36
  }
37
+ function normalizeRecentModels(state) {
38
+ if (!Array.isArray(state.recent)) {
39
+ return [];
40
+ }
41
+ return state.recent
42
+ .filter((model) => typeof model?.providerID === "string" &&
43
+ model.providerID.length > 0 &&
44
+ typeof model.modelID === "string" &&
45
+ model.modelID.length > 0)
46
+ .map((model) => ({
47
+ providerID: model.providerID,
48
+ modelID: model.modelID,
49
+ }));
50
+ }
37
51
  function getOpenCodeModelStatePath() {
38
52
  const xdgStateHome = process.env.XDG_STATE_HOME;
39
53
  if (xdgStateHome && xdgStateHome.trim().length > 0) {
@@ -43,38 +57,54 @@ function getOpenCodeModelStatePath() {
43
57
  return path.join(homeDir, ".local", "state", "opencode", "model.json");
44
58
  }
45
59
  /**
46
- * Get list of favorite models from OpenCode local state file
47
- * Falls back to env default model if file is unavailable or empty
60
+ * Get favorite and recent models from OpenCode local state file.
61
+ * Config model is always treated as favorite.
48
62
  */
49
- export async function getFavoriteModels() {
63
+ export async function getModelSelectionLists() {
50
64
  const envDefaultModel = getEnvDefaultModel();
51
65
  try {
52
66
  const fs = await import("fs/promises");
53
67
  const stateFilePath = getOpenCodeModelStatePath();
54
68
  const content = await fs.readFile(stateFilePath, "utf-8");
55
69
  const state = JSON.parse(content);
56
- const favorites = normalizeFavoriteModels(state);
70
+ const rawFavorites = normalizeFavoriteModels(state);
71
+ const favorites = envDefaultModel
72
+ ? dedupeModels([...rawFavorites, envDefaultModel])
73
+ : rawFavorites;
74
+ if (rawFavorites.length === 0 && envDefaultModel) {
75
+ logger.info(`[ModelManager] No favorites in ${stateFilePath}, using config model as favorite`);
76
+ }
57
77
  if (favorites.length === 0) {
58
- if (envDefaultModel) {
59
- logger.info(`[ModelManager] No favorites in ${stateFilePath}, using env default model`);
60
- return [envDefaultModel];
61
- }
62
78
  logger.warn(`[ModelManager] No favorites in ${stateFilePath}`);
63
- return [];
64
79
  }
65
- const merged = envDefaultModel ? dedupeModels([...favorites, envDefaultModel]) : favorites;
66
- logger.debug(`[ModelManager] Loaded ${merged.length} favorite models from ${stateFilePath}`);
67
- return merged;
80
+ const favoriteKeys = new Set(favorites.map((model) => `${model.providerID}/${model.modelID}`));
81
+ const recent = dedupeModels(normalizeRecentModels(state)).filter((model) => !favoriteKeys.has(`${model.providerID}/${model.modelID}`));
82
+ logger.debug(`[ModelManager] Loaded model selection lists from ${stateFilePath}: favorites=${favorites.length}, recent=${recent.length}`);
83
+ return { favorites, recent };
68
84
  }
69
85
  catch (err) {
70
86
  if (envDefaultModel) {
71
- logger.warn("[ModelManager] Failed to load OpenCode favorites, using env default model:", err);
72
- return [envDefaultModel];
87
+ logger.warn("[ModelManager] Failed to load OpenCode model state, using config model as favorite:", err);
88
+ return {
89
+ favorites: [envDefaultModel],
90
+ recent: [],
91
+ };
73
92
  }
74
- logger.error("[ModelManager] Failed to load OpenCode favorites:", err);
75
- return [];
93
+ logger.error("[ModelManager] Failed to load OpenCode model state:", err);
94
+ return {
95
+ favorites: [],
96
+ recent: [],
97
+ };
76
98
  }
77
99
  }
100
+ /**
101
+ * Get list of favorite models from OpenCode local state file
102
+ * Falls back to env default model if file is unavailable or empty
103
+ */
104
+ export async function getFavoriteModels() {
105
+ const { favorites } = await getModelSelectionLists();
106
+ return favorites;
107
+ }
78
108
  /**
79
109
  * Get current model from settings or fallback to config
80
110
  * @returns Current model info
@@ -8,12 +8,10 @@
8
8
  * @returns Formatted string "providerID/modelID"
9
9
  */
10
10
  export function formatModelForButton(providerID, modelID) {
11
- const formatted = `${providerID}/${modelID}`;
12
- // Limit to ~30 characters for button width (excluding emoji)
13
- if (formatted.length > 30) {
14
- return `🤖 ${formatted.substring(0, 27)}...`;
15
- }
16
- return `🤖 ${formatted}`;
11
+ // If model name is too long, we only truncate the model part
12
+ const displayModelId = modelID.length > 20 ? `${modelID.substring(0, 17)}...` : modelID;
13
+ const displayProviderId = providerID.length > 15 ? `${providerID.substring(0, 12)}...` : providerID;
14
+ return `🤖 ${displayProviderId}\n${displayModelId}`;
17
15
  }
18
16
  /**
19
17
  * Format model for display in messages (full format)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",