qwenproxy-cli 1.2.3 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.2.3",
3
+ "version": "1.2.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": {
@@ -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 src/tests/tui-logs-copy.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 src/tests/tui-logs-copy.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"
@@ -121,6 +121,8 @@ export function loadAccounts(): QwenAccount[] {
121
121
  export function invalidateAccountsCache(): void {
122
122
  accountsCache = null;
123
123
  accountsCacheTime = 0;
124
+ lastSyncedEnv = "";
125
+ lastSyncTime = 0;
124
126
  }
125
127
  export interface BatchAccountEntry {
126
128
  email: string;
package/src/core/paths.ts CHANGED
@@ -188,6 +188,20 @@ export function getEnvFilePath(customDataDir?: string): string {
188
188
  return path.join(customDataDir || getDataDir(), ".env");
189
189
  }
190
190
 
191
+ /**
192
+ * Path to logs directory.
193
+ */
194
+ export function getLogsDir(customDataDir?: string): string {
195
+ return path.join(customDataDir || getDataDir(), "logs");
196
+ }
197
+
198
+ /**
199
+ * Path to persistent server log file.
200
+ */
201
+ export function getServerLogFilePath(customDataDir?: string): string {
202
+ return path.join(getLogsDir(customDataDir), "server.log");
203
+ }
204
+
191
205
  /**
192
206
  * Ensures all standard directories exist on disk with proper recursive creation.
193
207
  */
@@ -195,11 +209,13 @@ export function ensureDataDirs(targetDataDir?: string): void {
195
209
  const root = targetDataDir || getDataDir();
196
210
  const dbDir = getDbDir(root);
197
211
  const profilesDir = getProfilesDir(root);
212
+ const logsDir = getLogsDir(root);
198
213
 
199
214
  try {
200
215
  if (!fs.existsSync(root)) fs.mkdirSync(root, { recursive: true });
201
216
  if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
202
217
  if (!fs.existsSync(profilesDir)) fs.mkdirSync(profilesDir, { recursive: true });
218
+ if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
203
219
  } catch (err: any) {
204
220
  console.error(`[Paths] Error ensuring data directories at ${root}:`, err?.message || String(err));
205
221
  }
@@ -3,7 +3,9 @@
3
3
  * Captures server logs and streams them to connected TUI instances.
4
4
  */
5
5
 
6
+ import fs from "node:fs";
6
7
  import { stripAnsi } from "../tui/theme.ts";
8
+ import { getServerLogFilePath, ensureDataDirs, isRunningUnderNodeTest } from "./paths.ts";
7
9
 
8
10
  export interface ServerLogMessage {
9
11
  time: string;
@@ -11,11 +13,33 @@ export interface ServerLogMessage {
11
13
  message: string;
12
14
  }
13
15
 
14
- const MAX_LOG_HISTORY = 400;
16
+ const MAX_LOG_HISTORY = 2000;
15
17
  const logHistory: ServerLogMessage[] = [];
16
18
  const subscribers = new Set<(entry: ServerLogMessage) => void>();
17
19
  let consoleHooked = false;
18
20
 
21
+ function appendServerLogToFile(time: string, level: string, message: string): void {
22
+ if (isRunningUnderNodeTest()) return;
23
+ try {
24
+ const filePath = getServerLogFilePath();
25
+ if (fs.existsSync(filePath)) {
26
+ const stats = fs.statSync(filePath);
27
+ if (stats.size > 20 * 1024 * 1024) { // Rotate at 20MB
28
+ const oldPath = filePath + ".old";
29
+ try {
30
+ if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
31
+ fs.renameSync(filePath, oldPath);
32
+ } catch {}
33
+ }
34
+ } else {
35
+ ensureDataDirs();
36
+ }
37
+ const dateStr = new Date().toLocaleDateString("pt-BR");
38
+ const line = `[${dateStr} ${time}] [${level}] ${message}\n`;
39
+ fs.appendFileSync(filePath, line, "utf-8");
40
+ } catch {}
41
+ }
42
+
19
43
  export function recordServerLog(level: "INFO" | "WARN" | "ERROR", text: string): void {
20
44
  if (!text) return;
21
45
  const clean = stripAnsi(text).trim();
@@ -60,6 +84,7 @@ export function recordServerLog(level: "INFO" | "WARN" | "ERROR", text: string):
60
84
  if (logHistory.length > MAX_LOG_HISTORY) {
61
85
  logHistory.shift();
62
86
  }
87
+ appendServerLogToFile(time, level, line);
63
88
 
64
89
  for (const sub of subscribers) {
65
90
  try {
@@ -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
@@ -6,14 +6,14 @@
6
6
  import { config } from "../core/config.ts";
7
7
  import { startServer, stopServer } from "../api/server.ts";
8
8
  import { stripAnsi } from "./theme.ts";
9
- export type ServerLifecycleState = "offline" | "warming" | "online" | "error";
9
+ import { recordServerLog } from "../core/server-log-buffer.ts";
10
10
 
11
+ export type ServerLifecycleState = "offline" | "warming" | "online" | "error";
11
12
  export interface ServerLogEntry {
12
13
  time: string;
13
14
  level: "INFO" | "WARN" | "ERROR";
14
15
  message: string;
15
16
  }
16
-
17
17
  export class ServerManager {
18
18
  private static instance: ServerManager | null = null;
19
19
 
@@ -127,19 +127,21 @@ export class ServerManager {
127
127
  }
128
128
 
129
129
  this.logEntries.push({ time, level, message: line });
130
- if (this.logEntries.length > 500) {
130
+ if (this.logEntries.length > 2000) {
131
131
  this.logEntries.shift();
132
132
  }
133
133
 
134
134
  const levelTag = level === "ERROR" ? "[ERR]" : level === "WARN" ? "[WARN]" : "";
135
135
  const formatted = `[${time}] ${levelTag ? levelTag + " " : ""}${line}`;
136
136
  this.logBuffer.push(formatted);
137
- if (this.logBuffer.length > 500) {
137
+ if (this.logBuffer.length > 2000) {
138
138
  this.logBuffer.shift();
139
139
  }
140
+ try {
141
+ recordServerLog(level, line);
142
+ } catch {}
140
143
  }
141
144
  }
142
-
143
145
  public interceptLogs(): void {
144
146
  if (this.intercepted) return;
145
147
  this.intercepted = true;
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;
@@ -3,12 +3,13 @@
3
3
  * Real-time event log viewer with level filtering (All, Warnings, Errors).
4
4
  */
5
5
 
6
+ import fs from "node:fs";
6
7
  import type { TuiView } from "../types.ts";
7
8
  import type { KeyEvent } from "../screen.ts";
8
9
  import { theme, drawBox, stringWidth, truncate, pad, stripAnsi, setClipboardText } from "../theme.ts";
9
10
  import { ServerManager } from "../server-manager.ts";
10
11
  import { loadTuiSettings, saveTuiSettings } from "../settings.ts";
11
-
12
+ import { getServerLogFilePath } from "../../core/paths.ts";
12
13
  export class LogsView implements TuiView {
13
14
  public readonly id = "logs";
14
15
  public readonly title = "Logs";
@@ -89,58 +90,56 @@ export class LogsView implements TuiView {
89
90
  }
90
91
 
91
92
  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) {
97
- if (key.mouse.row >= 3 && key.mouse.row <= 6) {
93
+ // Mouse hover or click on chips (only compute chips when mouse is on chip rows 3 to 6)
94
+ if ((key.name === "hover" || key.name === "click") && key.mouse) {
95
+ if (key.mouse.row === 3) {
96
+ const rawCount = ServerManager.getInstance().getLogEntries(this.filter).length;
97
+ const { chips } = this.getChips(rawCount);
98
98
  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
99
 
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;
100
+ if (key.name === "hover") {
101
+ let target: "all" | "warn" | "error" | "copy" | "clear" | null = null;
102
+ for (const c of chips) {
103
+ if (col >= c.startCol && col <= c.endCol) {
104
+ target = c.id;
105
+ break;
106
+ }
124
107
  }
125
- if (c.id === "warn") {
126
- this.setFilter("warn");
108
+ if (this.hoveredChip !== target) {
109
+ this.hoveredChip = target;
127
110
  return true;
128
111
  }
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;
112
+ } else if (key.name === "click") {
113
+ for (const c of chips) {
114
+ if (col >= c.startCol && col <= c.endCol) {
115
+ if (c.id === "all") {
116
+ this.setFilter("all");
117
+ return true;
118
+ }
119
+ if (c.id === "warn") {
120
+ this.setFilter("warn");
121
+ return true;
122
+ }
123
+ if (c.id === "error") {
124
+ this.setFilter("error");
125
+ return true;
126
+ }
127
+ if (c.id === "copy") {
128
+ this.copyLogs(false);
129
+ return true;
130
+ }
131
+ if (c.id === "clear") {
132
+ ServerManager.getInstance().clearLogs();
133
+ this.scrollOffset = 0;
134
+ this.selectedLogIndex = null;
135
+ return true;
136
+ }
137
+ }
142
138
  }
143
139
  }
140
+ } else if (this.hoveredChip !== null) {
141
+ this.hoveredChip = null;
142
+ return true;
144
143
  }
145
144
  }
146
145
 
@@ -149,7 +148,7 @@ export class LogsView implements TuiView {
149
148
  return (
150
149
  col >= this.lastWidth - 3 &&
151
150
  col <= this.lastWidth &&
152
- row >= 7 &&
151
+ row >= 6 &&
153
152
  row <= 6 + this.lastVisibleCapacity
154
153
  );
155
154
  };
@@ -167,7 +166,7 @@ export class LogsView implements TuiView {
167
166
  if (key.name === "click" && key.mouse && isMouseOnScrollbar(key.mouse.col, key.mouse.row)) {
168
167
  if (this.lastMaxOffset > 0 && this.lastVisibleCapacity > 0) {
169
168
  this.isDraggingScrollbar = true;
170
- const r = key.mouse.row - 7;
169
+ const r = key.mouse.row - 6;
171
170
  const pct = Math.max(0, Math.min(1, r / Math.max(1, this.lastVisibleCapacity - 1)));
172
171
  const targetScrollFromTop = Math.round(pct * this.lastMaxOffset);
173
172
  this.scrollOffset = Math.max(0, Math.min(this.lastMaxOffset, this.lastMaxOffset - targetScrollFromTop));
@@ -179,7 +178,7 @@ export class LogsView implements TuiView {
179
178
  // Scrollbar Drag (Hold and Move)
180
179
  if (key.name === "drag" && key.mouse) {
181
180
  if (this.isDraggingScrollbar && this.lastMaxOffset > 0 && this.lastVisibleCapacity > 0) {
182
- const r = key.mouse.row - 7;
181
+ const r = key.mouse.row - 6;
183
182
  const pct = Math.max(0, Math.min(1, r / Math.max(1, this.lastVisibleCapacity - 1)));
184
183
  const targetScrollFromTop = Math.round(pct * this.lastMaxOffset);
185
184
  this.scrollOffset = Math.max(0, Math.min(this.lastMaxOffset, this.lastMaxOffset - targetScrollFromTop));
@@ -196,9 +195,9 @@ export class LogsView implements TuiView {
196
195
  }
197
196
  }
198
197
 
199
- // Mouse click on log rows (terminal row 7+, accounting for 2-line top margin)
200
- if (key.name === "click" && key.mouse && key.mouse.row >= 7) {
201
- const rowOffset = key.mouse.row - 7;
198
+ // Mouse click on log rows (terminal row 6+, accounting for 2-line top margin: row 4 & 5 are margin)
199
+ if (key.name === "click" && key.mouse && key.mouse.row >= 6) {
200
+ const rowOffset = key.mouse.row - 6;
202
201
  if (rowOffset >= 0 && rowOffset < this.lastVisibleCount) {
203
202
  const clickedIdx = this.lastStartIndex + rowOffset;
204
203
  if (this.selectedLogIndex === clickedIdx) {
@@ -232,16 +231,16 @@ export class LogsView implements TuiView {
232
231
  return true;
233
232
  }
234
233
 
235
- // Copy logs with 'y' or 'Y'
234
+ // Copy logs with 'y' or 'Y' (always copies full log)
236
235
  if ((key.name === "y" || key.name === "Y") && !key.ctrl) {
237
- this.copyLogs();
236
+ this.copyLogs(false);
238
237
  return true;
239
238
  }
240
239
 
241
- // Enter copies selected log if a row is selected
240
+ // Enter copies selected single line if a row is selected
242
241
  if (key.name === "return" || key.name === "enter") {
243
242
  if (this.selectedLogIndex !== null) {
244
- this.copyLogs();
243
+ this.copyLogs(true);
245
244
  return true;
246
245
  }
247
246
  }
@@ -351,39 +350,11 @@ export class LogsView implements TuiView {
351
350
  ? theme.bgHover(" [ C ] Limpar ")
352
351
  : theme.muted(" [ C ] Limpar ");
353
352
 
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;
353
+ const total = rawEntries.length;
354
+ this.lastTotalCount = total;
383
355
 
384
356
  // Scroll Window with top margin breathing room (2 rows reserved at the top)
385
357
  const visibleCapacity = Math.max(1, contentH - 4);
386
- const total = formattedLines.length;
387
358
  const maxOffset = Math.max(0, total - visibleCapacity);
388
359
  this.lastWidth = width;
389
360
  this.lastHeight = height;
@@ -394,10 +365,10 @@ export class LogsView implements TuiView {
394
365
  const scrollFromTop = maxOffset - clampedOffset;
395
366
 
396
367
  const startIndex = Math.max(0, total - visibleCapacity - clampedOffset);
397
- const visibleSlice = formattedLines.slice(startIndex, startIndex + visibleCapacity);
368
+ const visibleEntries = total === 0 ? [] : rawEntries.slice(startIndex, startIndex + visibleCapacity);
398
369
 
399
370
  this.lastStartIndex = startIndex;
400
- this.lastVisibleCount = visibleSlice.length;
371
+ this.lastVisibleCount = visibleEntries.length;
401
372
 
402
373
  // Scrollbar calculation
403
374
  const hasScrollbar = total > visibleCapacity;
@@ -416,33 +387,64 @@ export class LogsView implements TuiView {
416
387
  const finalRows: string[] = ["", ""];
417
388
  const boxInnerW = Math.max(1, width - 2);
418
389
 
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
- }
390
+ if (total === 0) {
391
+ finalRows.push("");
392
+ finalRows.push(
393
+ theme.muted(` Nenhum log registrado para o filtro atual [${this.filter}].`),
394
+ );
395
+ finalRows.push(
396
+ theme.muted(" Eventos de inicialização, requisições e alertas do proxy aparecerão aqui."),
397
+ );
398
+ while (finalRows.length < visibleCapacity + 2) {
399
+ finalRows.push("");
400
+ }
401
+ } else {
402
+ for (let r = 0; r < visibleCapacity; r++) {
403
+ if (r < visibleEntries.length) {
404
+ const actualIdx = startIndex + r;
405
+ const entry = visibleEntries[r];
406
+ let formattedLine = "";
407
+ if (entry && entry.message && entry.message.trim()) {
408
+ let tag = theme.dim("[INFO]");
409
+ if (entry.level === "WARN") {
410
+ tag = theme.yellow("[WARN]");
411
+ } else if (entry.level === "ERROR") {
412
+ tag = theme.red("[ERR]");
413
+ }
414
+
415
+ const prefix = `${theme.dim(entry.time)} ${tag} `;
416
+ const prefixW = 16;
417
+ const maxMsgW = Math.max(20, innerW - prefixW - 4);
418
+ const truncatedMsg = truncate(entry.message, maxMsgW);
419
+ formattedLine = `${prefix}${truncatedMsg}`;
420
+ }
429
421
 
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("█");
422
+ let styledText = formattedLine;
423
+ if (this.selectedLogIndex === actualIdx) {
424
+ styledText = theme.bgSelected(`▸ ${stripAnsi(formattedLine)} `);
436
425
  } else {
437
- scrollChar = isHighlighted ? theme.cyan("│") : theme.dark("│");
426
+ styledText = ` ${formattedLine}`;
427
+ }
428
+
429
+ if (hasScrollbar) {
430
+ const isThumb = r >= thumbTop && r < thumbTop + thumbSize;
431
+ const isHighlighted = this.isScrollbarHovered || this.isDraggingScrollbar;
432
+ let scrollChar: string;
433
+ if (isThumb) {
434
+ scrollChar = isHighlighted
435
+ ? `\x1b[48;2;45;35;85m\x1b[38;2;0;255;255m\x1b[1m█\x1b[0m`
436
+ : theme.cyan("█");
437
+ } else {
438
+ scrollChar = isHighlighted ? theme.cyan("│") : theme.dark("│");
439
+ }
440
+ const padded = pad(styledText, boxInnerW - 1);
441
+ finalRows.push(`${padded}${scrollChar}`);
442
+ } else {
443
+ finalRows.push(styledText);
438
444
  }
439
- const padded = pad(styledText, boxInnerW - 1);
440
- finalRows.push(`${padded}${scrollChar}`);
441
445
  } else {
442
- finalRows.push(styledText);
446
+ finalRows.push("");
443
447
  }
444
- } else {
445
- finalRows.push("");
446
448
  }
447
449
  }
448
450
 
@@ -457,22 +459,39 @@ export class LogsView implements TuiView {
457
459
  });
458
460
  return box;
459
461
  }
460
- private copyLogs(): void {
462
+
463
+ private copyLogs(singleLineOnly = false): void {
461
464
  const rawEntries = ServerManager.getInstance()
462
465
  .getLogEntries(this.filter)
463
466
  .filter((e) => e && e.message && e.message.trim().length > 0);
464
- if (rawEntries.length === 0) return;
465
467
 
466
468
  let text = "";
467
- if (this.selectedLogIndex !== null && rawEntries[this.selectedLogIndex]) {
469
+ if (singleLineOnly && this.selectedLogIndex !== null && rawEntries[this.selectedLogIndex]) {
468
470
  const entry = rawEntries[this.selectedLogIndex];
469
471
  text = `[${entry.time}] [${entry.level}] ${entry.message}`;
470
472
  } else {
471
- text = rawEntries
472
- .map((entry) => `[${entry.time}] [${entry.level}] ${entry.message}`)
473
- .join("\n");
473
+ // For full copy of "all" filter, prefer the complete persistent file from disk if available
474
+ if (this.filter === "all") {
475
+ try {
476
+ const logPath = getServerLogFilePath();
477
+ if (fs.existsSync(logPath)) {
478
+ const diskContent = fs.readFileSync(logPath, "utf-8");
479
+ if (diskContent && diskContent.trim().length > 0) {
480
+ text = diskContent.trim();
481
+ }
482
+ }
483
+ } catch {}
484
+ }
485
+
486
+ // Fallback to memory buffer if disk log is empty or on filtered view
487
+ if (!text && rawEntries.length > 0) {
488
+ text = rawEntries
489
+ .map((entry) => `[${entry.time}] [${entry.level}] ${entry.message}`)
490
+ .join("\n");
491
+ }
474
492
  }
475
493
 
494
+ if (!text) return;
476
495
  setClipboardText(text);
477
496
  this.copyNotification = true;
478
497
  if (this.copyTimeout) clearTimeout(this.copyTimeout);