qwenproxy-cli 1.0.30 → 1.0.32

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.0.30",
3
+ "version": "1.0.32",
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": {
@@ -35,7 +35,11 @@ import {
35
35
  type AccountLease,
36
36
  } from "../../core/account-concurrency.ts";
37
37
  import { isAuthMockEnabled } from "../../services/auth-playwright.ts";
38
- import { isPlaywrightInitialized, refreshHeaders } from "../../services/playwright.ts";
38
+ import {
39
+ isPlaywrightInitialized,
40
+ isAccountRecentlyActive,
41
+ refreshHeaders,
42
+ } from "../../services/playwright.ts";
39
43
  import { enqueueOrphanChatDeletion } from "../../services/chat-cleanup.ts";
40
44
  import {
41
45
  clearAllSessionsForAccount,
@@ -78,14 +82,19 @@ const MAX_ANTI_BOT_ROTATIONS = 1;
78
82
  * a stuck account page (closed context / WAF) can otherwise hold each browser
79
83
  * op for 60s and keep the personalization mutex blocked for minutes.
80
84
  */
81
- export const PERSONALIZATION_SYNC_DEADLINE_MS = 30_000;
85
+ export const PERSONALIZATION_SYNC_DEADLINE_MS = 45_000;
82
86
  export const COLD_ACCOUNT_PERSONALIZATION_SYNC_DEADLINE_MS = 60_000;
83
87
 
84
88
  export function computePersonalizationDeadlineMs(
85
89
  accountId: string | undefined,
86
90
  navigationTimeoutMs = config.timeouts.navigation,
87
91
  ): number {
88
- if (accountId && accountId !== "global" && isPlaywrightInitialized(accountId)) {
92
+ if (
93
+ accountId &&
94
+ accountId !== "global" &&
95
+ isPlaywrightInitialized(accountId) &&
96
+ isAccountRecentlyActive(accountId, 5 * 60 * 1000)
97
+ ) {
89
98
  return PERSONALIZATION_SYNC_DEADLINE_MS;
90
99
  }
91
100
  return Math.max(COLD_ACCOUNT_PERSONALIZATION_SYNC_DEADLINE_MS, navigationTimeoutMs);
@@ -3342,6 +3342,14 @@ export function isPlaywrightInitialized(accountId: string): boolean {
3342
3342
  return accountPages.has(accountId);
3343
3343
  }
3344
3344
 
3345
+ export function isAccountRecentlyActive(
3346
+ accountId: string,
3347
+ maxIdleMs = 300_000,
3348
+ ): boolean {
3349
+ const last = lastAccountActivity.get(accountId) ?? 0;
3350
+ return last > 0 && Date.now() - last < maxIdleMs;
3351
+ }
3352
+
3345
3353
  /**
3346
3354
  * Register an account as if it had been initialized, with a chosen last
3347
3355
  * activity timestamp. Lets the idle/keep-alive selection be exercised without
@@ -2993,19 +2993,6 @@ async function createQwenStreamInternal(
2993
2993
  const preview = await readResponsePreview(response);
2994
2994
  const htmlBody = isHtmlResponseBody(preview);
2995
2995
  const antiBotChallenge = isWafChallengeResponse(preview);
2996
- logger.warn(
2997
- htmlBody || isHtmlResponseContentType(responseContentType)
2998
- ? "[Qwen] Completion returned HTML instead of SSE"
2999
- : "[Qwen] Completion returned a non-SSE body",
3000
- {
3001
- accountId: accountId ?? "global",
3002
- chatId: chatSessionId ?? "new",
3003
- status: response.status,
3004
- contentType: responseContentType,
3005
- antiBotChallenge,
3006
- previewBytes: Buffer.byteLength(preview, "utf8"),
3007
- },
3008
- );
3009
2996
 
3010
2997
  if (
3011
2998
  antiBotChallenge &&
@@ -3026,6 +3013,19 @@ async function createQwenStreamInternal(
3026
3013
  continue;
3027
3014
  }
3028
3015
 
3016
+ logger.warn(
3017
+ htmlBody || isHtmlResponseContentType(responseContentType)
3018
+ ? "[Qwen] Completion returned HTML instead of SSE"
3019
+ : "[Qwen] Completion returned a non-SSE body",
3020
+ {
3021
+ accountId: accountId ?? "global",
3022
+ chatId: chatSessionId ?? "new",
3023
+ status: response.status,
3024
+ contentType: responseContentType,
3025
+ antiBotChallenge,
3026
+ previewBytes: Buffer.byteLength(preview, "utf8"),
3027
+ },
3028
+ );
3029
3029
  throw withCreatedChatMetadata(
3030
3030
  new QwenUpstreamError(
3031
3031
  antiBotChallenge
@@ -3070,16 +3070,6 @@ async function createQwenStreamInternal(
3070
3070
  const htmlResponse = isHtmlResponseBody(errText);
3071
3071
  const antiBotChallenge = isWafChallengeResponse(errText);
3072
3072
  if (antiBotChallenge || htmlResponse) {
3073
- logger.warn(
3074
- "[Qwen] Completion returned an HTML or anti-bot challenge body instead of SSE.",
3075
- {
3076
- accountId: accountId ?? "global",
3077
- chatId: chatSessionId ?? "new",
3078
- antiBotChallenge,
3079
- previewBytes: Buffer.byteLength(errText, "utf8"),
3080
- },
3081
- );
3082
-
3083
3073
  if (
3084
3074
  antiBotChallenge &&
3085
3075
  (await retryAfterCaptchaRecovery(
@@ -3099,6 +3089,15 @@ async function createQwenStreamInternal(
3099
3089
  continue;
3100
3090
  }
3101
3091
 
3092
+ logger.warn(
3093
+ "[Qwen] Completion returned an HTML or anti-bot challenge body instead of SSE.",
3094
+ {
3095
+ accountId: accountId ?? "global",
3096
+ chatId: chatSessionId ?? "new",
3097
+ antiBotChallenge,
3098
+ previewBytes: Buffer.byteLength(errText, "utf8"),
3099
+ },
3100
+ );
3102
3101
  throw withCreatedChatMetadata(
3103
3102
  new QwenUpstreamError(
3104
3103
  antiBotChallenge
package/src/sync/cline.ts CHANGED
@@ -84,7 +84,7 @@ export function syncCline(options: SyncOptions): ClientSyncResult {
84
84
  backupPath,
85
85
  success: true,
86
86
  action: backupPath ? "updated" : "created",
87
- message: `Configured Cline & Zoo Code in state.vscdb with model ${model} and reasoning effort ${reasoningEffort}`,
87
+ message: `Configured Cline in state.vscdb with model ${model} and reasoning effort ${reasoningEffort}`,
88
88
  };
89
89
  } catch (err: any) {
90
90
  return {
package/src/sync/index.ts CHANGED
@@ -151,6 +151,98 @@ export interface ClientDetectionStatus {
151
151
  url?: string;
152
152
  }
153
153
 
154
+ export function isExecutableInPath(name: string): boolean {
155
+ const envPath = process.env.PATH || "";
156
+ const dirs = envPath.split(path.delimiter).filter(Boolean);
157
+ const extensions = process.platform === "win32"
158
+ ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.toLowerCase())
159
+ : [""];
160
+
161
+ for (const dir of dirs) {
162
+ for (const ext of extensions) {
163
+ const fullPath = path.join(dir, name + ext);
164
+ try {
165
+ if (fs.existsSync(fullPath) && !fs.statSync(fullPath).isDirectory()) {
166
+ return true;
167
+ }
168
+ } catch {}
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+
174
+ export function isVscodeExtensionInstalled(pattern: RegExp): boolean {
175
+ const home = os.homedir();
176
+ const candidateDirs = [
177
+ path.join(home, ".vscode", "extensions"),
178
+ path.join(home, ".vscode-insiders", "extensions"),
179
+ path.join(home, ".cursor", "extensions"),
180
+ path.join(home, ".windsurf", "extensions"),
181
+ ];
182
+ for (const d of candidateDirs) {
183
+ if (fs.existsSync(d)) {
184
+ try {
185
+ const entries = fs.readdirSync(d);
186
+ if (entries.some((e) => pattern.test(e))) return true;
187
+ } catch {}
188
+ }
189
+ }
190
+ return false;
191
+ }
192
+
193
+ export function isZedInstalled(): boolean {
194
+ if (isExecutableInPath("zed")) return true;
195
+ if (process.platform === "win32") {
196
+ const local = process.env.LOCALAPPDATA || "";
197
+ if (fs.existsSync(path.join(local, "Programs", "Zed"))) return true;
198
+ } else if (process.platform === "darwin") {
199
+ if (fs.existsSync("/Applications/Zed.app")) return true;
200
+ }
201
+ return false;
202
+ }
203
+
204
+ export function isMatchingLocalHost(text: string, port = 7936): boolean {
205
+ if (!text) return false;
206
+ const configuredPort = config.server?.port || 7936;
207
+ const ports = new Set([String(port), String(configuredPort), "7936", "3000"]);
208
+ const isLocal = text.includes("127.0.0.1") || text.includes("localhost") || text.includes("0.0.0.0");
209
+ return isLocal && Array.from(ports).some((p) => text.includes(`:${p}`));
210
+ }
211
+
212
+ export function isClientToolInstalled(
213
+ id: SyncClientName,
214
+ targetPath: string,
215
+ isCustomTestPath = false,
216
+ ): boolean {
217
+ if (isCustomTestPath) {
218
+ return fs.existsSync(targetPath);
219
+ }
220
+ switch (id) {
221
+ case "hermes":
222
+ return isExecutableInPath("hermes");
223
+ case "openclaw":
224
+ return isExecutableInPath("openclaw") || isExecutableInPath("clawdbot") || isExecutableInPath("moltbot");
225
+ case "aider":
226
+ return isExecutableInPath("aider");
227
+ case "kilo":
228
+ return isExecutableInPath("kilo") || isVscodeExtensionInstalled(/kilo/i);
229
+ case "cline":
230
+ return isExecutableInPath("cline") || isVscodeExtensionInstalled(/cline|zoo-code|roo-cline/i);
231
+ case "zed":
232
+ return isZedInstalled();
233
+ case "claude-code":
234
+ return isExecutableInPath("claude") || fs.existsSync(targetPath);
235
+ case "codex":
236
+ return isExecutableInPath("codex") || fs.existsSync(targetPath);
237
+ case "opencode":
238
+ return isExecutableInPath("opencode") || fs.existsSync(targetPath);
239
+ case "omp":
240
+ return isExecutableInPath("omp") || fs.existsSync(targetPath);
241
+ default:
242
+ return fs.existsSync(targetPath);
243
+ }
244
+ }
245
+
154
246
  /**
155
247
  * Inspects a client configuration file to determine whether the client is installed
156
248
  * and whether it is actively configured to route to QwenProxy.
@@ -161,37 +253,26 @@ export function inspectClientSyncStatus(
161
253
  port = 7936,
162
254
  ): ClientDetectionStatus {
163
255
  const defaultPaths = getDefaultPaths();
164
- const targetPath =
165
- filePath ||
166
- (id === "claude-code"
167
- ? defaultPaths.claudeCode
168
- : id === "codex"
169
- ? defaultPaths.codex
170
- : id === "opencode"
171
- ? defaultPaths.openCode
172
- : id === "omp"
173
- ? defaultPaths.omp
174
- : id === "hermes"
175
- ? defaultPaths.hermes
176
- : id === "openclaw"
177
- ? defaultPaths.openClaw
178
- : id === "kilo"
179
- ? defaultPaths.kilo
180
- : id === "cline"
181
- ? defaultPaths.cline
182
- : id === "zed"
183
- ? defaultPaths.zed
184
- : defaultPaths.aider);
256
+ const defaultTarget = (defaultPaths as Record<string, string>)[id] || "";
257
+ const isCustomTestPath = Boolean(
258
+ filePath && defaultTarget && path.resolve(filePath) !== path.resolve(defaultTarget),
259
+ );
260
+ const targetPath = filePath || defaultTarget;
185
261
 
186
262
  if (!fs.existsSync(targetPath)) {
187
263
  return { id, installed: false, synced: false };
188
264
  }
189
265
 
266
+ const toolInstalled = isClientToolInstalled(id, targetPath, isCustomTestPath);
267
+ if (!toolInstalled) {
268
+ return { id, installed: false, synced: false };
269
+ }
270
+
190
271
  try {
191
272
  if (id === "cline") {
192
- // Inspect SQLite DB
193
273
  let isSynced = false;
194
274
  let model: string | undefined;
275
+ let rowExists = false;
195
276
  try {
196
277
  const db = new Database(targetPath, { readonly: true });
197
278
  const row = db
@@ -201,18 +282,19 @@ export function inspectClientSyncStatus(
201
282
  .get() as { value: string } | undefined;
202
283
  db.close();
203
284
  if (row && row.value) {
285
+ rowExists = true;
204
286
  const parsed = JSON.parse(row.value);
205
287
  const url = parsed.openAiBaseUrl || "";
206
288
  model = parsed.openAiModelId;
207
- isSynced = Boolean(
208
- url &&
209
- (url.includes(String(port)) ||
210
- url.includes(`127.0.0.1:${port}`) ||
211
- url.includes(`localhost:${port}`)),
212
- );
289
+ isSynced = isMatchingLocalHost(url, port);
213
290
  }
214
291
  } catch {}
215
- return { id, installed: true, synced: isSynced, model };
292
+
293
+ const isInstalled = isCustomTestPath
294
+ ? true
295
+ : rowExists || isExecutableInPath("cline") || isVscodeExtensionInstalled(/cline|zoo-code|roo-cline/i);
296
+
297
+ return { id, installed: isInstalled, synced: isInstalled && isSynced, model };
216
298
  }
217
299
 
218
300
  const raw = fs.readFileSync(targetPath, "utf-8");
@@ -221,12 +303,8 @@ export function inspectClientSyncStatus(
221
303
  const data = JSON.parse(raw);
222
304
  const url = data?.env?.ANTHROPIC_BASE_URL || "";
223
305
  const model = data?.env?.ANTHROPIC_MODEL || data?.model || "";
224
- const isLocalHost =
225
- url.includes(String(port)) ||
226
- url.includes(`127.0.0.1:${port}`) ||
227
- url.includes(`localhost:${port}`);
228
306
  const isSynced =
229
- isLocalHost &&
307
+ isMatchingLocalHost(url, port) &&
230
308
  (model.toLowerCase().includes("qwen") || Boolean(data?.env?.ANTHROPIC_AUTH_TOKEN));
231
309
  return {
232
310
  id,
@@ -244,13 +322,11 @@ export function inspectClientSyncStatus(
244
322
  const model = modelMatch ? modelMatch[1] : undefined;
245
323
  const urlMatch = raw.match(/\[model_providers\.qwenproxy\][\s\S]*?base_url\s*=\s*["']([^"']+)["']/);
246
324
  const url = urlMatch ? urlMatch[1] : "";
247
- const isLocalHost = Boolean(
248
- url && (url.includes(String(port)) || url.includes(`127.0.0.1:${port}`) || url.includes(`localhost:${port}`)),
249
- );
325
+ const isSynced = hasProvider && isProviderActive && isMatchingLocalHost(url, port);
250
326
  return {
251
327
  id,
252
328
  installed: true,
253
- synced: hasProvider && isProviderActive && isLocalHost,
329
+ synced: isSynced,
254
330
  model,
255
331
  };
256
332
  }
@@ -261,20 +337,13 @@ export function inspectClientSyncStatus(
261
337
  const data = JSON.parse(raw);
262
338
  const provider = data?.provider?.qwenproxy;
263
339
  const url = provider?.options?.baseURL || "";
264
- const isLocalHost =
265
- url.includes(String(port)) ||
266
- url.includes(`127.0.0.1:${port}`) ||
267
- url.includes(`localhost:${port}`);
268
- isSynced = Boolean(provider && isLocalHost);
340
+ const isSynced = Boolean(provider && isMatchingLocalHost(url, port));
341
+ return { id, installed: true, synced: isSynced };
269
342
  } catch {
270
343
  const qwenBlockMatch = raw.match(/"qwenproxy"\s*:\s*\{[\s\S]*?"baseURL"\s*:\s*"([^"]+)"/);
271
344
  const url = qwenBlockMatch ? qwenBlockMatch[1] : "";
272
- isSynced = Boolean(
273
- url &&
274
- (url.includes(String(port)) ||
275
- url.includes(`127.0.0.1:${port}`) ||
276
- url.includes(`localhost:${port}`)),
277
- );
345
+ const isSynced = Boolean(url && isMatchingLocalHost(url, port));
346
+ return { id, installed: true, synced: isSynced };
278
347
  }
279
348
  return {
280
349
  id,
@@ -290,12 +359,7 @@ export function inspectClientSyncStatus(
290
359
  if (ompMatch) {
291
360
  const urlMatch = ompMatch[1].match(/baseUrl:\s*(\S+)/);
292
361
  url = urlMatch ? urlMatch[1].replace(/['"]/g, "") : undefined;
293
- isSynced = Boolean(
294
- url &&
295
- (url.includes(String(port)) ||
296
- url.includes(`127.0.0.1:${port}`) ||
297
- url.includes(`localhost:${port}`)),
298
- );
362
+ isSynced = Boolean(url && isMatchingLocalHost(url, port));
299
363
  }
300
364
  return {
301
365
  id,
@@ -306,47 +370,27 @@ export function inspectClientSyncStatus(
306
370
  }
307
371
 
308
372
  if (id === "hermes") {
309
- const isLocalHost =
310
- raw.includes(String(port)) ||
311
- raw.includes(`127.0.0.1:${port}`) ||
312
- raw.includes(`localhost:${port}`);
313
- const isSynced = isLocalHost && (raw.includes("qwenproxy") || raw.includes("qwen"));
373
+ const isSynced = isMatchingLocalHost(raw, port) && (raw.includes("qwenproxy") || raw.includes("qwen"));
314
374
  return { id, installed: true, synced: isSynced };
315
375
  }
316
376
 
317
377
  if (id === "openclaw") {
318
- const isLocalHost =
319
- raw.includes(String(port)) ||
320
- raw.includes(`127.0.0.1:${port}`) ||
321
- raw.includes(`localhost:${port}`);
322
- const isSynced = isLocalHost && raw.includes("qwenproxy");
378
+ const isSynced = isMatchingLocalHost(raw, port) && raw.includes("qwenproxy");
323
379
  return { id, installed: true, synced: isSynced };
324
380
  }
325
381
 
326
382
  if (id === "kilo") {
327
- const isLocalHost =
328
- raw.includes(String(port)) ||
329
- raw.includes(`127.0.0.1:${port}`) ||
330
- raw.includes(`localhost:${port}`);
331
- const isSynced = isLocalHost && raw.includes("qwenproxy");
383
+ const isSynced = isMatchingLocalHost(raw, port) && raw.includes("qwenproxy");
332
384
  return { id, installed: true, synced: isSynced };
333
385
  }
334
386
 
335
387
  if (id === "zed") {
336
- const isLocalHost =
337
- raw.includes(String(port)) ||
338
- raw.includes(`127.0.0.1:${port}`) ||
339
- raw.includes(`localhost:${port}`);
340
- const isSynced = isLocalHost && raw.includes("QwenProxy");
388
+ const isSynced = isMatchingLocalHost(raw, port) && raw.includes("QwenProxy");
341
389
  return { id, installed: true, synced: isSynced };
342
390
  }
343
391
 
344
392
  if (id === "aider") {
345
- const isLocalHost =
346
- raw.includes(String(port)) ||
347
- raw.includes(`127.0.0.1:${port}`) ||
348
- raw.includes(`localhost:${port}`);
349
- const isSynced = isLocalHost && raw.includes("qwen");
393
+ const isSynced = isMatchingLocalHost(raw, port) && raw.includes("qwen");
350
394
  return { id, installed: true, synced: isSynced };
351
395
  }
352
396
  } catch {
@@ -544,7 +588,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
544
588
  }
545
589
  }
546
590
 
547
- // 8. Cline & Zoo Code
591
+ // 8. Cline
548
592
  if (shouldSync("cline")) {
549
593
  const clineExisted = fs.existsSync(paths.cline);
550
594
  const clineRes = syncCline({
@@ -67,7 +67,7 @@ Exemplos:
67
67
  npm run sync claude # Sincroniza apenas o Claude Code
68
68
  npm run sync openclaw # Sincroniza apenas o OpenClaw
69
69
  npm run sync kilo # Sincroniza apenas o Kilo Code
70
- npm run sync cline # Sincroniza Cline & Zoo Code
70
+ npm run sync cline # Sincroniza apenas o Cline
71
71
  npm run sync omp # Sincroniza apenas o OMP (Oh My Pi)
72
72
  npm run sync codex # Sincroniza apenas o Codex CLI
73
73
  npm run sync zed # Sincroniza apenas o Zed Editor
@@ -108,7 +108,7 @@ async function main() {
108
108
  { id: "claude-code", name: "3. Claude Code", path: defaultPaths.claudeCode },
109
109
  { id: "openclaw", name: "4. OpenClaw", path: defaultPaths.openClaw },
110
110
  { id: "kilo", name: "5. Kilo Code", path: defaultPaths.kilo },
111
- { id: "cline", name: "6. Cline & Zoo", path: defaultPaths.cline },
111
+ { id: "cline", name: "6. Cline", path: defaultPaths.cline },
112
112
  { id: "omp", name: "7. OMP (Oh My Pi)", path: defaultPaths.omp },
113
113
  { id: "codex", name: "8. Codex CLI", path: defaultPaths.codex },
114
114
  { id: "zed", name: "9. Zed Editor", path: defaultPaths.zed },
@@ -11,10 +11,11 @@ import {
11
11
  getDefaultPaths,
12
12
  inspectClientSyncStatus,
13
13
  } from "../../sync/index.ts";
14
+ import type { SyncClientName } from "../../sync/types.ts";
14
15
  import { fetchLiveModels } from "../proxy-client.ts";
15
16
 
16
17
  interface ClientOption {
17
- id: "claude-code" | "codex" | "opencode" | "omp";
18
+ id: SyncClientName;
18
19
  name: string;
19
20
  path: string;
20
21
  selected: boolean;
@@ -27,7 +28,7 @@ export class SyncView implements TuiView {
27
28
  public readonly title = "Sync";
28
29
  public readonly tabNumber = 3;
29
30
  private clients: ClientOption[] = [];
30
- private selectedRowIndex = 0; // 0..3 for clients, 4 for model, 5 for scope, 6 for sync, 7 for restore
31
+ private selectedRowIndex = 0; // 0..9 for clients, 10 for model, 11 for scope, 12 for sync, 13 for restore
31
32
  private hoveredActionRow: number | null = null;
32
33
  private availableModels = [
33
34
  "qwen3.8-max",
@@ -60,11 +61,17 @@ export class SyncView implements TuiView {
60
61
 
61
62
  private detectClients(): void {
62
63
  const paths = getDefaultPaths();
63
- const defs: Array<{ id: "claude-code" | "codex" | "opencode" | "omp"; name: string; path: string }> = [
64
- { id: "claude-code", name: "Claude Code", path: paths.claudeCode },
65
- { id: "codex", name: "OpenAI Codex", path: paths.codex },
64
+ const defs: Array<{ id: SyncClientName; name: string; path: string }> = [
65
+ { id: "hermes", name: "Hermes Agent", path: paths.hermes },
66
66
  { id: "opencode", name: "OpenCode", path: paths.openCode },
67
+ { id: "claude-code", name: "Claude Code", path: paths.claudeCode },
68
+ { id: "openclaw", name: "OpenClaw", path: paths.openClaw },
69
+ { id: "kilo", name: "Kilo Code", path: paths.kilo },
70
+ { id: "cline", name: "Cline", path: paths.cline },
67
71
  { id: "omp", name: "OMP (Oh My Pi)", path: paths.omp },
72
+ { id: "codex", name: "Codex CLI", path: paths.codex },
73
+ { id: "zed", name: "Zed Editor", path: paths.zed },
74
+ { id: "aider", name: "Aider", path: paths.aider },
68
75
  ];
69
76
 
70
77
  this.clients = defs.map((d) => {
@@ -96,23 +103,23 @@ export class SyncView implements TuiView {
96
103
  const { row, col } = key.mouse;
97
104
  const leftW = this.lastLeftW || 46;
98
105
  if (col >= 2 && col <= leftW - 1) {
99
- if (row >= 8 && row <= 11) {
106
+ if (row >= 8 && row <= 17) {
100
107
  const targetRow = row - 8;
101
108
  if (this.selectedRowIndex !== targetRow) {
102
109
  this.selectedRowIndex = targetRow;
103
110
  return true;
104
111
  }
105
- } else if (row === 14) {
106
- if (this.selectedRowIndex !== 4) {
107
- this.selectedRowIndex = 4;
112
+ } else if (row === 20 || row === 14) {
113
+ if (this.selectedRowIndex !== 10) {
114
+ this.selectedRowIndex = 10;
108
115
  return true;
109
116
  }
110
- } else if (row === 15) {
111
- if (this.selectedRowIndex !== 5) {
112
- this.selectedRowIndex = 5;
117
+ } else if (row === 21 || row === 15) {
118
+ if (this.selectedRowIndex !== 11) {
119
+ this.selectedRowIndex = 11;
113
120
  return true;
114
121
  }
115
- } else if (row === 18 || row === 19) {
122
+ } else if (row === 24 || row === 18 || row === 25 || row === 19) {
116
123
  if (this.hoveredActionRow !== row) {
117
124
  this.hoveredActionRow = row;
118
125
  return true;
@@ -132,8 +139,8 @@ export class SyncView implements TuiView {
132
139
  const { row, col } = key.mouse;
133
140
  const leftW = this.lastLeftW || 46;
134
141
  if (col >= 2 && col <= leftW - 1) {
135
- // Rows 8, 9, 10, 11: Toggle client
136
- if (row >= 8 && row <= 11) {
142
+ // Rows 8..17: Toggle client
143
+ if (row >= 8 && row <= 17) {
137
144
  const client = this.clients[row - 8];
138
145
  if (client) {
139
146
  client.selected = !client.selected;
@@ -141,27 +148,27 @@ export class SyncView implements TuiView {
141
148
  return true;
142
149
  }
143
150
  }
144
- // Row 14: Model selector
145
- if (row === 14) {
151
+ // Model selector
152
+ if (row === 20 || row === 14) {
146
153
  this.modelIndex = (this.modelIndex + 1) % this.availableModels.length;
147
- this.selectedRowIndex = 4;
154
+ this.selectedRowIndex = 10;
148
155
  return true;
149
156
  }
150
- // Row 15: Scope selector
151
- if (row === 15) {
157
+ // Scope selector
158
+ if (row === 21 || row === 15) {
152
159
  this.syncAllModels = !this.syncAllModels;
153
- this.selectedRowIndex = 5;
160
+ this.selectedRowIndex = 11;
154
161
  return true;
155
162
  }
156
- // Row 18: Sincronizar button
157
- if (row === 18) {
158
- this.selectedRowIndex = 6;
163
+ // Sincronizar button
164
+ if (row === 24 || row === 18) {
165
+ this.selectedRowIndex = 12;
159
166
  this.executeSync();
160
167
  return true;
161
168
  }
162
- // Row 19: Restaurar button
163
- if (row === 19) {
164
- this.selectedRowIndex = 7;
169
+ // Restaurar button
170
+ if (row === 25 || row === 19) {
171
+ this.selectedRowIndex = 13;
165
172
  this.executeRollback();
166
173
  return true;
167
174
  }
@@ -174,28 +181,28 @@ export class SyncView implements TuiView {
174
181
  return true;
175
182
  }
176
183
  if (key.name === "down" || key.name === "wheeldown" || (key.name === "j" && !key.ctrl)) {
177
- this.selectedRowIndex = Math.min(7, this.selectedRowIndex + 1);
184
+ this.selectedRowIndex = Math.min(13, this.selectedRowIndex + 1);
178
185
  return true;
179
186
  }
180
187
 
181
188
  // Toggle client selection with Space
182
189
  if (key.name === "space") {
183
- if (this.selectedRowIndex < 4) {
190
+ if (this.selectedRowIndex < this.clients.length) {
184
191
  const client = this.clients[this.selectedRowIndex];
185
192
  if (client) {
186
193
  client.selected = !client.selected;
187
194
  }
188
- } else if (this.selectedRowIndex === 4) {
195
+ } else if (this.selectedRowIndex === 10) {
189
196
  // Cycle model with space
190
197
  this.modelIndex = (this.modelIndex + 1) % this.availableModels.length;
191
- } else if (this.selectedRowIndex === 5) {
198
+ } else if (this.selectedRowIndex === 11) {
192
199
  this.syncAllModels = !this.syncAllModels;
193
200
  }
194
201
  return true;
195
202
  }
196
203
 
197
- // Cycle model left/right on row 4
198
- if (this.selectedRowIndex === 4 && (key.name === "left" || key.name === "right")) {
204
+ // Cycle model left/right on row 10
205
+ if (this.selectedRowIndex === 10 && (key.name === "left" || key.name === "right")) {
199
206
  if (key.name === "left") {
200
207
  this.modelIndex =
201
208
  (this.modelIndex - 1 + this.availableModels.length) %
@@ -205,7 +212,6 @@ export class SyncView implements TuiView {
205
212
  }
206
213
  return true;
207
214
  }
208
-
209
215
  // Toggle all with 'a'
210
216
  if (key.name === "a" && !key.ctrl) {
211
217
  const allSelected = this.clients.every((c) => c.selected);
@@ -226,7 +232,7 @@ export class SyncView implements TuiView {
226
232
 
227
233
  // Confirm action on Enter
228
234
  if (key.name === "return") {
229
- if (this.selectedRowIndex === 7) {
235
+ if (this.selectedRowIndex === 13) {
230
236
  this.executeRollback();
231
237
  } else {
232
238
  this.executeSync();
@@ -289,8 +295,8 @@ export class SyncView implements TuiView {
289
295
  }
290
296
 
291
297
  public render(width: number, height: number): string[] {
292
- const contentH = Math.max(12, height);
293
- const leftW = Math.max(46, Math.floor(width * 0.52));
298
+ const contentH = Math.max(22, height);
299
+ const leftW = Math.max(48, Math.floor(width * 0.52));
294
300
  this.lastLeftW = leftW;
295
301
  const rightW = Math.max(30, width - leftW - 1);
296
302
 
@@ -323,8 +329,8 @@ export class SyncView implements TuiView {
323
329
  leftContent.push("");
324
330
  leftContent.push(` ${theme.bold("Modelo:")}`);
325
331
 
326
- // Row index 4: Model Selector
327
- const isModelFocused = this.selectedRowIndex === 4;
332
+ // Row index 10: Model Selector
333
+ const isModelFocused = this.selectedRowIndex === 10;
328
334
  const modelPointer = isModelFocused ? theme.cyan(`${glyphs.pointer} `) : " ";
329
335
  const currentModel = this.availableModels[this.modelIndex] || "qwen3.8-max";
330
336
  const modelText = `${currentModel} (${this.modelIndex + 1}/${this.availableModels.length})`;
@@ -335,23 +341,23 @@ export class SyncView implements TuiView {
335
341
 
336
342
  leftContent.push(isModelFocused ? theme.bgSelected(modelLine) : modelLine);
337
343
 
338
- // Row index 5: Scope Selector
339
- const isScopeFocused = this.selectedRowIndex === 5;
344
+ // Row index 11: Scope Selector
345
+ const isScopeFocused = this.selectedRowIndex === 11;
340
346
  const scopePointer = isScopeFocused ? theme.cyan(`${glyphs.pointer} `) : " ";
341
347
  const scopeCheck = this.syncAllModels ? theme.green(glyphs.radioOn) : theme.muted(glyphs.radioOff);
342
348
  const scopeLine = `${scopePointer}${scopeCheck} Registrar todos os modelos`;
343
349
  leftContent.push(isScopeFocused ? theme.bgSelected(scopeLine) : scopeLine);
344
350
  leftContent.push("");
345
351
  leftContent.push(` ${theme.bold("Ações:")}`);
346
- // Row 18: Sincronizar
347
- const isSyncFocused = this.selectedRowIndex === 6;
348
- const isSyncHovered = this.hoveredActionRow === 18;
352
+ // Row index 12: Sincronizar
353
+ const isSyncFocused = this.selectedRowIndex === 12;
354
+ const isSyncHovered = this.hoveredActionRow === 24 || this.hoveredActionRow === 18;
349
355
  const syncLine = ` ${isSyncHovered || isSyncFocused ? theme.bgHover(` ${theme.cyan("[ Enter ] Sincronizar")} `) : `${theme.cyan("[ Enter ]")} Sincronizar`}`;
350
356
  leftContent.push(syncLine);
351
357
 
352
- // Row 19: Restaurar
353
- const isRestoreFocused = this.selectedRowIndex === 7;
354
- const isRestoreHovered = this.hoveredActionRow === 19;
358
+ // Row index 13: Restaurar
359
+ const isRestoreFocused = this.selectedRowIndex === 13;
360
+ const isRestoreHovered = this.hoveredActionRow === 25 || this.hoveredActionRow === 19;
355
361
  const restoreLine = ` ${isRestoreHovered || isRestoreFocused ? theme.bgHover(` ${theme.yellow("[ R ] Restaurar")} `) : `${theme.yellow("[ R ]")} Restaurar`}`;
356
362
  leftContent.push(restoreLine);
357
363