qwenproxy-cli 1.2.6 → 1.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -23,12 +23,30 @@ function parseEnvAccounts(): QwenAccount[] {
23
23
  const envAccounts = process.env.QWEN_ACCOUNTS;
24
24
  if (!envAccounts) return [];
25
25
 
26
- const separator = envAccounts.includes(";") ? ";" : ",";
26
+ const clean = (s: string) => s.trim().replace(/^[,;\s"'`]+|[,;\s"'`]+$/g, "").trim();
27
27
 
28
- return envAccounts
29
- .split(separator)
28
+ const lines = envAccounts.split(/[\r\n;]+/);
29
+ const rawEntries: string[] = [];
30
+
31
+ for (const rawLine of lines) {
32
+ const line = clean(rawLine);
33
+ if (!line || line.startsWith("#") || line.startsWith("//")) continue;
34
+
35
+ // Check if line contains multiple accounts separated by comma (e.g. "a@b.com:p1, c@d.com:p2")
36
+ if (line.includes(",") && (line.match(/@/g) || []).length > 1) {
37
+ for (const seg of line.split(",")) {
38
+ const trimmed = clean(seg);
39
+ if (trimmed) rawEntries.push(trimmed);
40
+ }
41
+ } else {
42
+ const trimmed = clean(line);
43
+ if (trimmed) rawEntries.push(trimmed);
44
+ }
45
+ }
46
+
47
+ return rawEntries
30
48
  .map((entry, index) => {
31
- const trimmed = entry.trim();
49
+ const trimmed = clean(entry);
32
50
  const colonIdx = trimmed.indexOf(":");
33
51
  if (colonIdx === -1) {
34
52
  console.warn(
@@ -36,8 +54,8 @@ function parseEnvAccounts(): QwenAccount[] {
36
54
  );
37
55
  return null;
38
56
  }
39
- const email = trimmed.substring(0, colonIdx);
40
- const password = trimmed.substring(colonIdx + 1);
57
+ const email = clean(trimmed.substring(0, colonIdx));
58
+ const password = clean(trimmed.substring(colonIdx + 1));
41
59
  if (!email || !password) {
42
60
  console.warn(
43
61
  `[Accounts] Invalid QWEN_ACCOUNTS entry at index ${index}: "${trimmed}"`,
@@ -46,8 +64,8 @@ function parseEnvAccounts(): QwenAccount[] {
46
64
  }
47
65
  return {
48
66
  id: generateId(email),
49
- email: email.trim(),
50
- password: password.trim(),
67
+ email,
68
+ password,
51
69
  };
52
70
  })
53
71
  .filter((a): a is QwenAccount => a !== null);
@@ -160,26 +178,27 @@ export function parseBatchAccounts(rawInput: string): {
160
178
  let email = "";
161
179
  let password = "";
162
180
 
181
+ const clean = (s: string) => s.trim().replace(/^[,;\s"'`]+|[,;\s"'`]+$/g, "").trim();
163
182
  if (seg.includes("---")) {
164
183
  const parts = seg.split("---");
165
- email = parts[0].trim();
166
- password = parts.slice(1).join("---").trim();
184
+ email = clean(parts[0]);
185
+ password = clean(parts.slice(1).join("---"));
167
186
  } else if (seg.includes("\t")) {
168
187
  const parts = seg.split("\t");
169
- email = parts[0].trim();
170
- password = parts.slice(1).join("\t").trim();
188
+ email = clean(parts[0]);
189
+ password = clean(parts.slice(1).join("\t"));
171
190
  } else if (seg.includes(" | ")) {
172
191
  const parts = seg.split(" | ");
173
- email = parts[0].trim();
174
- password = parts.slice(1).join(" | ").trim();
192
+ email = clean(parts[0]);
193
+ password = clean(parts.slice(1).join(" | "));
175
194
  } else if (seg.includes(":")) {
176
195
  const colonIdx = seg.indexOf(":");
177
- email = seg.slice(0, colonIdx).trim();
178
- password = seg.slice(colonIdx + 1).trim();
196
+ email = clean(seg.slice(0, colonIdx));
197
+ password = clean(seg.slice(colonIdx + 1));
179
198
  } else if (seg.includes(",")) {
180
199
  const commaIdx = seg.indexOf(",");
181
- email = seg.slice(0, commaIdx).trim();
182
- password = seg.slice(commaIdx + 1).trim();
200
+ email = clean(seg.slice(0, commaIdx));
201
+ password = clean(seg.slice(commaIdx + 1));
183
202
  } else {
184
203
  invalid.push(seg);
185
204
  continue;
@@ -174,7 +174,7 @@ const envSchema = z
174
174
  DELETE_ALL_CHATS_ON_SHUTDOWN: z.string().default("false"),
175
175
  AUTO_CLEAN_CHATS_ON_STARTUP: z.string().default("true"),
176
176
  AUTO_CLEAN_ORPHAN_CHATS: z.string().default("true"),
177
- AUTO_CLEAN_CHAT_MAX_AGE_HOURS: z.string().default("24"),
177
+ AUTO_CLEAN_CHAT_MAX_AGE_HOURS: z.string().default("168"),
178
178
  // The Baxia WAF scores live page behavior (pointer/scroll events, open
179
179
  // session) — an account whose page sits frozen for minutes returns a low
180
180
  // trust score and gets TMD-challenged on the next request. On by default;
@@ -384,7 +384,7 @@ export const config = {
384
384
  autoCleanOrphanChats: env.AUTO_CLEAN_ORPHAN_CHATS !== "false",
385
385
  autoCleanChatMaxAgeHours: Math.max(
386
386
  1,
387
- parseInt(env.AUTO_CLEAN_CHAT_MAX_AGE_HOURS) || 24,
387
+ parseInt(env.AUTO_CLEAN_CHAT_MAX_AGE_HOURS) || 168,
388
388
  ),
389
389
  sendBxUa: env.QWEN_SEND_BX_UA === "true",
390
390
  /** Deployed web bundle version sent as the `version` API header. */
@@ -80,9 +80,11 @@ export function recordServerLog(level: "INFO" | "WARN" | "ERROR", text: string):
80
80
  .replace(/([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}]\uFE0F?)\s{2,}/gu, "$1 ");
81
81
  if (!line) continue;
82
82
 
83
- // Prevent identical consecutive duplicate logs in the same second
84
- const last = logHistory[logHistory.length - 1];
85
- if (last && last.time === time && last.level === level && last.message === line) {
83
+ // Prevent identical duplicate logs within the same second window
84
+ const isDuplicate = logHistory
85
+ .slice(-10)
86
+ .some((entry) => entry.time === time && entry.level === level && entry.message === line);
87
+ if (isDuplicate) {
86
88
  continue;
87
89
  }
88
90
 
@@ -1150,27 +1150,19 @@ async function tryCreateStreamWithRetry(
1150
1150
  const hasRequestPersonalization =
1151
1151
  params.requestPersonalizationInstruction !== null &&
1152
1152
  params.requestPersonalizationInstruction !== undefined;
1153
- const releasePersonalization = hasRequestPersonalization
1154
- ? await acquirePersonalizationLock(currentAccountId)
1155
- : null;
1156
- // A same-session retry (or client disconnect) can abort this request
1157
- // while the personalization sync is still stuck on a hung page op
1158
- // (closed Playwright context / WAF). The sync never resolves, so the
1159
- // finally below would not run and the mutex would stay held for
1160
- // minutes, blocking the retry until its 60s acquire timeout fires.
1161
- // Release the lock immediately on abort instead.
1162
- const onPersonalizationAbort = () => releasePersonalization?.();
1163
- if (combinedSignal.aborted) {
1164
- onPersonalizationAbort();
1165
- } else {
1166
- combinedSignal.addEventListener("abort", onPersonalizationAbort, {
1167
- once: true,
1168
- });
1169
- }
1170
1153
  let result: Awaited<ReturnType<typeof createQwenStream>>;
1171
- try {
1172
- let promptForUpstream = effectivePrompt;
1173
- if (hasRequestPersonalization) {
1154
+ let promptForUpstream = effectivePrompt;
1155
+ if (hasRequestPersonalization) {
1156
+ const releasePersonalization = await acquirePersonalizationLock(currentAccountId);
1157
+ const onPersonalizationAbort = () => releasePersonalization();
1158
+ if (combinedSignal.aborted) {
1159
+ onPersonalizationAbort();
1160
+ } else {
1161
+ combinedSignal.addEventListener("abort", onPersonalizationAbort, {
1162
+ once: true,
1163
+ });
1164
+ }
1165
+ try {
1174
1166
  // Let the hash-based cache in syncQwenRequestPersonalization decide
1175
1167
  // whether to actually POST. A new chat does not imply the account's
1176
1168
  // global settings were reset — only session refresh or profile reset
@@ -1244,12 +1236,25 @@ async function tryCreateStreamWithRetry(
1244
1236
  `personalization sync not confirmed for ${currentAccountEmail}: ${syncFailure ?? "settings response did not confirm the instruction"}`,
1245
1237
  );
1246
1238
  }
1247
- }
1248
- if (logger.isLevelEnabled("info")) {
1249
- console.log(
1250
- `⏱️ [Chat] Acquire: sync | account=${currentAccountEmail} | +${Date.now() - acquireStartedAt}ms`,
1251
- );
1252
- }
1239
+ } finally {
1240
+ combinedSignal.removeEventListener("abort", onPersonalizationAbort);
1241
+ releasePersonalization();
1242
+ }
1243
+
1244
+ if (logger.isLevelEnabled("info")) {
1245
+ console.log(
1246
+ `⏱️ [Chat] Acquire: sync | account=${currentAccountEmail} | +${Date.now() - acquireStartedAt}ms`,
1247
+ );
1248
+ }
1249
+
1250
+ if (combinedSignal.aborted) {
1251
+ accountLease?.release();
1252
+ return {
1253
+ success: false,
1254
+ error: new ClientAbortedError("client aborted during personalization sync"),
1255
+ };
1256
+ }
1257
+ }
1253
1258
 
1254
1259
  assertPromptWithinLimits(
1255
1260
  promptForUpstream,
@@ -1350,13 +1355,6 @@ async function tryCreateStreamWithRetry(
1350
1355
  },
1351
1356
  };
1352
1357
  }
1353
- } finally {
1354
- combinedSignal.removeEventListener(
1355
- "abort",
1356
- onPersonalizationAbort,
1357
- );
1358
- releasePersonalization?.();
1359
- }
1360
1358
 
1361
1359
  // Client cancelled (or a same-session retry superseded us) during the
1362
1360
  // (potentially slow) personalization sync. Bail before createQwenStream
@@ -220,6 +220,7 @@ export function isAccountInitializationError(err: unknown): boolean {
220
220
  return (
221
221
  code === "acquire_deadline" ||
222
222
  message.includes("acquire deadline") ||
223
+ message.includes("header capture timed out") ||
223
224
  message.includes("header capture returned incomplete anti-fraud headers") ||
224
225
  message.includes("required qwen anti-fraud headers are unavailable") ||
225
226
  message.includes("playwright not initialized for account") ||
@@ -479,6 +480,28 @@ export function classifyRetryAction(
479
480
  });
480
481
  }
481
482
 
483
+ // Upstream 401 / Unauthorized on chat creation or API requests:
484
+ // Account session is invalid or expired. Cool down account with AuthInitFailed so the
485
+ // proxy rotates to a valid account instead of looping endlessly on 503s.
486
+ if (
487
+ code === "createchatinvalidresponse" ||
488
+ code === "createchatfailed" ||
489
+ code === "unauthorized" ||
490
+ message.includes("401 não autorizado") ||
491
+ message.includes("não tem permissão para acessar") ||
492
+ message.includes("401 unauthorized") ||
493
+ message.includes('"code":"unauthorized"') ||
494
+ message.includes('"code": "unauthorized"')
495
+ ) {
496
+ return makeRetryAction("account_initialization_failed", {
497
+ switchAccount: true,
498
+ forceNewChat: true,
499
+ retryWithFullPrompt: true,
500
+ retryAfterMs: Math.min(baseDelayMs, 1_000),
501
+ accountCooldownMs: config.concurrency.initFailureCooldownMs,
502
+ accountCooldownReason: "AuthInitFailed",
503
+ });
504
+ }
482
505
  // Specialized recoveries first (even if wrapped as RetryableQwenStreamError)
483
506
  // Corrupted chat history must win over broad "invalid input" matches.
484
507
  // Try a fresh chat on the SAME account first — the corruption is in the
@@ -14,11 +14,11 @@ import {
14
14
  closeAllPlaywright,
15
15
  getActivePlaywrightAccountIds,
16
16
  } from "./playwright.ts";
17
+ import { isChatSessionActive } from "./qwen-thread-state.ts";
18
+ import { hasActiveAccountLease, isAccountBusy } from "../core/account-concurrency.ts";
17
19
  import { isAuthMockEnabled } from "./auth-playwright.ts";
18
20
  import { maskEmail, logger } from "../core/logger.ts";
19
21
  import { config } from "../core/config.ts";
20
- import { isAccountBusy } from "../core/account-concurrency.ts";
21
- import { isChatSessionActive } from "./qwen-thread-state.ts";
22
22
  import { sleep } from "./human-behavior.ts";
23
23
  import { metrics } from "../core/metrics.ts";
24
24
 
@@ -246,11 +246,16 @@ export async function cleanOldChatsForAccount(
246
246
  : new Date(chat.updated_at).getTime();
247
247
 
248
248
  if (now - updatedMs >= maxAgeMs) {
249
+ // Cooperative yield: if account becomes active with a request, stop cleanup immediately
250
+ if (hasActiveAccountLease(accountId) || isAccountBusy(accountId)) {
251
+ break;
252
+ }
253
+
249
254
  const ok = await deleteSingleQwenChat(accountId, chat.id);
250
255
  if (ok) {
251
256
  cleaned++;
252
257
  metrics.increment("chats.cleaned");
253
- await sleep(300);
258
+ await sleep(150);
254
259
  }
255
260
  }
256
261
  }
@@ -281,6 +286,8 @@ export function scheduleStartupChatCleanup(): void {
281
286
  try {
282
287
  const activeIds = getActivePlaywrightAccountIds();
283
288
  for (const accountId of activeIds) {
289
+ // Yield to active requests: skip if account is busy serving a stream or lease
290
+ if (hasActiveAccountLease(accountId) || isAccountBusy(accountId)) continue;
284
291
  await cleanOldChatsForAccount(accountId);
285
292
  await sleep(1_000);
286
293
  }
@@ -352,10 +352,7 @@ export async function isPageLoggedIn(
352
352
  const res = await fetch("/api/v1/auths/", { method: "GET" });
353
353
  return res.status === 200;
354
354
  } catch {
355
- const btn = document.querySelector(
356
- ".header-right-auth-button, button.header-right-auth-button, a[href*='/auth'], a[href*='/login']",
357
- );
358
- return !btn || (btn as HTMLElement).offsetWidth === 0;
355
+ return false;
359
356
  }
360
357
  })
361
358
  .catch(() => false);
@@ -1844,8 +1841,9 @@ async function loginViaApi(
1844
1841
  .digest("hex");
1845
1842
  const signinUrl = qwenUrl("/api/v2/auths/signin");
1846
1843
 
1844
+ const requestId = crypto.randomUUID();
1847
1845
  const result = await page.evaluate(
1848
- async ({ email, password, signinUrl }) => {
1846
+ async ({ email, password, signinUrl, requestId }) => {
1849
1847
  try {
1850
1848
  const response = await fetch(signinUrl, {
1851
1849
  method: "POST",
@@ -1855,7 +1853,7 @@ async function loginViaApi(
1855
1853
  "content-type": "application/json",
1856
1854
  source: "web",
1857
1855
  timezone: new Date().toString().split(" (")[0],
1858
- "x-request-id": crypto.randomUUID(),
1856
+ "x-request-id": requestId,
1859
1857
  },
1860
1858
  body: JSON.stringify({ email, password, login_type: "email" }),
1861
1859
  });
@@ -1865,7 +1863,7 @@ async function loginViaApi(
1865
1863
  return { ok: false, error: e.message };
1866
1864
  }
1867
1865
  },
1868
- { email, password: hashedPassword, signinUrl },
1866
+ { email, password: hashedPassword, signinUrl, requestId },
1869
1867
  );
1870
1868
 
1871
1869
  if (result.data) {
@@ -2606,7 +2604,9 @@ async function refreshHeadersInternal(
2606
2604
  ),
2607
2605
  });
2608
2606
  const url = page.url();
2609
- if (url.includes("auth") || url.includes("login")) {
2607
+ const isAuthUrl = url.includes("auth") || url.includes("login");
2608
+ const isLoggedIn = isAuthUrl ? false : await isPageLoggedIn(page, 5_000);
2609
+ if (isAuthUrl || !isLoggedIn) {
2610
2610
  console.warn(
2611
2611
  `⚠️ [Playwright] Session expired during refresh for ${accountId}, re-authenticating...`,
2612
2612
  );
@@ -1793,8 +1793,9 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
1793
1793
  const result = await withAccountPage(
1794
1794
  accountId,
1795
1795
  async (page) => {
1796
+ const requestId = crypto.randomUUID();
1796
1797
  const response = await page.evaluate(
1797
- async ({ payload, timeoutMs }: { payload: any; timeoutMs: number }) => {
1798
+ async ({ payload, timeoutMs, requestId }: { payload: any; timeoutMs: number; requestId: string }) => {
1798
1799
  const controller = new AbortController();
1799
1800
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1800
1801
  try {
@@ -1805,7 +1806,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
1805
1806
  headers: {
1806
1807
  accept: "application/json, text/plain, */*",
1807
1808
  "content-type": "application/json",
1808
- "x-request-id": crypto.randomUUID(),
1809
+ "x-request-id": requestId,
1809
1810
  timezone: new Date().toString().split(" (")[0],
1810
1811
  source: "web",
1811
1812
  },
@@ -1818,7 +1819,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
1818
1819
  clearTimeout(timeoutId);
1819
1820
  }
1820
1821
  },
1821
- { payload, timeoutMs: config.timeouts.http },
1822
+ { payload, timeoutMs: config.timeouts.http, requestId },
1822
1823
  );
1823
1824
  return response;
1824
1825
  },
@@ -2152,25 +2153,29 @@ export async function fetchQwenModels(
2152
2153
  const result = await withAccountPage(
2153
2154
  accountId,
2154
2155
  async (page) => {
2155
- const response = await page.evaluate(async (timeoutMs: number) => {
2156
- const controller = new AbortController();
2157
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2158
- try {
2159
- const resp = await fetch("https://chat.qwen.ai/api/models", {
2160
- method: "GET",
2161
- headers: {
2162
- accept: "application/json, text/plain, */*",
2163
- "x-request-id": crypto.randomUUID(),
2164
- timezone: new Date().toString().split(" (")[0],
2165
- source: "web",
2166
- },
2167
- signal: controller.signal,
2168
- });
2169
- return { status: resp.status, body: await resp.text() };
2170
- } finally {
2171
- clearTimeout(timeoutId);
2172
- }
2173
- }, config.timeouts.http);
2156
+ const requestId = crypto.randomUUID();
2157
+ const response = await page.evaluate(
2158
+ async ({ timeoutMs, requestId }: { timeoutMs: number; requestId: string }) => {
2159
+ const controller = new AbortController();
2160
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2161
+ try {
2162
+ const resp = await fetch("https://chat.qwen.ai/api/models", {
2163
+ method: "GET",
2164
+ headers: {
2165
+ accept: "application/json, text/plain, */*",
2166
+ "x-request-id": requestId,
2167
+ timezone: new Date().toString().split(" (")[0],
2168
+ source: "web",
2169
+ },
2170
+ signal: controller.signal,
2171
+ });
2172
+ return { status: resp.status, body: await resp.text() };
2173
+ } finally {
2174
+ clearTimeout(timeoutId);
2175
+ }
2176
+ },
2177
+ { timeoutMs: config.timeouts.http, requestId },
2178
+ );
2174
2179
  return response;
2175
2180
  },
2176
2181
  );
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  export function syncClaudeCode(options: SyncOptions): ClientSyncResult {
7
7
  const { filePath, apiKey, baseUrl, model = "qwen3.8-max" } = options;
@@ -27,8 +27,8 @@ export function syncClaudeCode(options: SyncOptions): ClientSyncResult {
27
27
  ANTHROPIC_AUTH_TOKEN: apiKey,
28
28
  ANTHROPIC_MODEL: model,
29
29
  ANTHROPIC_CUSTOM_MODEL_OPTION: model,
30
- ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: "Qwen 3.8 Max (1M Context)",
31
- ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION: "QwenProxy model qwen3.8-max - 1M context window",
30
+ ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: formatModelDisplayName(model),
31
+ ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION: `QwenProxy ${model}`,
32
32
  ANTHROPIC_DEFAULT_SONNET_MODEL: model,
33
33
  ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3.7-plus",
34
34
  ANTHROPIC_DEFAULT_OPUS_MODEL: model,
package/src/sync/codex.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function updateTopLevelKey(content: string, key: string, value: string | number): string {
7
7
  // Find first section header [section]
@@ -75,8 +75,8 @@ experimental_bearer_token = "${apiKey}"
75
75
  catalog.models.unshift({
76
76
  ...template,
77
77
  slug: model,
78
- display_name: `Qwen (${model})`,
79
- description: `QwenProxy ${model} with 1,000,000 token context window`,
78
+ display_name: formatModelDisplayName(model),
79
+ description: `QwenProxy ${model}`,
80
80
  context_window: 1000000,
81
81
  max_context_window: 1000000,
82
82
  });
package/src/sync/index.ts CHANGED
@@ -462,6 +462,9 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
462
462
  const apiKey = resolveApiKey(options.apiKey, config.apiKey);
463
463
  const { anthropicBaseUrl, openaiBaseUrl } = resolveBaseUrls(port, host);
464
464
  const stateFilePath = options.stateFilePath || getDefaultStateFilePath();
465
+ const selectedModel = options.model || "qwen3.8-max";
466
+ const allModels = options.models && options.models.length > 0 ? options.models : undefined;
467
+ const syncModels = options.syncAllModels !== false && allModels ? allModels : [selectedModel];
465
468
 
466
469
  const results: SyncAllResult = {
467
470
  apiKey,
@@ -483,6 +486,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
483
486
  filePath: paths.claudeCode,
484
487
  apiKey,
485
488
  baseUrl: anthropicBaseUrl,
489
+ model: selectedModel,
486
490
  });
487
491
  results.clients.claudeCode = claudeRes;
488
492
  if (claudeRes.success && claudeRes.backupPath) {
@@ -503,6 +507,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
503
507
  apiKey,
504
508
  baseUrl: openaiBaseUrl,
505
509
  setActive: options.setActive ?? true,
510
+ model: selectedModel,
506
511
  });
507
512
  results.clients.codex = codexRes;
508
513
  if (codexRes.success && codexRes.backupPath) {
@@ -522,6 +527,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
522
527
  filePath: paths.openCode,
523
528
  apiKey,
524
529
  baseUrl: openaiBaseUrl,
530
+ model: selectedModel,
531
+ models: syncModels,
525
532
  });
526
533
  results.clients.openCode = openCodeRes;
527
534
  if (openCodeRes.success && openCodeRes.backupPath) {
@@ -541,6 +548,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
541
548
  filePath: paths.omp,
542
549
  apiKey,
543
550
  baseUrl: openaiBaseUrl,
551
+ model: selectedModel,
552
+ models: syncModels,
544
553
  });
545
554
  results.clients.omp = ompRes;
546
555
  if (ompRes.success && ompRes.backupPath) {
@@ -560,6 +569,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
560
569
  filePath: paths.hermes,
561
570
  apiKey,
562
571
  baseUrl: openaiBaseUrl,
572
+ model: selectedModel,
563
573
  });
564
574
  results.clients.hermes = hermesRes;
565
575
  if (hermesRes.success && hermesRes.backupPath) {
@@ -579,6 +589,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
579
589
  filePath: paths.openClaw,
580
590
  apiKey,
581
591
  baseUrl: openaiBaseUrl,
592
+ model: selectedModel,
593
+ models: syncModels,
582
594
  });
583
595
  results.clients.openClaw = openClawRes;
584
596
  if (openClawRes.success && openClawRes.backupPath) {
@@ -599,6 +611,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
599
611
  apiKey,
600
612
  baseUrl: openaiBaseUrl,
601
613
  setActive: options.setActive ?? true,
614
+ model: selectedModel,
615
+ models: syncModels,
602
616
  });
603
617
  results.clients.kilo = kiloRes;
604
618
  if (kiloRes.success && kiloRes.backupPath) {
@@ -618,6 +632,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
618
632
  filePath: paths.cline,
619
633
  apiKey,
620
634
  baseUrl: openaiBaseUrl,
635
+ model: selectedModel,
621
636
  });
622
637
  results.clients.cline = clineRes;
623
638
  if (clineRes.success && clineRes.backupPath) {
@@ -638,6 +653,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
638
653
  apiKey,
639
654
  baseUrl: openaiBaseUrl,
640
655
  setActive: options.setActive ?? true,
656
+ model: selectedModel,
657
+ models: syncModels,
641
658
  });
642
659
  results.clients.zed = zedRes;
643
660
  if (zedRes.success && zedRes.backupPath) {
@@ -657,6 +674,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
657
674
  filePath: paths.aider,
658
675
  apiKey,
659
676
  baseUrl: openaiBaseUrl,
677
+ model: selectedModel,
660
678
  });
661
679
  results.clients.aider = aiderRes;
662
680
  if (aiderRes.success && aiderRes.backupPath) {
package/src/sync/kilo.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function findKeyObjectSpan(content: string, key: string): { start: number; end: number; hasTrailingComma: boolean } | null {
7
7
  const regex = new RegExp(`"${key}"\\s*:\\s*\\{`);
@@ -90,21 +90,16 @@ function buildKiloProviderObject(
90
90
  baseUrl: string,
91
91
  apiKey: string,
92
92
  primaryModel: string = "qwen3.8-max",
93
+ models?: string[],
93
94
  ): Record<string, any> {
94
95
  const modelsObj: Record<string, any> = {};
95
- const modelList = [primaryModel];
96
- if (primaryModel !== "qwen3.7-plus") {
97
- modelList.push("qwen3.7-plus");
98
- }
96
+ const modelList = Array.from(
97
+ new Set([primaryModel, ...(models && models.length > 0 ? models : [primaryModel, "qwen3.7-plus"])].filter(Boolean)),
98
+ );
99
99
 
100
100
  for (const m of modelList) {
101
101
  modelsObj[m] = {
102
- name:
103
- m === "qwen3.8-max"
104
- ? "Qwen 3.8 Max"
105
- : m === "qwen3.7-plus"
106
- ? "Qwen 3.7 Plus"
107
- : m,
102
+ name: formatModelDisplayName(m),
108
103
  limit: { context: 1048576, output: 65536 },
109
104
  modalities: { input: ["text", "image"], output: ["text"] },
110
105
  reasoning: true,
@@ -128,7 +123,7 @@ function buildKiloProviderObject(
128
123
  }
129
124
 
130
125
  export function syncKilo(options: SyncOptions): ClientSyncResult {
131
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max", setActive = true } = options;
126
+ const { filePath, apiKey, baseUrl, model = "qwen3.8-max", models, setActive = true } = options;
132
127
  try {
133
128
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
134
129
 
@@ -140,7 +135,7 @@ export function syncKilo(options: SyncOptions): ClientSyncResult {
140
135
  content = fs.readFileSync(filePath, "utf-8");
141
136
  }
142
137
 
143
- const providerObj = buildKiloProviderObject(baseUrl, apiKey, model);
138
+ const providerObj = buildKiloProviderObject(baseUrl, apiKey, model, models);
144
139
  const providerJson = JSON.stringify(providerObj, null, 6)
145
140
  .split("\n")
146
141
  .map((line, idx) => (idx === 0 ? line : ` ${line}`))
package/src/sync/omp.ts CHANGED
@@ -1,22 +1,21 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function buildOmpProviderYaml(
7
7
  baseUrl: string,
8
8
  apiKey: string,
9
9
  primaryModel: string = "qwen3.8-max",
10
+ models?: string[],
10
11
  ): string {
11
- const modelList = [primaryModel];
12
- if (primaryModel !== "qwen3.7-plus") {
13
- modelList.push("qwen3.7-plus");
14
- }
15
-
12
+ const modelList = Array.from(
13
+ new Set([primaryModel, ...(models && models.length > 0 ? models : [primaryModel, "qwen3.7-plus"])].filter(Boolean)),
14
+ );
16
15
  const formattedModels = modelList
17
16
  .map(
18
17
  (m) => ` - id: ${m}
19
- name: ${m === "qwen3.8-max" ? "Qwen3.8-Max" : m === "qwen3.7-plus" ? "Qwen3.7-Plus" : m}
18
+ name: ${formatModelDisplayName(m).replace(/\s+/g, "")}
20
19
  input: [text, image]
21
20
  contextWindow: 1000000
22
21
  maxTokens: 131072
@@ -41,7 +40,7 @@ ${formattedModels}
41
40
  }
42
41
 
43
42
  export function syncOmp(options: SyncOptions): ClientSyncResult {
44
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max" } = options;
43
+ const { filePath, apiKey, baseUrl, model = "qwen3.8-max", models } = options;
45
44
  try {
46
45
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
46
 
@@ -53,8 +52,7 @@ export function syncOmp(options: SyncOptions): ClientSyncResult {
53
52
  content = fs.readFileSync(filePath, "utf-8");
54
53
  }
55
54
 
56
- const providerBlock = buildOmpProviderYaml(baseUrl, apiKey, model);
57
-
55
+ const providerBlock = buildOmpProviderYaml(baseUrl, apiKey, model, models);
58
56
  if (!content.trim()) {
59
57
  content = `providers:\n${providerBlock}`;
60
58
  } else {
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function findKeyObjectSpan(content: string, key: string): { start: number; end: number; hasTrailingComma: boolean } | null {
7
7
  const regex = new RegExp(`"${key}"\\s*:\\s*\\{`);
@@ -90,41 +90,31 @@ function buildOpenClawProviderObject(
90
90
  baseUrl: string,
91
91
  apiKey: string,
92
92
  model: string = "qwen3.8-max",
93
+ models?: string[],
93
94
  ): Record<string, any> {
94
- const models = [
95
- {
96
- id: model,
97
- name: model === "qwen3.8-max" ? "Qwen 3.8 Max" : model,
98
- reasoning: true,
99
- supportsReasoningEffort: true,
100
- supportedReasoningEfforts: ["low", "medium", "high"],
101
- contextWindow: 1000000,
102
- maxTokens: 65536,
103
- },
104
- ];
105
-
106
- if (model !== "qwen3.7-plus") {
107
- models.push({
108
- id: "qwen3.7-plus",
109
- name: "Qwen 3.7 Plus",
110
- reasoning: true,
111
- supportsReasoningEffort: true,
112
- supportedReasoningEfforts: ["low", "medium", "high"],
113
- contextWindow: 1000000,
114
- maxTokens: 65536,
115
- });
116
- }
95
+ const modelList = Array.from(
96
+ new Set([model, ...(models && models.length > 0 ? models : [model, "qwen3.7-plus"])].filter(Boolean)),
97
+ );
98
+ const modelEntries = modelList.map((m) => ({
99
+ id: m,
100
+ name: formatModelDisplayName(m),
101
+ reasoning: true,
102
+ supportsReasoningEffort: true,
103
+ supportedReasoningEfforts: ["low", "medium", "high"],
104
+ contextWindow: 1000000,
105
+ maxTokens: 65536,
106
+ }));
117
107
 
118
108
  return {
119
109
  baseUrl,
120
110
  apiKey,
121
111
  api: "openai-completions",
122
- models,
112
+ models: modelEntries,
123
113
  };
124
114
  }
125
115
 
126
116
  export function syncOpenClaw(options: SyncOptions): ClientSyncResult {
127
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max", reasoningEffort = "high" } = options;
117
+ const { filePath, apiKey, baseUrl, model = "qwen3.8-max", models, reasoningEffort = "high" } = options;
128
118
  try {
129
119
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
130
120
 
@@ -136,7 +126,7 @@ export function syncOpenClaw(options: SyncOptions): ClientSyncResult {
136
126
  content = fs.readFileSync(filePath, "utf-8");
137
127
  }
138
128
 
139
- const providerObj = buildOpenClawProviderObject(baseUrl, apiKey, model);
129
+ const providerObj = buildOpenClawProviderObject(baseUrl, apiKey, model, models);
140
130
  const providerJson = JSON.stringify(providerObj, null, 6)
141
131
  .split("\n")
142
132
  .map((line, idx) => (idx === 0 ? line : ` ${line}`))
@@ -1,27 +1,22 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function buildOpenCodeProviderObject(
7
7
  baseUrl: string,
8
8
  apiKey: string,
9
9
  primaryModel: string = "qwen3.8-max",
10
+ models?: string[],
10
11
  ): Record<string, any> {
11
12
  const modelsObj: Record<string, any> = {};
12
- const modelList = [primaryModel];
13
- if (primaryModel !== "qwen3.7-plus") {
14
- modelList.push("qwen3.7-plus");
15
- }
13
+ const modelList = Array.from(
14
+ new Set([primaryModel, ...(models && models.length > 0 ? models : [primaryModel, "qwen3.7-plus"])].filter(Boolean)),
15
+ );
16
16
 
17
17
  for (const m of modelList) {
18
18
  modelsObj[m] = {
19
- name:
20
- m === "qwen3.8-max"
21
- ? "Qwen 3.8 Max"
22
- : m === "qwen3.7-plus"
23
- ? "Qwen 3.7 Plus"
24
- : m,
19
+ name: formatModelDisplayName(m),
25
20
  limit: { context: 1048576, output: 65536 },
26
21
  modalities: { input: ["text", "image"], output: ["text"] },
27
22
  reasoning: true,
@@ -127,8 +122,9 @@ function findKeyObjectSpan(content: string, key: string): { start: number; end:
127
122
  }
128
123
 
129
124
  export function syncOpenCode(options: SyncOptions): ClientSyncResult {
130
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max" } = options;
125
+ const { filePath, apiKey, baseUrl, model = "qwen3.8-max", models } = options;
131
126
  try {
127
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
132
128
  let backupPath: string | undefined;
133
129
  let content = "";
134
130
 
@@ -137,7 +133,7 @@ export function syncOpenCode(options: SyncOptions): ClientSyncResult {
137
133
  content = fs.readFileSync(filePath, "utf-8");
138
134
  }
139
135
 
140
- const providerObj = buildOpenCodeProviderObject(baseUrl, apiKey, model);
136
+ const providerObj = buildOpenCodeProviderObject(baseUrl, apiKey, model, models);
141
137
  const providerJson = JSON.stringify(providerObj, null, 6)
142
138
  .split("\n")
143
139
  .map((line, idx) => (idx === 0 ? line : ` ${line}`))
package/src/sync/types.ts CHANGED
@@ -26,6 +26,7 @@ export interface SyncOptions {
26
26
  apiKey: string;
27
27
  baseUrl: string;
28
28
  model?: string;
29
+ models?: string[];
29
30
  setActive?: boolean;
30
31
  reasoningEffort?: "low" | "medium" | "high" | "none";
31
32
  modelSettingsPath?: string;
@@ -38,6 +39,9 @@ export interface SyncAllOptions {
38
39
  setActive?: boolean;
39
40
  stateFilePath?: string;
40
41
  targets?: SyncClientName[];
42
+ model?: string;
43
+ models?: string[];
44
+ syncAllModels?: boolean;
41
45
  customPaths?: {
42
46
  claudeCode?: string;
43
47
  codex?: string;
package/src/sync/utils.ts CHANGED
@@ -54,3 +54,21 @@ export function restoreFromBackup(filePath: string, backupPath?: string): boolea
54
54
  }
55
55
  return true;
56
56
  }
57
+
58
+ /**
59
+ * Format internal model slug to clean human-readable display name without bulky suffixes.
60
+ * E.g. "qwen3.8-max" -> "Qwen 3.8 Max", "qwen3.8-omni-flash" -> "Qwen 3.8 Omni Flash"
61
+ */
62
+ export function formatModelDisplayName(model: string): string {
63
+ if (model === "qwen3.8-max") return "Qwen 3.8 Max";
64
+ if (model === "qwen3.8-omni-flash") return "Qwen 3.8 Omni Flash";
65
+ if (model === "qwen3.7-plus") return "Qwen 3.7 Plus";
66
+ if (model === "qwen3.6-plus") return "Qwen 3.6 Plus";
67
+ return model
68
+ .replace(/^qwen/i, "Qwen ")
69
+ .split(/[-_]/)
70
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
71
+ .join(" ")
72
+ .replace(/\s+/g, " ")
73
+ .trim();
74
+ }
package/src/sync/zed.ts CHANGED
@@ -64,10 +64,15 @@ function parseJsonWithComments(raw: string): Record<string, any> {
64
64
  return JSON.parse(cleaned);
65
65
  }
66
66
 
67
- function buildZedAvailableModels(primaryModel: string = "qwen3.8-max"): any[] {
68
- const models = [
69
- {
70
- name: primaryModel,
67
+ function buildZedAvailableModels(primaryModel: string = "qwen3.8-max", models?: string[]): any[] {
68
+ const modelList = Array.from(
69
+ new Set([primaryModel, ...(models && models.length > 0 ? models : [primaryModel, "qwen3.7-plus"])].filter(Boolean)),
70
+ );
71
+ const zedModels: any[] = [];
72
+
73
+ for (const m of modelList) {
74
+ zedModels.push({
75
+ name: m,
71
76
  max_tokens: 1000000,
72
77
  max_output_tokens: 131072,
73
78
  max_completion_tokens: 131072,
@@ -79,59 +84,46 @@ function buildZedAvailableModels(primaryModel: string = "qwen3.8-max"): any[] {
79
84
  chat_completions: true,
80
85
  interleaved_reasoning: true,
81
86
  },
82
- },
83
- {
84
- name: `${primaryModel}-thinking`,
85
- max_tokens: 1000000,
86
- max_output_tokens: 131072,
87
- max_completion_tokens: 131072,
88
- capabilities: {
89
- tools: true,
90
- images: true,
91
- parallel_tool_calls: true,
92
- prompt_cache_key: true,
93
- chat_completions: true,
94
- interleaved_reasoning: true,
95
- },
96
- },
97
- {
98
- name: `${primaryModel}-fast`,
99
- max_tokens: 1000000,
100
- max_output_tokens: 131072,
101
- max_completion_tokens: 131072,
102
- capabilities: {
103
- tools: true,
104
- images: true,
105
- parallel_tool_calls: true,
106
- prompt_cache_key: true,
107
- chat_completions: true,
108
- interleaved_reasoning: true,
109
- },
110
- },
111
- ];
112
-
113
- if (primaryModel !== "qwen3.7-plus") {
114
- models.push({
115
- name: "qwen3.7-plus",
116
- max_tokens: 1000000,
117
- max_output_tokens: 65536,
118
- max_completion_tokens: 65536,
119
- capabilities: {
120
- tools: true,
121
- images: true,
122
- parallel_tool_calls: true,
123
- prompt_cache_key: true,
124
- chat_completions: true,
125
- interleaved_reasoning: true,
126
- },
127
87
  });
88
+ if (m === primaryModel) {
89
+ zedModels.push(
90
+ {
91
+ name: `${m}-thinking`,
92
+ max_tokens: 1000000,
93
+ max_output_tokens: 131072,
94
+ max_completion_tokens: 131072,
95
+ capabilities: {
96
+ tools: true,
97
+ images: true,
98
+ parallel_tool_calls: true,
99
+ prompt_cache_key: true,
100
+ chat_completions: true,
101
+ interleaved_reasoning: true,
102
+ },
103
+ },
104
+ {
105
+ name: `${m}-fast`,
106
+ max_tokens: 1000000,
107
+ max_output_tokens: 131072,
108
+ max_completion_tokens: 131072,
109
+ capabilities: {
110
+ tools: true,
111
+ images: true,
112
+ parallel_tool_calls: true,
113
+ prompt_cache_key: true,
114
+ chat_completions: true,
115
+ interleaved_reasoning: true,
116
+ },
117
+ },
118
+ );
119
+ }
128
120
  }
129
-
130
- return models;
121
+ return zedModels;
131
122
  }
132
123
 
124
+
133
125
  export function syncZed(options: SyncOptions): ClientSyncResult {
134
- const { filePath, baseUrl, model = "qwen3.8-max", setActive = true } = options;
126
+ const { filePath, baseUrl, model = "qwen3.8-max", models, setActive = true } = options;
135
127
  try {
136
128
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
137
129
 
@@ -153,7 +145,7 @@ export function syncZed(options: SyncOptions): ClientSyncResult {
153
145
 
154
146
  openaiCompatible.QwenProxy = {
155
147
  api_url: baseUrl,
156
- available_models: buildZedAvailableModels(model),
148
+ available_models: buildZedAvailableModels(model, models),
157
149
  };
158
150
 
159
151
  const updatedSettings: Record<string, any> = {
@@ -19,6 +19,7 @@ function parseArgs() {
19
19
  host?: string;
20
20
  setActive: boolean;
21
21
  targets: SyncClientName[];
22
+ model?: string;
22
23
  } = {
23
24
  restore: false,
24
25
  list: false,
@@ -43,6 +44,8 @@ function parseArgs() {
43
44
  options.host = args[++i];
44
45
  } else if (arg === "--no-active") {
45
46
  options.setActive = false;
47
+ } else if ((arg === "--model" || arg === "-m") && args[i + 1]) {
48
+ options.model = args[++i];
46
49
  } else if (arg === "--client" && args[i + 1]) {
47
50
  const normalized = normalizeClientName(args[++i]);
48
51
  if (normalized) options.targets.push(normalized);
@@ -78,6 +81,7 @@ Exemplos:
78
81
 
79
82
  Opções:
80
83
  --client <nome> Nome do cliente (hermes, opencode, claude, openclaw, kilo, cline, omp, codex, zed, aider)
84
+ --model <modelo> Modelo padrão a configurar (padrão: qwen3.8-max)
81
85
  --api-key <chave> Sobrescrever chave de API (padrão: lê do .env ou usa sk-qwenproxy-local)
82
86
  --port <porta> Sobrescrever porta do servidor (padrão: lê do .env ou usa 7936)
83
87
  --host <host> Sobrescrever host do servidor (padrão: 127.0.0.1)
@@ -165,6 +169,7 @@ async function main() {
165
169
  host: options.host,
166
170
  setActive: options.setActive,
167
171
  targets: options.targets.length > 0 ? options.targets : undefined,
172
+ model: options.model,
168
173
  });
169
174
 
170
175
  console.log(`🔑 Chave API: ${result.apiKey}`);
@@ -70,8 +70,8 @@ export class LogsView implements TuiView {
70
70
  chips.push({
71
71
  id: d.id,
72
72
  label: d.label,
73
- startCol: startCol - (i === 0 ? 1 : 0),
74
- endCol: endCol + 1,
73
+ startCol,
74
+ endCol,
75
75
  });
76
76
  currentCol += w;
77
77
  }
@@ -90,9 +90,9 @@ export class LogsView implements TuiView {
90
90
  }
91
91
 
92
92
  public handleKey(key: KeyEvent): boolean | void {
93
- // Mouse hover or click on chips (only compute chips when mouse is on chip rows 3 to 6)
93
+ // Mouse hover or click on chips (terminal row 4 is the top border of the logs box where chips are rendered)
94
94
  if ((key.name === "hover" || key.name === "click") && key.mouse) {
95
- if (key.mouse.row === 3) {
95
+ if (key.mouse.row === 4) {
96
96
  const rawCount = ServerManager.getInstance().getLogEntries(this.filter).length;
97
97
  const { chips } = this.getChips(rawCount);
98
98
  const col = key.mouse.col;
@@ -148,7 +148,7 @@ export class LogsView implements TuiView {
148
148
  return (
149
149
  col >= this.lastWidth - 3 &&
150
150
  col <= this.lastWidth &&
151
- row >= 6 &&
151
+ row >= 7 &&
152
152
  row <= 6 + this.lastVisibleCapacity
153
153
  );
154
154
  };
@@ -166,7 +166,7 @@ export class LogsView implements TuiView {
166
166
  if (key.name === "click" && key.mouse && isMouseOnScrollbar(key.mouse.col, key.mouse.row)) {
167
167
  if (this.lastMaxOffset > 0 && this.lastVisibleCapacity > 0) {
168
168
  this.isDraggingScrollbar = true;
169
- const r = key.mouse.row - 6;
169
+ const r = key.mouse.row - 7;
170
170
  const pct = Math.max(0, Math.min(1, r / Math.max(1, this.lastVisibleCapacity - 1)));
171
171
  const targetScrollFromTop = Math.round(pct * this.lastMaxOffset);
172
172
  this.scrollOffset = Math.max(0, Math.min(this.lastMaxOffset, this.lastMaxOffset - targetScrollFromTop));
@@ -178,7 +178,7 @@ export class LogsView implements TuiView {
178
178
  // Scrollbar Drag (Hold and Move)
179
179
  if (key.name === "drag" && key.mouse) {
180
180
  if (this.isDraggingScrollbar && this.lastMaxOffset > 0 && this.lastVisibleCapacity > 0) {
181
- const r = key.mouse.row - 6;
181
+ const r = key.mouse.row - 7;
182
182
  const pct = Math.max(0, Math.min(1, r / Math.max(1, this.lastVisibleCapacity - 1)));
183
183
  const targetScrollFromTop = Math.round(pct * this.lastMaxOffset);
184
184
  this.scrollOffset = Math.max(0, Math.min(this.lastMaxOffset, this.lastMaxOffset - targetScrollFromTop));
@@ -195,9 +195,9 @@ export class LogsView implements TuiView {
195
195
  }
196
196
  }
197
197
 
198
- // Mouse click on log rows (terminal row 6+, accounting for 2-line top margin: row 4 & 5 are margin)
199
- if (key.name === "click" && key.mouse && key.mouse.row >= 6) {
200
- const rowOffset = key.mouse.row - 6;
198
+ // Mouse click on log rows (terminal row 7+, accounting for 2-line top margin: row 5 & 6 are margin)
199
+ if (key.name === "click" && key.mouse && key.mouse.row >= 7) {
200
+ const rowOffset = key.mouse.row - 7;
201
201
  if (rowOffset >= 0 && rowOffset < this.lastVisibleCount) {
202
202
  const clickedIdx = this.lastStartIndex + rowOffset;
203
203
  if (this.selectedLogIndex === clickedIdx) {
@@ -299,6 +299,9 @@ export class SyncView implements TuiView {
299
299
  try {
300
300
  const res = syncAllClients({
301
301
  targets: selectedTargets,
302
+ model: currentModel,
303
+ models: this.availableModels,
304
+ syncAllModels: this.syncAllModels,
302
305
  });
303
306
 
304
307
  let successCount = 0;