qwenproxy-cli 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -284,7 +284,7 @@ qpx
284
284
  | `qpx login` | Abre navegador visível para autenticar novas contas interativamente |
285
285
  | `qpx sync` | Configura e sincroniza clientes (Claude Code, Codex, OpenCode, OMP) |
286
286
  | `qpx clean` | Limpa caches temporários dos perfis Chromium (~4.5MB por conta) |
287
- | `qpx clean:all` | Limpa caches e remove versões antigas de navegadores órfãos no SSD |
287
+ | `qpx clean:all` | Limpa caches e remove versões antigas de navegadores órfãos em disco |
288
288
  | `qpx purge` | Limpa o histórico de conversas remotas no Qwen de todas as contas |
289
289
  | `qpx reset` | Reseta cooldowns e rate limits salvos no banco de dados |
290
290
  ### Opção 2: Execução Instantânea (Zero Instalação)
@@ -845,7 +845,7 @@ QwenProxy/
845
845
  | `npm start` | Iniciar apenas o servidor QwenProxy em modo headless |
846
846
  | `npm run sync` | Sincronizar clientes (Claude Code, Codex, OpenCode, OMP) com backup |
847
847
  | `npm run clean` | Limpar caches temporários dos perfis Chromium (~4.5MB por conta) |
848
- | `npm run clean:all` | Limpar caches + remover navegadores órfãos e versões antigas no SSD |
848
+ | `npm run clean:all` | Limpar caches + remover navegadores órfãos e versões antigas em disco |
849
849
  | `npm run reset` | Zerar cooldowns de contas no banco de dados |
850
850
  | `npm run login` | Adicionar/autenticar novas contas visualmente no navegador |
851
851
  | `npm run purge` | Limpar chats remotos do Qwen nas contas configuradas |
package/bin/qwenproxy.js CHANGED
@@ -115,19 +115,40 @@ try {
115
115
  tsxLoaderArg = pathToFileURL(tsxEntry).href;
116
116
  } catch {}
117
117
 
118
- // Ensure Playwright Chromium is installed for first-time global users
119
- try {
120
- const { chromium } = await import("patchright");
121
- const execPath = chromium.executablePath();
122
- if (!fs.existsSync(execPath)) {
123
- console.log("⏳ [QwenProxy] Instalando o navegador Chromium pela primeira vez...");
124
- spawnSync("npx", ["patchright", "install", "chromium"], {
125
- stdio: "inherit",
126
- shell: true,
127
- });
128
- console.log("✓ [QwenProxy] Navegador instalado com sucesso!\n");
129
- }
130
- } catch {}
118
+ // Ensure Playwright Chromium is installed only for commands that need the browser
119
+ const browserCommands = ["start", "tui", "login"];
120
+ const isBrowserCommand =
121
+ !firstArg ||
122
+ browserCommands.includes(firstArg) ||
123
+ rawArgs.includes("--tui") ||
124
+ rawArgs.includes("--server");
125
+
126
+ if (isBrowserCommand) {
127
+ try {
128
+ const { chromium } = await import("patchright");
129
+ const execPath = chromium.executablePath();
130
+ if (!fs.existsSync(execPath)) {
131
+ console.log("⏳ [QwenProxy] Instalando o navegador Chromium pela primeira vez...");
132
+ let cliPath = "";
133
+ try {
134
+ const patchrightEntry = require.resolve("patchright");
135
+ cliPath = path.join(path.dirname(patchrightEntry), "cli.js");
136
+ } catch {}
137
+
138
+ if (cliPath && fs.existsSync(cliPath)) {
139
+ spawnSync(process.execPath, [cliPath, "install", "chromium"], {
140
+ stdio: "inherit",
141
+ });
142
+ } else {
143
+ const cmd = process.platform === "win32" ? "npx.cmd" : "npx";
144
+ spawnSync(cmd, ["--yes", "patchright", "install", "chromium"], {
145
+ stdio: "inherit",
146
+ });
147
+ }
148
+ console.log("✓ [QwenProxy] Navegador instalado com sucesso!\n");
149
+ }
150
+ } catch {}
151
+ }
131
152
 
