@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 +23 -17
- package/dist/agent/manager.js +10 -14
- package/dist/bot/commands/projects.js +102 -18
- package/dist/bot/commands/sessions.js +108 -22
- package/dist/bot/commands/status.js +20 -1
- package/dist/bot/handlers/agent.js +20 -10
- package/dist/bot/handlers/model.js +24 -26
- package/dist/bot/handlers/voice.js +4 -1
- package/dist/bot/message-patterns.js +1 -1
- package/dist/i18n/de.js +17 -1
- package/dist/i18n/en.js +17 -1
- package/dist/i18n/es.js +17 -1
- package/dist/i18n/ru.js +17 -1
- package/dist/i18n/zh.js +17 -1
- package/dist/model/manager.js +46 -16
- package/dist/model/types.js +4 -6
- package/package.json +1 -1
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
|
-
|
|
14
|
+
Platforms: macOS, Windows, Linux
|
|
15
15
|
|
|
16
|
-
|
|
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
|
|
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 |
|
|
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` |
|
|
140
|
-
| `PROJECTS_LIST_LIMIT` |
|
|
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
|
|
181
|
+
The model picker uses OpenCode local model state (`favorite` + `recent`):
|
|
180
182
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
package/dist/agent/manager.js
CHANGED
|
@@ -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
|
|
18
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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 =
|
|
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:
|
|
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(
|
|
118
|
+
if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
|
|
59
119
|
return false;
|
|
60
120
|
}
|
|
61
|
-
const
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
text
|
|
106
|
-
|
|
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,
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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,
|
|
97
|
+
export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
99
98
|
const keyboard = new InlineKeyboard();
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
const keyboard = await buildModelSelectionMenu(currentModel,
|
|
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
|
|
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
|
-
|
|
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
|
}
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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/model/manager.js
CHANGED
|
@@ -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
|
|
47
|
-
*
|
|
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
|
|
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
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
72
|
-
return
|
|
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
|
|
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
|
package/dist/model/types.js
CHANGED
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
* @returns Formatted string "providerID/modelID"
|
|
9
9
|
*/
|
|
10
10
|
export function formatModelForButton(providerID, modelID) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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)
|