qwenproxy-cli 1.2.3 → 1.2.4

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.3",
3
+ "version": "1.2.4",
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": {
@@ -26,8 +26,8 @@
26
26
  "reset": "npx tsx src/reset-cooldowns.ts",
27
27
  "purge": "npx tsx src/delete-chats.ts",
28
28
  "test": "npm run test:mock && npm run test:live",
29
- "test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts",
30
- "test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts",
29
+ "test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts",
30
+ "test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts",
31
31
  "test:live": "tsx --test src/tests/concurrentChat.test.ts src/tests/agenticStress.test.ts",
32
32
  "benchmark:proxy": "npx tsx src/benchmarks/proxy-baseline.ts",
33
33
  "typecheck": "tsc --noEmit"
@@ -261,7 +261,18 @@ app.post("/v1/messages", async (c) => {
261
261
  `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
262
262
  );
263
263
  } catch (error: any) {
264
- console.error("❌ [Anthropic] Stream error:", error?.message || error);
264
+ const isClientAbort =
265
+ c.req.raw.signal.aborted ||
266
+ error?.name === "AbortError" ||
267
+ error?.code === "ECONNRESET" ||
268
+ (typeof error?.message === "string" &&
269
+ (error.message.includes("prematurely closed") ||
270
+ error.message.includes("aborted") ||
271
+ error.message.includes("canceled") ||
272
+ error.message.includes("cancelled")));
273
+ if (!isClientAbort) {
274
+ console.error("❌ [Anthropic] Stream error:", error?.message || error);
275
+ }
265
276
  try {
266
277
  await write(
267
278
  `event: error\ndata: ${JSON.stringify({
@@ -1485,6 +1485,7 @@ export async function processStreamingResponse(
1485
1485
  }
1486
1486
 
1487
1487
  const previousUiSessionId = currentUiSessionId;
1488
+ const previousAccountId = currentAccountId;
1488
1489
  currentAccountId = newStreamResult.activeAccountId;
1489
1490
  currentUiSessionId = newStreamResult.uiSessionId;
1490
1491
  retryContext.releaseAccountLease =
@@ -1517,9 +1518,18 @@ export async function processStreamingResponse(
1517
1518
  });
1518
1519
  }
1519
1520
 
1520
- console.log(
1521
- `🔄 [Chat] Stream recovery switched account | old=${previousUiSessionId.substring(0, 12)} | new=${currentUiSessionId.substring(0, 12)} | account=${currentAccountId}`,
1522
- );
1521
+ const switched =
1522
+ previousUiSessionId !== currentUiSessionId ||
1523
+ previousAccountId !== currentAccountId;
1524
+ if (switched) {
1525
+ console.log(
1526
+ `🔄 [Chat] Stream recovery switched account | old=${previousUiSessionId.substring(0, 12)} | new=${currentUiSessionId.substring(0, 12)} | account=${currentAccountId}`,
1527
+ );
1528
+ } else {
1529
+ console.log(
1530
+ `🔄 [Chat] Stream recovery resumed | chat=${currentUiSessionId.substring(0, 12)} | account=${currentAccountId}`,
1531
+ );
1532
+ }
1523
1533
  reader = newStreamResult.stream.getReader();
1524
1534
  activeReader = reader;
1525
1535
  return true;
@@ -2453,18 +2453,24 @@ export class StreamingToolParser {
2453
2453
  recoveryAttempts,
2454
2454
  });
2455
2455
 
2456
- logger.warn(
2457
- `[parser] Dropping malformed tool call (${t.length} chars): ${t.substring(0, 80).replace(/\n/g, " ")}...`,
2458
- {
2459
- toolName: droppedToolName,
2460
- category: "malformed",
2461
- contentLength: t.length,
2462
- content: t.substring(0, 2000),
2463
- failureReason: "all recovery stages failed to produce valid JSON",
2464
- recoveryAttempts,
2465
- declaredTools: [...this.declaredToolNames].slice(0, 10),
2466
- },
2467
- );
2456
+ if (isToolcallDebugEnabled()) {
2457
+ logger.warn(
2458
+ `[parser] Dropping malformed tool call (${t.length} chars): ${t.substring(0, 80).replace(/\n/g, " ")}...`,
2459
+ {
2460
+ toolName: droppedToolName,
2461
+ category: "malformed",
2462
+ contentLength: t.length,
2463
+ content: t.substring(0, 2000),
2464
+ failureReason: "all recovery stages failed to produce valid JSON",
2465
+ recoveryAttempts,
2466
+ declaredTools: [...this.declaredToolNames].slice(0, 10),
2467
+ },
2468
+ );
2469
+ } else {
2470
+ logger.warn(
2471
+ `[parser] Dropping malformed tool call (${t.length} chars)${droppedToolName ? ` [${droppedToolName}]` : ""}: ${t.substring(0, 80).replace(/\n/g, " ")}...`,
2472
+ );
2473
+ }
2468
2474
  if (
2469
2475
  this.emittedToolCallCount === 0 &&
2470
2476
  this.pendingLeadIn.trim().length > 0
@@ -3018,8 +3024,24 @@ export class StreamingToolParser {
3018
3024
  private parseToolCall(parsed: any): ParsedToolCall | null {
3019
3025
  if (!parsed || typeof parsed !== "object") return null;
3020
3026
 
3021
- const name =
3027
+ let name =
3022
3028
  parsed.name || parsed.function?.name || parsed.tool_name || parsed.tool;
3029
+ if (!name || typeof name !== "string" || name.length === 0) {
3030
+ const candidateArgs =
3031
+ parsed.arguments ||
3032
+ parsed.function?.arguments ||
3033
+ parsed.args ||
3034
+ parsed.parameters ||
3035
+ parsed.input ||
3036
+ parsed;
3037
+ const parsedCandidateArgs =
3038
+ typeof candidateArgs === "string"
3039
+ ? parseJsonishString(candidateArgs) ?? {}
3040
+ : typeof candidateArgs === "object" && candidateArgs !== null
3041
+ ? candidateArgs
3042
+ : {};
3043
+ name = inferToolNameFromParameters(parsedCandidateArgs, this.tools);
3044
+ }
3023
3045
  if (!name || typeof name !== "string" || name.length === 0) return null;
3024
3046
 
3025
3047
  // Drop hallucinated tool calls where the model split a value vertically
package/src/tui/theme.ts CHANGED
@@ -191,67 +191,81 @@ export function stripAnsi(str: string): string {
191
191
  }
192
192
  const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
193
193
 
194
+ function singleGraphemeWidth(segment: string): number {
195
+ const code = segment.codePointAt(0) || 0;
196
+ // Standalone zero-width characters (variation selectors, zero-width space/joiner)
197
+ if (
198
+ segment === "\u200b" ||
199
+ segment === "\u200c" ||
200
+ segment === "\u200d" ||
201
+ (code >= 0xfe00 && code <= 0xfe0f && segment.length === 1)
202
+ ) {
203
+ return 0;
204
+ }
205
+ // Graphemes containing variation selector 16 (emoji presentation)
206
+ if (segment.includes("\ufe0f")) {
207
+ return 2;
208
+ }
209
+ // Specific BMP emojis and symbols that occupy 2 visual terminal cells (e.g. ⚠️, ⏱, ⚡, ✅, ❌, ✨, ☕, ⚙)
210
+ const isBmpEmoji =
211
+ code === 0x26a0 || // ⚠️ (WARNING SIGN)
212
+ code === 0x23f1 || // ⏱ (STOPWATCH)
213
+ code === 0x23f0 || // ⏰
214
+ code === 0x23f3 || // ⏳
215
+ code === 0x231a || // ⌚
216
+ code === 0x231b || // ⌛
217
+ code === 0x2705 || // ✅
218
+ code === 0x2728 || // ✨
219
+ code === 0x274c || // ❌
220
+ code === 0x274e || // ❎
221
+ code === 0x2753 || // ❓
222
+ code === 0x2757 || // ❗
223
+ code === 0x2b50 || // ⭐
224
+ code === 0x2b55 || // ⭕
225
+ code === 0x26a1 || // ⚡
226
+ code === 0x2615 || // ☕
227
+ code === 0x2699 || // ⚙
228
+ code === 0x2709; // ✉
229
+
230
+ // Common emoji and CJK full-width ranges (SMP Emojis 0x1f300 - 0x1faff)
231
+ if (
232
+ isBmpEmoji ||
233
+ (code >= 0x1100 && code <= 0x115f) ||
234
+ (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
235
+ (code >= 0xac00 && code <= 0xd7a3) ||
236
+ (code >= 0xf900 && code <= 0xfaff) ||
237
+ (code >= 0xfe10 && code <= 0xfe19) ||
238
+ (code >= 0xfe30 && code <= 0xfe6f) ||
239
+ (code >= 0xff01 && code <= 0xff60) ||
240
+ (code >= 0xffe0 && code <= 0xffe6) ||
241
+ (code >= 0x1f300 && code <= 0x1faff)
242
+ ) {
243
+ return 2;
244
+ }
245
+ return 1;
246
+ }
247
+
194
248
  /**
195
249
  * Computes visual display width of string, accounting for wide characters,
196
250
  * grapheme clusters, variation selectors (VS16), and terminal-wide emojis.
197
251
  */
198
252
  export function stringWidth(str: string): number {
253
+ if (!str) return 0;
254
+ // Fast path for pure ASCII strings without ANSI escapes
255
+ let isAscii = true;
256
+ for (let i = 0; i < str.length; i++) {
257
+ const c = str.charCodeAt(i);
258
+ if (c < 32 || c > 126) {
259
+ isAscii = false;
260
+ break;
261
+ }
262
+ }
263
+ if (isAscii) return str.length;
264
+
199
265
  const clean = stripAnsi(str).replace(/\t/g, " ").replace(/\r/g, "");
200
266
  let width = 0;
201
267
  for (const { segment } of graphemeSegmenter.segment(clean)) {
202
- const code = segment.codePointAt(0) || 0;
203
- // Standalone zero-width characters (variation selectors, zero-width space/joiner)
204
- if (
205
- segment === "\u200b" ||
206
- segment === "\u200c" ||
207
- segment === "\u200d" ||
208
- (code >= 0xfe00 && code <= 0xfe0f && segment.length === 1)
209
- ) {
210
- continue;
211
- }
212
- // Graphemes containing variation selector 16 (emoji presentation)
213
- if (segment.includes("\ufe0f")) {
214
- width += 2;
215
- continue;
216
- }
217
- // Specific BMP emojis and symbols that occupy 2 visual terminal cells (e.g. ⚠️, ⏱, ⚡, ✅, ❌, ✨, ☕, ⚙)
218
- const isBmpEmoji =
219
- code === 0x26a0 || // ⚠️ (WARNING SIGN)
220
- code === 0x23f1 || // ⏱ (STOPWATCH)
221
- code === 0x23f0 || // ⏰
222
- code === 0x23f3 || // ⏳
223
- code === 0x231a || // ⌚
224
- code === 0x231b || // ⌛
225
- code === 0x2705 || // ✅
226
- code === 0x2728 || // ✨
227
- code === 0x274c || // ❌
228
- code === 0x274e || // ❎
229
- code === 0x2753 || // ❓
230
- code === 0x2757 || // ❗
231
- code === 0x2b50 || // ⭐
232
- code === 0x2b55 || // ⭕
233
- code === 0x26a1 || // ⚡
234
- code === 0x2615 || // ☕
235
- code === 0x2699 || // ⚙
236
- code === 0x2709; // ✉
237
-
238
- // Common emoji and CJK full-width ranges (SMP Emojis 0x1f300 - 0x1faff)
239
- if (
240
- isBmpEmoji ||
241
- (code >= 0x1100 && code <= 0x115f) ||
242
- (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
243
- (code >= 0xac00 && code <= 0xd7a3) ||
244
- (code >= 0xf900 && code <= 0xfaff) ||
245
- (code >= 0xfe10 && code <= 0xfe19) ||
246
- (code >= 0xfe30 && code <= 0xfe6f) ||
247
- (code >= 0xff01 && code <= 0xff60) ||
248
- (code >= 0xffe0 && code <= 0xffe6) ||
249
- (code >= 0x1f300 && code <= 0x1faff)
250
- ) {
251
- width += 2;
252
- } else {
253
- width += 1;
254
- }
268
+ width += singleGraphemeWidth(segment);
255
269
  }
256
270
  return width;
257
271
  }
@@ -261,8 +275,26 @@ export function stringWidth(str: string): number {
261
275
  */
262
276
  export function truncate(str: string, maxWidth: number, ellipsis = "…"): string {
263
277
  if (maxWidth <= 0) return "";
278
+ if (!str) return "";
279
+
280
+ // Fast path for pure ASCII strings
281
+ let isAscii = true;
282
+ for (let i = 0; i < str.length; i++) {
283
+ const c = str.charCodeAt(i);
284
+ if (c < 32 || c > 126) {
285
+ isAscii = false;
286
+ break;
287
+ }
288
+ }
289
+ if (isAscii) {
290
+ if (str.length <= maxWidth) return str;
291
+ const targetW = Math.max(0, maxWidth - 1);
292
+ return str.slice(0, targetW) + ellipsis;
293
+ }
294
+
264
295
  const clean = stripAnsi(str).replace(/\t/g, " ").replace(/\r/g, "");
265
- if (stringWidth(clean) <= maxWidth) return str;
296
+ const cleanW = stringWidth(clean);
297
+ if (cleanW <= maxWidth) return str;
266
298
 
267
299
  const ellipsisW = stringWidth(ellipsis);
268
300
  const targetW = Math.max(0, maxWidth - ellipsisW);
@@ -270,7 +302,7 @@ export function truncate(str: string, maxWidth: number, ellipsis = "…"): strin
270
302
  let currentW = 0;
271
303
  let result = "";
272
304
  for (const { segment } of graphemeSegmenter.segment(clean)) {
273
- const segW = stringWidth(segment);
305
+ const segW = singleGraphemeWidth(segment);
274
306
  if (currentW + segW > targetW) break;
275
307
  currentW += segW;
276
308
  result += segment;
@@ -89,58 +89,56 @@ export class LogsView implements TuiView {
89
89
  }
90
90
 
91
91
  public handleKey(key: KeyEvent): boolean | void {
92
- const rawEntries = ServerManager.getInstance().getLogEntries(this.filter);
93
- const { chips } = this.getChips(rawEntries.length);
94
-
95
- // Mouse hover on chips (accepts rows 3 to 6 for generous vertical target)
96
- if (key.name === "hover" && key.mouse) {
92
+ // Mouse hover or click on chips (only compute chips when mouse is on chip rows 3 to 6)
93
+ if ((key.name === "hover" || key.name === "click") && key.mouse) {
97
94
  if (key.mouse.row >= 3 && key.mouse.row <= 6) {
95
+ const rawCount = ServerManager.getInstance().getLogEntries(this.filter).length;
96
+ const { chips } = this.getChips(rawCount);
98
97
  const col = key.mouse.col;
99
- let target: "all" | "warn" | "error" | "copy" | "clear" | null = null;
100
- for (const c of chips) {
101
- if (col >= c.startCol && col <= c.endCol) {
102
- target = c.id;
103
- break;
104
- }
105
- }
106
- if (this.hoveredChip !== target) {
107
- this.hoveredChip = target;
108
- return true;
109
- }
110
- } else if (this.hoveredChip !== null) {
111
- this.hoveredChip = null;
112
- return true;
113
- }
114
- }
115
98
 
116
- // Mouse click on chips (accepts rows 3 to 6 for effortless immediate single-click)
117
- if (key.name === "click" && key.mouse && key.mouse.row >= 3 && key.mouse.row <= 6) {
118
- const col = key.mouse.col;
119
- for (const c of chips) {
120
- if (col >= c.startCol && col <= c.endCol) {
121
- if (c.id === "all") {
122
- this.setFilter("all");
123
- return true;
99
+ if (key.name === "hover") {
100
+ let target: "all" | "warn" | "error" | "copy" | "clear" | null = null;
101
+ for (const c of chips) {
102
+ if (col >= c.startCol && col <= c.endCol) {
103
+ target = c.id;
104
+ break;
105
+ }
124
106
  }
125
- if (c.id === "warn") {
126
- this.setFilter("warn");
107
+ if (this.hoveredChip !== target) {
108
+ this.hoveredChip = target;
127
109
  return true;
128
110
  }
129
- if (c.id === "error") {
130
- this.setFilter("error");
131
- return true;
132
- }
133
- if (c.id === "copy") {
134
- this.copyLogs();
135
- return true;
136
- }
137
- if (c.id === "clear") {
138
- ServerManager.getInstance().clearLogs();
139
- this.scrollOffset = 0;
140
- this.selectedLogIndex = null;
141
- return true;
111
+ } else if (key.name === "click") {
112
+ for (const c of chips) {
113
+ if (col >= c.startCol && col <= c.endCol) {
114
+ if (c.id === "all") {
115
+ this.setFilter("all");
116
+ return true;
117
+ }
118
+ if (c.id === "warn") {
119
+ this.setFilter("warn");
120
+ return true;
121
+ }
122
+ if (c.id === "error") {
123
+ this.setFilter("error");
124
+ return true;
125
+ }
126
+ if (c.id === "copy") {
127
+ this.copyLogs();
128
+ return true;
129
+ }
130
+ if (c.id === "clear") {
131
+ ServerManager.getInstance().clearLogs();
132
+ this.scrollOffset = 0;
133
+ this.selectedLogIndex = null;
134
+ return true;
135
+ }
136
+ }
142
137
  }
143
138
  }
139
+ } else if (this.hoveredChip !== null) {
140
+ this.hoveredChip = null;
141
+ return true;
144
142
  }
145
143
  }
146
144
 
@@ -351,39 +349,11 @@ export class LogsView implements TuiView {
351
349
  ? theme.bgHover(" [ C ] Limpar ")
352
350
  : theme.muted(" [ C ] Limpar ");
353
351
 
354
- const formattedLines: string[] = [];
355
-
356
- if (rawEntries.length === 0) {
357
- formattedLines.push("");
358
- formattedLines.push(
359
- theme.muted(` Nenhum log registrado para o filtro atual [${this.filter}].`),
360
- );
361
- formattedLines.push(
362
- theme.muted(" Eventos de inicialização, requisições e alertas do proxy aparecerão aqui."),
363
- );
364
- } else {
365
- for (const entry of rawEntries) {
366
- if (!entry || !entry.message || !entry.message.trim()) continue;
367
- let tag = theme.dim("[INFO]");
368
- if (entry.level === "WARN") {
369
- tag = theme.yellow("[WARN]");
370
- } else if (entry.level === "ERROR") {
371
- tag = theme.red("[ERR]");
372
- }
373
-
374
- const prefix = `${theme.dim(entry.time)} ${tag} `;
375
- const prefixW = stringWidth(prefix);
376
- const maxMsgW = Math.max(20, innerW - prefixW - 4);
377
- const truncatedMsg = truncate(entry.message, maxMsgW);
378
- formattedLines.push(`${prefix}${truncatedMsg}`);
379
- }
380
- }
381
-
382
- this.lastTotalCount = formattedLines.length;
352
+ const total = rawEntries.length;
353
+ this.lastTotalCount = total;
383
354
 
384
355
  // Scroll Window with top margin breathing room (2 rows reserved at the top)
385
356
  const visibleCapacity = Math.max(1, contentH - 4);
386
- const total = formattedLines.length;
387
357
  const maxOffset = Math.max(0, total - visibleCapacity);
388
358
  this.lastWidth = width;
389
359
  this.lastHeight = height;
@@ -394,10 +364,10 @@ export class LogsView implements TuiView {
394
364
  const scrollFromTop = maxOffset - clampedOffset;
395
365
 
396
366
  const startIndex = Math.max(0, total - visibleCapacity - clampedOffset);
397
- const visibleSlice = formattedLines.slice(startIndex, startIndex + visibleCapacity);
367
+ const visibleEntries = total === 0 ? [] : rawEntries.slice(startIndex, startIndex + visibleCapacity);
398
368
 
399
369
  this.lastStartIndex = startIndex;
400
- this.lastVisibleCount = visibleSlice.length;
370
+ this.lastVisibleCount = visibleEntries.length;
401
371
 
402
372
  // Scrollbar calculation
403
373
  const hasScrollbar = total > visibleCapacity;
@@ -416,33 +386,64 @@ export class LogsView implements TuiView {
416
386
  const finalRows: string[] = ["", ""];
417
387
  const boxInnerW = Math.max(1, width - 2);
418
388
 
419
- for (let r = 0; r < visibleCapacity; r++) {
420
- if (r < visibleSlice.length) {
421
- const actualIdx = startIndex + r;
422
- const rawLine = visibleSlice[r];
423
- let styledText = rawLine;
424
- if (this.selectedLogIndex === actualIdx) {
425
- styledText = theme.bgSelected(`▸ ${stripAnsi(rawLine)} `);
426
- } else {
427
- styledText = ` ${rawLine}`;
428
- }
389
+ if (total === 0) {
390
+ finalRows.push("");
391
+ finalRows.push(
392
+ theme.muted(` Nenhum log registrado para o filtro atual [${this.filter}].`),
393
+ );
394
+ finalRows.push(
395
+ theme.muted(" Eventos de inicialização, requisições e alertas do proxy aparecerão aqui."),
396
+ );
397
+ while (finalRows.length < visibleCapacity + 2) {
398
+ finalRows.push("");
399
+ }
400
+ } else {
401
+ for (let r = 0; r < visibleCapacity; r++) {
402
+ if (r < visibleEntries.length) {
403
+ const actualIdx = startIndex + r;
404
+ const entry = visibleEntries[r];
405
+ let formattedLine = "";
406
+ if (entry && entry.message && entry.message.trim()) {
407
+ let tag = theme.dim("[INFO]");
408
+ if (entry.level === "WARN") {
409
+ tag = theme.yellow("[WARN]");
410
+ } else if (entry.level === "ERROR") {
411
+ tag = theme.red("[ERR]");
412
+ }
413
+
414
+ const prefix = `${theme.dim(entry.time)} ${tag} `;
415
+ const prefixW = 16;
416
+ const maxMsgW = Math.max(20, innerW - prefixW - 4);
417
+ const truncatedMsg = truncate(entry.message, maxMsgW);
418
+ formattedLine = `${prefix}${truncatedMsg}`;
419
+ }
429
420
 
430
- if (hasScrollbar) {
431
- const isThumb = r >= thumbTop && r < thumbTop + thumbSize;
432
- const isHighlighted = this.isScrollbarHovered || this.isDraggingScrollbar;
433
- let scrollChar: string;
434
- if (isThumb) {
435
- scrollChar = isHighlighted ? `\x1b[48;2;45;35;85m\x1b[38;2;0;255;255m\x1b[1m█\x1b[0m` : theme.cyan("█");
421
+ let styledText = formattedLine;
422
+ if (this.selectedLogIndex === actualIdx) {
423
+ styledText = theme.bgSelected(`▸ ${stripAnsi(formattedLine)} `);
436
424
  } else {
437
- scrollChar = isHighlighted ? theme.cyan("│") : theme.dark("│");
425
+ styledText = ` ${formattedLine}`;
426
+ }
427
+
428
+ if (hasScrollbar) {
429
+ const isThumb = r >= thumbTop && r < thumbTop + thumbSize;
430
+ const isHighlighted = this.isScrollbarHovered || this.isDraggingScrollbar;
431
+ let scrollChar: string;
432
+ if (isThumb) {
433
+ scrollChar = isHighlighted
434
+ ? `\x1b[48;2;45;35;85m\x1b[38;2;0;255;255m\x1b[1m█\x1b[0m`
435
+ : theme.cyan("█");
436
+ } else {
437
+ scrollChar = isHighlighted ? theme.cyan("│") : theme.dark("│");
438
+ }
439
+ const padded = pad(styledText, boxInnerW - 1);
440
+ finalRows.push(`${padded}${scrollChar}`);
441
+ } else {
442
+ finalRows.push(styledText);
438
443
  }
439
- const padded = pad(styledText, boxInnerW - 1);
440
- finalRows.push(`${padded}${scrollChar}`);
441
444
  } else {
442
- finalRows.push(styledText);
445
+ finalRows.push("");
443
446
  }
444
- } else {
445
- finalRows.push("");
446
447
  }
447
448
  }
448
449