132
153
  const child = spawn(process.execPath, ["--import", tsxLoaderArg, targetPath, ...scriptArgs], {
133
154
  stdio: "inherit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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": {
package/src/api/server.ts CHANGED
@@ -597,6 +597,7 @@ export async function stopServer(): Promise<void> {
597
597
 
598
598
  export async function startServer(options?: {
599
599
  installSignalHandlers?: boolean;
600
+ showBanner?: boolean;
600
601
  }): Promise<StartedServerInfo> {
601
602
  if (server) {
602
603
  if (options?.installSignalHandlers !== false) installSignalHandlers();
@@ -829,7 +830,8 @@ export async function startServer(options?: {
829
830
 
830
831
  const endpoint = `${started.url}/v1`;
831
832
 
832
- console.log(`
833
+ if (options?.showBanner !== false) {
834
+ console.log(`
833
835
  +${"-".repeat(W)}+
834
836
  |${blank()}|
835
837
  |${center("QwenProxy")}|
@@ -845,6 +847,7 @@ export async function startServer(options?: {
845
847
  |${blank()}|
846
848
  +${"-".repeat(W)}+
847
849
  `);
850
+ }
848
851
  return started;
849
852
  })();
850
853
 
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
5
  import { chromium } from "patchright";
6
- import { pruneAllPlaywrightProfiles } from "./services/playwright.ts";
6
+ import { pruneAllPlaywrightProfiles, cleanupOrphanProfiles } from "./services/playwright.ts";
7
7
 
8
8
  export function formatBytes(bytes: number): string {
9
9
  if (bytes < 1024) return `${bytes} B`;
@@ -141,13 +141,19 @@ async function main() {
141
141
  // 1. Profile Transient Cache Pruning (V8 Code Cache, GPU Cache)
142
142
  console.log("1. Limpando caches transitórios dos perfis (data/qwen_profiles/)...");
143
143
  const profileResult = pruneAllPlaywrightProfiles();
144
- if (profileResult.totalFreedFiles > 0) {
145
- console.log(
146
- ` [OK] Perfis limpos com sucesso!`,
147
- );
148
- console.log(
149
- ` Espaço liberado: ${formatBytes(profileResult.totalFreedBytes)} em ${profileResult.totalFreedFiles} arquivos (${profileResult.profilesCleaned} perfil(is)).`,
150
- );
144
+ const orphanResult = cleanupOrphanProfiles();
145
+ if (profileResult.totalFreedFiles > 0 || orphanResult.removedCount > 0) {
146
+ console.log(` [OK] Perfis limpos com sucesso!`);
147
+ if (profileResult.totalFreedFiles > 0) {
148
+ console.log(
149
+ ` Espaço liberado: ${formatBytes(profileResult.totalFreedBytes)} em ${profileResult.totalFreedFiles} arquivos (${profileResult.profilesCleaned} perfil(is)).`,
150
+ );
151
+ }
152
+ if (orphanResult.removedCount > 0) {
153
+ console.log(
154
+ ` Perfis órfãos removidos: ${orphanResult.removedCount} (${formatBytes(orphanResult.freedBytes)} liberados).`,
155
+ );
156
+ }
151
157
  console.log(
152
158
  ` (Todos os cookies, sessões e logins foram 100% preservados!)`,
153
159
  );
@@ -172,13 +178,13 @@ async function main() {
172
178
  for (const d of browserResult.unusedDirs) {
173
179
  console.log(` - ${d.name} (${d.size})`);
174
180
  }
175
- console.log(` Total recuperado no SSD: ${formatBytes(browserResult.freedBytes)}!`);
181
+ console.log(` Total recuperado em disco: ${formatBytes(browserResult.freedBytes)}!`);
176
182
  } else {
177
- console.log(` [INFO] Encontrados ${browserResult.unusedDirs.length} navegador(es) legados/não utilizados no seu SSD:`);
183
+ console.log(` [INFO] Encontrados ${browserResult.unusedDirs.length} navegador(es) legados/não utilizados em disco:`);
178
184
  for (const d of browserResult.unusedDirs) {
179
185
  console.log(` - ${d.name} (${d.size})`);
180
186
  }
181
- console.log(` Espaço recuperável no SSD: ${formatBytes(totalReclaimable)}.`);
187
+ console.log(` Espaço recuperável em disco: ${formatBytes(totalReclaimable)}.`);
182
188
  console.log(` Para liberar esse espaço automaticamente, execute:`);
183
189
  console.log(` npm run clean:all\n`);
184
190
  }
package/src/login.ts CHANGED
@@ -130,6 +130,11 @@ async function removeAccountFlow() {
130
130
  const confirm = await askQuestion(`\nRemove ${account.email}? (y/N): `);
131
131
  if (confirm.toLowerCase() === "y") {
132
132
  if (removeAccount(account.id)) {
133
+ try {
134
+ const { removePlaywrightProfile } = await import("./services/playwright.ts");
135
+ const { getAccountProfilePath } = await import("./core/paths.ts");
136
+ removePlaywrightProfile(getAccountProfilePath(account.id));
137
+ } catch {}
133
138
  console.log(`Account ${maskEmail(account.email)} removed.`);
134
139
  } else {
135
140
  console.log("Failed to remove account.");
@@ -7,7 +7,31 @@ import { chromium, type Browser, type BrowserContext, type Page } from "patchrig
7
7
  import path from "path";
8
8
  import fs from "fs";
9
9
  import crypto from "crypto";
10
- import type { QwenAccount } from "../core/accounts.ts";
10
+ import { spawnSync } from "child_process";
11
+ import { createRequire } from "module";
12
+
13
+ const requireLocal = createRequire(import.meta.url);
14
+
15
+ function autoInstallPlaywrightChromium(): void {
16
+ try {
17
+ const patchrightEntry = requireLocal.resolve("patchright");
18
+ const cliPath = path.join(path.dirname(patchrightEntry), "cli.js");
19
+ if (fs.existsSync(cliPath)) {
20
+ console.log("⏳ [Playwright] Navegador Chromium não encontrado. Instalando automaticamente...");
21
+ spawnSync(process.execPath, [cliPath, "install", "chromium"], {
22
+ stdio: "inherit",
23
+ });
24
+ return;
25
+ }
26
+ } catch {}
27
+
28
+ console.log("⏳ [Playwright] Navegador Chromium não encontrado. Instalando via npx...");
29
+ const cmd = process.platform === "win32" ? "npx.cmd" : "npx";
30
+ spawnSync(cmd, ["--yes", "patchright", "install", "chromium"], {
31
+ stdio: "inherit",
32
+ });
33
+ }
34
+ import { loadAccounts, type QwenAccount } from "../core/accounts.ts";
11
35
  // Imported here rather than injected from session-keeper.ts: account-concurrency
12
36
  // only depends on config/logger, so playwright -> account-concurrency stays
13
37
  // acyclic, while the reverse direction would drag the browser layer into core.
@@ -260,15 +284,30 @@ export async function getOrLaunchSharedBrowser(
260
284
  const launchArgs = buildChromiumLaunchArgs(defaultViewport);
261
285
 
262
286
  console.log(
263
- `[Playwright] Launching single shared ${browserType} browser...`,
287
+ `🌐 [Playwright] Launching single shared ${browserType} browser...`,
264
288
  );
265
289
 
266
- const browser = await engine.launch({
267
- headless,
268
- channel,
269
- ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
270
- args: launchArgs,
271
- });
290
+ let browser: Browser;
291
+ try {
292
+ browser = await engine.launch({
293
+ headless,
294
+ channel,
295
+ ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
296
+ args: launchArgs,
297
+ });
298
+ } catch (launchErr: any) {
299
+ if (launchErr?.message?.includes("Executable doesn't exist")) {
300
+ autoInstallPlaywrightChromium();
301
+ browser = await engine.launch({
302
+ headless,
303
+ channel,
304
+ ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
305
+ args: launchArgs,
306
+ });
307
+ } else {
308
+ throw launchErr;
309
+ }
310
+ }
272
311
 
273
312
  browser.on("disconnected", () => {
274
313
  console.warn("[Playwright] Shared browser disconnected");
@@ -2338,6 +2377,64 @@ export function pruneAllPlaywrightProfiles(baseDir = getProfilesDir()): {
2338
2377
 
2339
2378
  return { totalFreedBytes, totalFreedFiles, profilesCleaned };
2340
2379
  }
2380
+ /**
2381
+ * Removes profile directories in data/qwen_profiles that do not belong to any
2382
+ * active account configured in the database or environment, plus any lingering
2383
+ * .stale-* directories from previous lock renames.
2384
+ */
2385
+ export function cleanupOrphanProfiles(
2386
+ baseDir = getProfilesDir(),
2387
+ activeAccountIds?: Set<string>,
2388
+ ): {
2389
+ removedCount: number;
2390
+ freedBytes: number;
2391
+ } {
2392
+ let removedCount = 0;
2393
+ let freedBytes = 0;
2394
+
2395
+ try {
2396
+ if (!fs.existsSync(baseDir)) {
2397
+ return { removedCount, freedBytes };
2398
+ }
2399
+
2400
+ const activeIds =
2401
+ activeAccountIds ?? new Set(loadAccounts().map((a) => a.id));
2402
+
2403
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
2404
+ for (const entry of entries) {
2405
+ if (!entry.isDirectory()) continue;
2406
+ const isStale = entry.name.includes(".stale-");
2407
+ const isOrphan = !isStale && !activeIds.has(entry.name);
2408
+
2409
+ if (isStale || isOrphan) {
2410
+ const targetPath = path.join(baseDir, entry.name);
2411
+ try {
2412
+ let bytes = 0;
2413
+ const countSize = (d: string) => {
2414
+ try {
2415
+ const subEntries = fs.readdirSync(d, { withFileTypes: true });
2416
+ for (const e of subEntries) {
2417
+ const full = path.join(d, e.name);
2418
+ if (e.isDirectory()) countSize(full);
2419
+ else if (e.isFile()) {
2420
+ try { bytes += fs.statSync(full).size; } catch {}
2421
+ }
2422
+ }
2423
+ } catch {}
2424
+ };
2425
+ countSize(targetPath);
2426
+ removePlaywrightProfile(targetPath);
2427
+ if (!fs.existsSync(targetPath)) {
2428
+ removedCount++;
2429
+ freedBytes += bytes;
2430
+ }
2431
+ } catch {}
2432
+ }
2433
+ }
2434
+ } catch {}
2435
+
2436
+ return { removedCount, freedBytes };
2437
+ }
2341
2438
 
2342
2439
  const PROFILE_RESET_TIMEOUT_MS = Math.max(90_000, config.timeouts.headers);
2343
2440
 
package/src/tui/app.ts CHANGED
@@ -11,7 +11,7 @@ import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
14
- let cachedAppVersion = "v1.0.4";
14
+ let cachedAppVersion = "v1.0.5";
15
15
  try {
16
16
  const currentDir = path.dirname(fileURLToPath(import.meta.url));
17
17
  const pkgPath = path.resolve(currentDir, "../../package.json");
package/src/tui/screen.ts CHANGED
@@ -33,6 +33,10 @@ export class Screen {
33
33
  private exitHandler: (() => void) | null = null;
34
34
  private prevRenderedRows: string[] = [];
35
35
  private originalStdinEmit: typeof process.stdin.emit | null = null;
36
+ private lastHoverCol = -1;
37
+ private lastHoverRow = -1;
38
+ private lastHoverTime = 0;
39
+ private pendingHoverTimeout: NodeJS.Timeout | null = null;
36
40
 
37
41
  constructor() {
38
42
  this.exitHandler = () => this.stop();
@@ -116,13 +120,42 @@ export class Screen {
116
120
  mouse: { type: "drag", button: "left", col, row },
117
121
  });
118
122
  } else if (btn === 35) {
119
- self.dispatchKey({
120
- name: "hover",
121
- ctrl: false,
122
- shift: false,
123
- meta: false,
124
- mouse: { type: "hover", col, row },
125
- });
123
+ if (col === self.lastHoverCol && row === self.lastHoverRow) {
124
+ continue;
125
+ }
126
+ const now = Date.now();
127
+ const timeSinceLast = now - self.lastHoverTime;
128
+ const emitHover = (c: number, r: number) => {
129
+ if (!self.active) return;
130
+ self.lastHoverCol = c;
131
+ self.lastHoverRow = r;
132
+ self.lastHoverTime = Date.now();
133
+ self.dispatchKey({
134
+ name: "hover",
135
+ ctrl: false,
136
+ shift: false,
137
+ meta: false,
138
+ mouse: { type: "hover", col: c, row: r },
139
+ });
140
+ };
141
+
142
+ if (timeSinceLast >= 30) {
143
+ if (self.pendingHoverTimeout) {
144
+ clearTimeout(self.pendingHoverTimeout);
145
+ self.pendingHoverTimeout = null;
146
+ }
147
+ emitHover(col, row);
148
+ } else {
149
+ if (self.pendingHoverTimeout) {
150
+ clearTimeout(self.pendingHoverTimeout);
151
+ }
152
+ self.pendingHoverTimeout = setTimeout(() => {
153
+ self.pendingHoverTimeout = null;
154
+ if (col !== self.lastHoverCol || row !== self.lastHoverRow) {
155
+ emitHover(col, row);
156
+ }
157
+ }, 30 - timeSinceLast);
158
+ }
126
159
  }
127
160
  }
128
161
  if (handled) {
@@ -212,6 +245,13 @@ export class Screen {
212
245
  process.stdin.emit = this.originalStdinEmit;
213
246
  this.originalStdinEmit = null;
214
247
  }
248
+ if (this.pendingHoverTimeout) {
249
+ clearTimeout(this.pendingHoverTimeout);
250
+ this.pendingHoverTimeout = null;
251
+ }
252
+ this.lastHoverCol = -1;
253
+ this.lastHoverRow = -1;
254
+ this.lastHoverTime = 0;
215
255
 
216
256
  // Restore original screen buffer, cursor, and disable all mouse tracking modes synchronously
217
257
  const restoreSeq = ANSI.disableMouse + ANSI.exitAltScreen + ANSI.showCursor + ANSI.reset;
@@ -95,15 +95,23 @@ export class ServerManager {
95
95
  let line = raw.trim();
96
96
  if (!line) continue;
97
97
 
98
- // Filter out raw ASCII box frames (like +-----+ or empty row | |)
98
+ // Filter out raw ASCII box frames and border rows entirely
99
99
  if (/^[+\-=#]{5,}$/.test(line)) continue;
100
100
  if (/^\|\s*\|$/.test(line)) continue;
101
-
102
- // If line is an ASCII box row like "| Endpoint http://... |", extract the text
103
- if (line.startsWith("|") && line.endsWith("|")) {
104
- line = line.slice(1, -1).trim();
101
+ if (line.startsWith("|") && line.endsWith("|")) continue;
102
+
103
+ // Filter out any box remnants (startup banner is for headless npm start only)
104
+ if (
105
+ line === "QwenProxy" ||
106
+ line === "OpenAI & Anthropic Compatible API" ||
107
+ /^Endpoint\s+http/i.test(line) ||
108
+ /^Port\s+\d+/i.test(line) ||
109
+ /^Accounts\s+\d+\/\d+/i.test(line) ||
110
+ /^API Key\s+/i.test(line) ||
111
+ /^Status\s+●/i.test(line)
112
+ ) {
113
+ continue;
105
114
  }
106
-
107
115
  if (!line) continue;
108
116
 
109
117
  // Prevent identical consecutive duplicate logs in the same second
@@ -224,7 +232,7 @@ export class ServerManager {
224
232
  this.state = "online";
225
233
  this.appendLog(
226
234
  "INFO",
227
- `QwenProxy está em execução na porta ${port}. Conectado com sucesso!`,
235
+ `✨ [Server] Conectado à instância em execução na porta ${port}`,
228
236
  );
229
237
  return;
230
238
  }
@@ -234,25 +242,25 @@ export class ServerManager {
234
242
  this.state = "warming";
235
243
  this.appendLog(
236
244
  "INFO",
237
- `Iniciando servidor QwenProxy na porta ${port} e aquecendo Playwright...`,
245
+ `🚀 [Server] Iniciando servidor na porta ${port}...`,
238
246
  );
239
247
 
240
248
  this.interceptLogs();
241
249
 
242
250
  this.startPromise = (async () => {
243
251
  try {
244
- await startServer({ installSignalHandlers: false });
252
+ await startServer({ installSignalHandlers: false, showBanner: false });
245
253
  this.state = "online";
246
254
  this.appendLog(
247
255
  "INFO",
248
- `✓ Servidor QwenProxy pronto e online em http://${cleanHost}:${port}/v1`,
256
+ `✨ [Server] QwenProxy pronto e online em http://${cleanHost}:${port}/v1`,
249
257
  );
250
258
  } catch (err: any) {
251
259
  this.state = "error";
252
260
  this.lastError = err?.message || String(err);
253
261
  this.appendLog(
254
262
  "ERROR",
255
- `✗ Falha ao iniciar servidor: ${this.lastError}`,
263
+ `❌ [Server] Falha ao iniciar servidor: ${this.lastError}`,
256
264
  );
257
265
  } finally {
258
266
  this.startPromise = null;
package/src/tui/theme.ts CHANGED
@@ -16,7 +16,7 @@ export const ANSI = {
16
16
  showCursor: "\x1b[?25h",
17
17
  enterAltScreen: "\x1b[?1049h",
18
18
  exitAltScreen: "\x1b[?1049l",
19
- enableMouse: "\x1b[?1000h\x1b[?1002h\x1b[?1006h",
19
+ enableMouse: "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h",
20
20
  disableMouse: "\x1b[?1006l\x1b[?1005l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1015l",
21
21
  };
22
22
 
@@ -181,22 +181,39 @@ const ANSI_REGEX =
181
181
  export function stripAnsi(str: string): string {
182
182
  return str.replace(ANSI_REGEX, "");
183
183
  }
184
+ const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
184
185
 
185
186
  /**
186
- * Computes visual display width of string, accounting for wide characters and emojis.
187
+ * Computes visual display width of string, accounting for wide characters,
188
+ * grapheme clusters, variation selectors (VS16), and terminal-wide emojis.
187
189
  */
188
190
  export function stringWidth(str: string): number {
189
191
  const clean = stripAnsi(str).replace(/\t/g, " ").replace(/\r/g, "");
190
192
  let width = 0;
191
- for (const char of clean) {
192
- const code = char.codePointAt(0) || 0;
193
- // Zero-width characters (variation selectors, zero-width space/joiner)
194
- if ((code >= 0xfe00 && code <= 0xfe0f) || (code >= 0x200b && code <= 0x200d)) {
193
+ for (const { segment } of graphemeSegmenter.segment(clean)) {
194
+ const code = segment.codePointAt(0) || 0;
195
+ // Standalone zero-width characters (variation selectors, zero-width space/joiner)
196
+ if (
197
+ segment === "\u200b" ||
198
+ segment === "\u200c" ||
199
+ segment === "\u200d" ||
200
+ (code >= 0xfe00 && code <= 0xfe0f && segment.length === 1)
201
+ ) {
202
+ continue;
203
+ }
204
+ // Graphemes containing variation selector 16 (emoji presentation)
205
+ if (segment.includes("\ufe0f")) {
206
+ width += 2;
195
207
  continue;
196
208
  }
197
- // Specific BMP emojis and symbols that occupy 2 visual terminal cells (e.g. ⚠️, ⚡, ✅, ❌, ✨, )
209
+ // Specific BMP emojis and symbols that occupy 2 visual terminal cells (e.g. ⚠️, ⏱, ⚡, ✅, ❌, ✨, ☕, ⚙)
198
210
  const isBmpEmoji =
199
211
  code === 0x26a0 || // ⚠️ (WARNING SIGN)
212
+ code === 0x23f1 || // ⏱ (STOPWATCH)
213
+ code === 0x23f0 || // ⏰
214
+ code === 0x23f3 || // ⏳
215
+ code === 0x231a || // ⌚
216
+ code === 0x231b || // ⌛
200
217
  code === 0x2705 || // ✅
201
218
  code === 0x2728 || // ✨
202
219
  code === 0x274c || // ❌
@@ -207,10 +224,9 @@ export function stringWidth(str: string): number {
207
224
  code === 0x2b55 || // ⭕
208
225
  code === 0x26a1 || // ⚡
209
226
  code === 0x2615 || // ☕
210
- code === 0x231a || //
211
- code === 0x231b || //
212
- code === 0x23f0 || // ⏰
213
- code === 0x23f3; // ⏳
227
+ code === 0x2699 || //
228
+ code === 0x2709; //
229
+
214
230
  // Common emoji and CJK full-width ranges (SMP Emojis 0x1f300 - 0x1faff)
215
231
  if (
216
232
  isBmpEmoji ||
@@ -244,15 +260,15 @@ export function truncate(str: string, maxWidth: number, ellipsis = "…"): strin
244
260
  const targetW = Math.max(0, maxWidth - ellipsisW);
245
261
 
246
262
  let currentW = 0;
247
- let cutIndex = 0;
248
- for (const char of clean) {
249
- const charW = stringWidth(char);
250
- if (currentW + charW > targetW) break;
251
- currentW += charW;
252
- cutIndex += char.length;
263
+ let result = "";
264
+ for (const { segment } of graphemeSegmenter.segment(clean)) {
265
+ const segW = stringWidth(segment);
266
+ if (currentW + segW > targetW) break;
267
+ currentW += segW;
268
+ result += segment;
253
269
  }
254
270
 
255
- return clean.slice(0, cutIndex) + ellipsis;
271
+ return result + ellipsis;
256
272
  }
257
273
 
258
274
  /**
@@ -328,6 +344,7 @@ export interface BoxOptions {
328
344
  borderColor?: (s: string) => string;
329
345
  titleColor?: (s: string) => string;
330
346
  footerColor?: (s: string) => string;
347
+ wrap?: boolean;
331
348
  content: string[];
332
349
  }
333
350
 
@@ -379,16 +396,17 @@ export function drawBox(options: BoxOptions): string[] {
379
396
  }
380
397
  lines.push(borderColor(b.tl) + topHeader + borderColor(b.tr));
381
398
 
382
- // Flatten and auto-wrap content lines so words are never cut off with ellipsis,
383
- // and strictly prevent any embedded \n or \r from leaking into a terminal row!
399
+ // Flatten content lines and optionally wrap prose lines.
400
+ // Fixed-height boxes do not auto-wrap by default to preserve row and scrollbar alignment.
401
+ const shouldWrap = options.wrap === true || (!options.height && options.wrap !== false);
384
402
  const expandedContent: string[] = [];
385
403
  for (const item of content) {
386
404
  const subItems = String(item ?? "").split(/\r?\n/);
387
405
  for (const sub of subItems) {
388
- if (stringWidth(sub) <= innerW) {
389
- expandedContent.push(sub);
390
- } else {
406
+ if (shouldWrap && stringWidth(sub) > innerW) {
391
407
  expandedContent.push(...wrapContentLine(sub, innerW));
408
+ } else {
409
+ expandedContent.push(sub);
392
410
  }
393
411
  }
394
412
  }
@@ -425,8 +425,10 @@ export class AccountsView implements TuiView {
425
425
  onConfirm: async () => {
426
426
  removeAccount(selected.id);
427
427
  try {
428
- const { closePlaywrightForAccount } = await import("../../services/playwright.ts");
428
+ const { closePlaywrightForAccount, removePlaywrightProfile } = await import("../../services/playwright.ts");
429
+ const { getAccountProfilePath } = await import("../../core/paths.ts");
429
430
  await closePlaywrightForAccount(selected.id);
431
+ removePlaywrightProfile(getAccountProfilePath(selected.id));
430
432
  } catch {}
431
433
  await this.refresh();
432
434
  this.setStatusMessage(theme.green(`✓ Conta ${selected.emailOrName} removida com sucesso`));
@@ -633,12 +635,12 @@ export class AccountsView implements TuiView {
633
635
  const isHovered = idx === this.hoveredAccountIndex;
634
636
  const pointer = isFocused ? theme.cyan(`${glyphs.pointer} `) : " ";
635
637
  const num = pad(String(idx + 1) + ".", 4);
636
- const name = pad(acc.emailOrName, 22);
638
+ const name = pad(truncate(acc.emailOrName, 20), 22);
637
639
 
638
640
  let status = theme.green(`${glyphs.bullet} Pronto `);
639
641
  if (acc.onCooldown) {
640
642
  const mins = Math.max(1, Math.round(acc.remainingCooldownMs / 60000));
641
- status = theme.yellow(`⚠ ${mins}m cd `);
643
+ status = theme.yellow(`⚠️ ${mins}m cd `);
642
644
  } else if (!acc.headersReady) {
643
645
  status = acc.isInitialized
644
646
  ? theme.yellow(`◐ Aquecendo...`)
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { TuiView, ProxyStatusSnapshot } from "../types.ts";
6
6
  import type { KeyEvent } from "../screen.ts";
7
- import { theme, glyphs, drawBox, pad } from "../theme.ts";
7
+ import { theme, glyphs, drawBox, pad, truncate } from "../theme.ts";
8
8
  import { fetchProxyStatus, resetAllCooldowns, formatUptime } from "../proxy-client.ts";
9
9
  import { ServerManager } from "../server-manager.ts";
10
10
 
@@ -169,17 +169,17 @@ export class StatusView implements TuiView {
169
169
  } else {
170
170
  accounts.slice(0, contentH - 5).forEach((acc, idx) => {
171
171
  const num = pad(String(idx + 1), 3);
172
- const name = pad(acc.emailOrName, 22);
172
+ const name = pad(truncate(acc.emailOrName, 20), 20);
173
173
  let status = theme.green(`${glyphs.bullet} Pronto`);
174
174
  if (acc.onCooldown) {
175
175
  const mins = Math.max(1, Math.round(acc.remainingCooldownMs / 60000));
176
- status = theme.yellow(`⚠ Cooldown ${mins}m`);
176
+ status = theme.yellow(`⚠️ Cooldown ${mins}m`);
177
177
  } else if (!acc.headersReady) {
178
178
  status = acc.isInitialized
179
179
  ? theme.yellow(`◐ Aquecendo...`)
180
180
  : theme.muted(`○ Standby`);
181
181
  }
182
- rightContent.push(` ${num} ${name} ${status}`);
182
+ rightContent.push(` ${num} ${name} ${status}`);
183
183
  });
184
184
  }
185
185
 
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import type { TuiView } from "../types.ts";
8
8
  import type { KeyEvent } from "../screen.ts";
9
9
  import { theme, glyphs, drawBox, pad } from "../theme.ts";
10
- import { pruneAllPlaywrightProfiles } from "../../services/playwright.ts";
10
+ import { pruneAllPlaywrightProfiles, cleanupOrphanProfiles } from "../../services/playwright.ts";
11
11
  import { getProfilesDir } from "../../core/paths.ts";
12
12
  import {
13
13
  formatBytes,
@@ -79,6 +79,9 @@ export class StorageView implements TuiView {
79
79
  this.isScanning = true;
80
80
 
81
81
  try {
82
+ // 0. Auto-prune orphan directories from removed accounts
83
+ cleanupOrphanProfiles();
84
+
82
85
  // 1. Scan profiles
83
86
  const profilesDir = getProfilesDir();
84
87
  let totalBytes = 0;
@@ -241,11 +244,12 @@ export class StorageView implements TuiView {
241
244
  if ((key.name === "p" || key.name === "P") && !key.ctrl) {
242
245
  try {
243
246
  const res = pruneAllPlaywrightProfiles();
244
- this.addLog(
245
- theme.green(
246
- `✓ Caches limpos: ${formatBytes(res.totalFreedBytes)} liberados em ${res.totalFreedFiles} arquivos (${res.profilesCleaned} perfis)`,
247
- ),
248
- );
247
+ const orphanRes = cleanupOrphanProfiles();
248
+ let logMsg = `✓ Caches limpos: ${formatBytes(res.totalFreedBytes)} liberados em ${res.totalFreedFiles} arquivos (${res.profilesCleaned} perfis)`;
249
+ if (orphanRes.removedCount > 0) {
250
+ logMsg += ` + ${orphanRes.removedCount} perfil(is) órfão(s) removido(s)`;
251
+ }
252
+ this.addLog(theme.green(logMsg));
249
253
  await this.refresh();
250
254
  } catch (err: any) {
251
255
  this.addLog(theme.red(`✗ Falha ao limpar perfis: ${err?.message || String(err)}`));
@@ -260,7 +264,7 @@ export class StorageView implements TuiView {
260
264
  if (res.freedBytes > 0 || res.unusedDirs.length > 0) {
261
265
  this.addLog(
262
266
  theme.green(
263
- `✓ Navegadores limpos: ${formatBytes(res.freedBytes)} recuperados no SSD (${res.unusedDirs.length} versões removidas)`,
267
+ `✓ Navegadores limpos: ${formatBytes(res.freedBytes)} recuperados em disco (${res.unusedDirs.length} versões removidas)`,
264
268
  ),
265
269
  );
266
270
  } else {