qwenproxy-cli 1.0.0 → 1.0.1

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.
Files changed (108) hide show
  1. package/LICENSE +14 -14
  2. package/README.md +906 -906
  3. package/bin/qwenproxy.js +5 -1
  4. package/package.json +78 -78
  5. package/src/api/error-classifier.ts +159 -159
  6. package/src/api/error-helpers.ts +118 -118
  7. package/src/api/models.ts +261 -261
  8. package/src/api/server.ts +860 -859
  9. package/src/cache/memory-cache.ts +385 -385
  10. package/src/clean-cache.ts +204 -204
  11. package/src/core/account-concurrency.ts +671 -671
  12. package/src/core/account-manager.ts +301 -297
  13. package/src/core/account-priority.ts +163 -163
  14. package/src/core/accounts.ts +186 -186
  15. package/src/core/config.ts +383 -383
  16. package/src/core/crypto-utils.ts +79 -79
  17. package/src/core/database.ts +276 -276
  18. package/src/core/errors.ts +118 -118
  19. package/src/core/logger.ts +269 -269
  20. package/src/core/memory-usage.ts +84 -84
  21. package/src/core/metrics.ts +291 -291
  22. package/src/core/model-alias.ts +77 -77
  23. package/src/core/model-registry.ts +544 -544
  24. package/src/core/mutex.ts +119 -119
  25. package/src/core/paths.ts +199 -199
  26. package/src/core/prompt-limits.ts +214 -214
  27. package/src/core/reasoning-effort.ts +102 -102
  28. package/src/core/stream-registry.ts +96 -96
  29. package/src/core/waf-isolation.ts +117 -117
  30. package/src/core/watchdog.ts +195 -195
  31. package/src/delete-chats.ts +23 -23
  32. package/src/index.ts +65 -64
  33. package/src/login.ts +147 -147
  34. package/src/reset-cooldowns.ts +11 -11
  35. package/src/routes/anthropic/index.ts +355 -355
  36. package/src/routes/anthropic/translate.ts +522 -522
  37. package/src/routes/anthropic/types.ts +154 -154
  38. package/src/routes/anthropic/validation.ts +144 -144
  39. package/src/routes/chat/account.ts +1817 -1817
  40. package/src/routes/chat/context.ts +241 -241
  41. package/src/routes/chat/errors.ts +85 -85
  42. package/src/routes/chat/helpers.ts +268 -268
  43. package/src/routes/chat/index.ts +618 -618
  44. package/src/routes/chat/media.ts +285 -285
  45. package/src/routes/chat/retry-policy.ts +754 -754
  46. package/src/routes/chat/stop.ts +98 -98
  47. package/src/routes/chat/streaming.ts +2710 -2710
  48. package/src/routes/chat/validation.ts +526 -526
  49. package/src/routes/chat.ts +2 -2
  50. package/src/routes/completions.ts +290 -290
  51. package/src/routes/images.ts +139 -139
  52. package/src/routes/responses/adapter.ts +503 -503
  53. package/src/routes/responses/index.ts +405 -405
  54. package/src/routes/responses/state.ts +230 -230
  55. package/src/routes/responses/streaming.ts +528 -528
  56. package/src/routes/responses/types.ts +285 -285
  57. package/src/routes/responses/validation.ts +202 -202
  58. package/src/routes/upload.ts +731 -731
  59. package/src/routes/videos.ts +214 -214
  60. package/src/services/auth-playwright.ts +173 -173
  61. package/src/services/captcha-coordinator.ts +161 -161
  62. package/src/services/captcha-solver.ts +553 -553
  63. package/src/services/chat-cleanup.ts +80 -80
  64. package/src/services/context-meter.ts +317 -317
  65. package/src/services/fingerprint.ts +242 -242
  66. package/src/services/human-behavior.ts +173 -173
  67. package/src/services/media-generation.ts +1748 -1748
  68. package/src/services/playwright.ts +2800 -2800
  69. package/src/services/qwen-chat-pool.ts +345 -345
  70. package/src/services/qwen-errors.ts +133 -133
  71. package/src/services/qwen-headers.ts +79 -79
  72. package/src/services/qwen-thread-state.ts +393 -393
  73. package/src/services/qwen-url.ts +19 -19
  74. package/src/services/qwen.ts +3126 -3126
  75. package/src/services/session-keeper.ts +88 -88
  76. package/src/services/token-estimation-metrics.ts +118 -118
  77. package/src/sync/claude-code.ts +75 -75
  78. package/src/sync/codex.ts +123 -123
  79. package/src/sync/index.ts +362 -362
  80. package/src/sync/omp.ts +105 -105
  81. package/src/sync/opencode.ts +214 -214
  82. package/src/sync/types.ts +53 -53
  83. package/src/sync/utils.ts +27 -27
  84. package/src/sync-clients.ts +189 -189
  85. package/src/tools/instructions.ts +137 -137
  86. package/src/tools/manifest.ts +81 -81
  87. package/src/tools/parser.ts +2989 -2989
  88. package/src/tools/toolcall-tags.ts +142 -142
  89. package/src/tui/app.ts +264 -264
  90. package/src/tui/index.ts +61 -61
  91. package/src/tui/markdown.ts +258 -258
  92. package/src/tui/proxy-client.ts +331 -326
  93. package/src/tui/screen.ts +294 -278
  94. package/src/tui/server-manager.ts +270 -270
  95. package/src/tui/theme.ts +432 -432
  96. package/src/tui/types.ts +33 -33
  97. package/src/tui/views/accounts-view.ts +656 -656
  98. package/src/tui/views/chat-view.ts +1018 -823
  99. package/src/tui/views/logs-view.ts +479 -413
  100. package/src/tui/views/status-view.ts +204 -204
  101. package/src/tui/views/storage-view.ts +304 -291
  102. package/src/tui/views/sync-view.ts +409 -409
  103. package/src/types/ali-oss.d.ts +32 -32
  104. package/src/utils/context-truncation.ts +84 -84
  105. package/src/utils/json.ts +380 -380
  106. package/src/utils/session-id.ts +37 -37
  107. package/src/utils/tool-call-guard.ts +84 -84
  108. package/src/utils/types.ts +109 -109
@@ -1,823 +1,1018 @@
1
- /**
2
- * QwenProxy TUI - Interactive Chat Tester View (Tab 2)
3
- */
4
-
5
- import type { TuiView, ProxyStatusSnapshot } from "../types.ts";
6
- import type { KeyEvent } from "../screen.ts";
7
- import { theme, glyphs, drawBox, stringWidth, truncate, stripAnsi, pad, wrapContentLine } from "../theme.ts";
8
- import { streamChatCompletions, fetchLiveModels } from "../proxy-client.ts";
9
- import { ServerManager } from "../server-manager.ts";
10
- import { formatMarkdown, formatReasoning } from "../markdown.ts";
11
-
12
- interface ChatMessage {
13
- role: "user" | "assistant";
14
- content: string;
15
- reasoning?: string;
16
- model?: string;
17
- ttfbMs?: number;
18
- totalTimeMs?: number;
19
- cachedContentLines?: string[];
20
- cachedReasoningBox?: string[];
21
- cachedWidth?: number;
22
- }
23
- export function classifyModel(modelId: string): { badge: string; category: string } {
24
- const lower = modelId.toLowerCase();
25
- if (
26
- lower.includes("image") ||
27
- lower.startsWith("z-image") ||
28
- lower.includes("t2i") ||
29
- lower.includes("i2i")
30
- ) {
31
- return { badge: theme.lavender("[Imagem]"), category: "Geração de Imagem" };
32
- }
33
- if (lower.includes("video") || lower.includes("t2v") || lower.includes("i2v")) {
34
- return { badge: theme.peach("[Vídeo] "), category: "Geração de Vídeo" };
35
- }
36
- return { badge: theme.cyan("[Texto] "), category: "Texto & Raciocínio" };
37
- }
38
-
39
- export class ChatView implements TuiView {
40
- public readonly id = "chat";
41
- public readonly title = "Chat";
42
- public readonly tabNumber = 2;
43
-
44
- private availableModels = [
45
- "qwen3.8-max",
46
- "qwen3.7-plus",
47
- "qwen3.7-max",
48
- "z-image-turbo",
49
- "qwen-image-3.0-pro",
50
- "wan3.0-video",
51
- ];
52
- private selectedModelIndex = 0;
53
- private messages: ChatMessage[] = [];
54
- private inputBuffer = "";
55
- private cursorPos = 0;
56
- private scrollOffset = 0; // 0 = follow bottom (newest messages)
57
- private isModelModalOpen = false;
58
- private modalSelectedIndex = 0;
59
- private availableEfforts: Array<{
60
- id: "high" | "medium" | "low";
61
- label: string;
62
- desc: string;
63
- badge: string;
64
- }> = [
65
- {
66
- id: "high",
67
- label: "High (Thinking)",
68
- desc: "Raciocínio profundo ativado (ideal para código)",
69
- badge: theme.green("[High]"),
70
- },
71
- {
72
- id: "medium",
73
- label: "Medium (Auto)",
74
- desc: "Raciocínio dinâmico (Qwen decide quando pensar)",
75
- badge: theme.yellow("[Medium]"),
76
- },
77
- {
78
- id: "low",
79
- label: "Low (Fast)",
80
- desc: "Raciocínio desativado (respostas ultrarrápidas)",
81
- badge: theme.cyan("[Low]"),
82
- },
83
- ];
84
- private selectedEffort: "high" | "medium" | "low" = "high";
85
- private isEffortModalOpen = false;
86
- private effortSelectedIndex = 0;
87
- private isGenerating = false;
88
- private currentAbortController: AbortController | null = null;
89
- private statusNote = "";
90
- private lastSnapshot: ProxyStatusSnapshot | null = null;
91
- private onNeedsRender?: () => void;
92
- private renderThrottleTimer: NodeJS.Timeout | null = null;
93
- private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
94
- private spinnerIndex = 0;
95
- private spinnerInterval: NodeJS.Timeout | null = null;
96
-
97
- private scheduleRender(): void {
98
- if (this.renderThrottleTimer) return;
99
- this.renderThrottleTimer = setTimeout(() => {
100
- this.renderThrottleTimer = null;
101
- this.onNeedsRender?.();
102
- }, 40);
103
- }
104
- private flushRender(): void {
105
- if (this.renderThrottleTimer) {
106
- clearTimeout(this.renderThrottleTimer);
107
- this.renderThrottleTimer = null;
108
- }
109
- this.onNeedsRender?.();
110
- }
111
-
112
- constructor(onNeedsRender?: () => void) {
113
- this.onNeedsRender = onNeedsRender;
114
- void this.refreshModels();
115
- }
116
- public onActivate(): void {
117
- void this.refreshModels();
118
- }
119
-
120
- public isModalOpen(): boolean {
121
- return this.isModelModalOpen || this.isEffortModalOpen;
122
- }
123
-
124
- public async refreshModels(): Promise<void> {
125
- try {
126
- const live = await fetchLiveModels();
127
- if (live.length > 0) {
128
- const current = this.availableModels[this.selectedModelIndex];
129
- this.availableModels = live;
130
- const foundIdx = this.availableModels.indexOf(current);
131
- this.selectedModelIndex = foundIdx !== -1 ? foundIdx : 0;
132
- this.onNeedsRender?.();
133
- }
134
- } catch {}
135
- }
136
- public getShortcuts(): Array<{ key: string; label: string }> {
137
- if (this.isModelModalOpen) {
138
- return [
139
- { key: "Enter", label: `${glyphs.enter} Escolher` },
140
- { key: "Esc", label: `${glyphs.cross} Fechar` },
141
- ];
142
- }
143
- if (this.isEffortModalOpen) {
144
- return [
145
- { key: "Enter", label: `${glyphs.enter} Confirmar` },
146
- { key: "Esc", label: `${glyphs.cross} Manter` },
147
- ];
148
- }
149
- return [
150
- { key: "Enter", label: `${glyphs.enter} Enviar` },
151
- { key: "Esc", label: `${glyphs.cross} Parar` },
152
- { key: "Ctrl+L", label: `${glyphs.broom} Limpar` },
153
- ];
154
- }
155
-
156
- private applyChosenModel(idx: number): void {
157
- const chosen = this.availableModels[idx];
158
- if (!chosen) return;
159
- this.selectedModelIndex = idx;
160
- this.isModelModalOpen = false;
161
-
162
- const info = classifyModel(chosen);
163
- if (info.category === "Texto & Raciocínio") {
164
- this.isEffortModalOpen = true;
165
- const effIdx = this.availableEfforts.findIndex((e) => e.id === this.selectedEffort);
166
- this.effortSelectedIndex = effIdx !== -1 ? effIdx : 0;
167
- this.statusNote = `Modelo ${chosen} escolhido. Escolha o esforço de raciocínio (Effort):`;
168
- } else {
169
- this.statusNote = `Modelo alterado para ${chosen}`;
170
- }
171
- this.onNeedsRender?.();
172
- }
173
-
174
- public async handleKey(key: KeyEvent): Promise<boolean | void> {
175
- if (this.isModelModalOpen) {
176
- if (key.name === "hover" && key.mouse) {
177
- const { row } = key.mouse;
178
- if (row >= 9 && row < 9 + this.availableModels.length) {
179
- const hoverIdx = row - 9;
180
- if (this.modalSelectedIndex !== hoverIdx) {
181
- this.modalSelectedIndex = hoverIdx;
182
- this.onNeedsRender?.();
183
- return true;
184
- }
185
- }
186
- }
187
- if (key.name === "click" && key.mouse) {
188
- const { row } = key.mouse;
189
- if (row >= 9 && row < 9 + this.availableModels.length) {
190
- const chosenIdx = row - 9;
191
- this.applyChosenModel(chosenIdx);
192
- return true;
193
- }
194
- this.isModelModalOpen = false;
195
- this.onNeedsRender?.();
196
- return true;
197
- }
198
- if (key.name === "up" || key.name === "wheelup" || (key.name === "k" && !key.ctrl)) {
199
- this.modalSelectedIndex = Math.max(0, this.modalSelectedIndex - 1);
200
- this.onNeedsRender?.();
201
- return true;
202
- }
203
- if (key.name === "down" || key.name === "wheeldown" || (key.name === "j" && !key.ctrl)) {
204
- this.modalSelectedIndex = Math.min(
205
- this.availableModels.length - 1,
206
- this.modalSelectedIndex + 1,
207
- );
208
- this.onNeedsRender?.();
209
- return true;
210
- }
211
- if (key.name === "return") {
212
- this.applyChosenModel(this.modalSelectedIndex);
213
- return true;
214
- }
215
- if (key.name === "escape") {
216
- this.isModelModalOpen = false;
217
- this.onNeedsRender?.();
218
- return true;
219
- }
220
- return true;
221
- }
222
- // 2. Effort Selection Modal Active
223
- if (this.isEffortModalOpen) {
224
- if (key.name === "hover" && key.mouse) {
225
- const { row } = key.mouse;
226
- if (row >= 9 && row < 9 + this.availableEfforts.length) {
227
- const hoverIdx = row - 9;
228
- if (this.effortSelectedIndex !== hoverIdx) {
229
- this.effortSelectedIndex = hoverIdx;
230
- this.onNeedsRender?.();
231
- return true;
232
- }
233
- }
234
- }
235
- if (key.name === "click" && key.mouse) {
236
- const { row } = key.mouse;
237
- if (row >= 9 && row < 9 + this.availableEfforts.length) {
238
- this.selectedEffort = this.availableEfforts[row - 9].id;
239
- this.isEffortModalOpen = false;
240
- const currentM = this.availableModels[this.selectedModelIndex];
241
- this.statusNote = `Modelo: ${currentM} | Effort: ${this.availableEfforts[row - 9].label}`;
242
- this.onNeedsRender?.();
243
- return true;
244
- }
245
- this.isEffortModalOpen = false;
246
- this.onNeedsRender?.();
247
- return true;
248
- }
249
- if (key.name === "up" || key.name === "wheelup" || (key.name === "k" && !key.ctrl)) {
250
- this.effortSelectedIndex = Math.max(0, this.effortSelectedIndex - 1);
251
- this.onNeedsRender?.();
252
- return true;
253
- }
254
- if (key.name === "down" || key.name === "wheeldown" || (key.name === "j" && !key.ctrl)) {
255
- this.effortSelectedIndex = Math.min(
256
- this.availableEfforts.length - 1,
257
- this.effortSelectedIndex + 1,
258
- );
259
- this.onNeedsRender?.();
260
- return true;
261
- }
262
- if (key.name === "return") {
263
- this.selectedEffort = this.availableEfforts[this.effortSelectedIndex].id;
264
- this.isEffortModalOpen = false;
265
- const currentM = this.availableModels[this.selectedModelIndex];
266
- this.statusNote = `Modelo: ${currentM} | Effort: ${this.availableEfforts[this.effortSelectedIndex].label}`;
267
- this.onNeedsRender?.();
268
- return true;
269
- }
270
- if (key.name === "escape") {
271
- this.isEffortModalOpen = false;
272
- this.onNeedsRender?.();
273
- return true;
274
- }
275
- return true;
276
- }
277
-
278
- // 3. Open Model Modal with F2, Ctrl+O, Alt+M, or clicking on Model Header (left half)
279
- if (
280
- key.name === "f2" ||
281
- (key.ctrl && key.name === "o") ||
282
- (key.meta && key.name === "m") ||
283
- (key.name === "click" && key.mouse && key.mouse.row >= 4 && key.mouse.row <= 6 && key.mouse.col < 55)
284
- ) {
285
- this.isModelModalOpen = true;
286
- this.modalSelectedIndex = this.selectedModelIndex;
287
- this.onNeedsRender?.();
288
- return true;
289
- }
290
-
291
- // 4. Open Effort Modal directly with F3 or clicking Effort badge on header (right half)
292
- if (
293
- key.name === "f3" ||
294
- (key.name === "click" && key.mouse && key.mouse.row >= 4 && key.mouse.row <= 6 && key.mouse.col >= 55)
295
- ) {
296
- const currentM = this.availableModels[this.selectedModelIndex] || "qwen3.8-max";
297
- const info = classifyModel(currentM);
298
- if (info.category === "Texto & Raciocínio") {
299
- this.isEffortModalOpen = true;
300
- const idx = this.availableEfforts.findIndex((e) => e.id === this.selectedEffort);
301
- this.effortSelectedIndex = idx !== -1 ? idx : 0;
302
- this.onNeedsRender?.();
303
- return true;
304
- }
305
- }
306
-
307
- // 3. Abort streaming with Esc or Ctrl+C
308
- if (this.isGenerating && (key.name === "escape" || (key.ctrl && key.name === "c"))) {
309
- if (this.currentAbortController) {
310
- this.currentAbortController.abort();
311
- this.currentAbortController = null;
312
- }
313
- clearInterval(this.spinnerInterval!);
314
- this.spinnerInterval = null;
315
- this.isGenerating = false;
316
- this.statusNote = theme.yellow("⚠ Geração cancelada pelo usuário");
317
- this.onNeedsRender?.();
318
- return true;
319
- }
320
- // 4. Chat history scrolling (Mouse wheel / PageUp / PageDown / Up / Down)
321
- if (key.name === "wheelup") {
322
- this.scrollOffset += 2;
323
- return true;
324
- }
325
- if (key.name === "wheeldown") {
326
- this.scrollOffset = Math.max(0, this.scrollOffset - 2);
327
- return true;
328
- }
329
- if (key.name === "pageup") {
330
- this.scrollOffset += 6;
331
- return true;
332
- }
333
- if (key.name === "pagedown") {
334
- this.scrollOffset = Math.max(0, this.scrollOffset - 6);
335
- return true;
336
- }
337
- if ((key.ctrl || key.shift) && key.name === "up") {
338
- this.scrollOffset += 2;
339
- return true;
340
- }
341
- if ((key.ctrl || key.shift) && key.name === "down") {
342
- this.scrollOffset = Math.max(0, this.scrollOffset - 2);
343
- return true;
344
- }
345
- if (!this.isGenerating && this.inputBuffer.length === 0 && key.name === "up") {
346
- this.scrollOffset += 1;
347
- return true;
348
- }
349
- if (!this.isGenerating && this.inputBuffer.length === 0 && key.name === "down") {
350
- this.scrollOffset = Math.max(0, this.scrollOffset - 1);
351
- return true;
352
- }
353
-
354
- // 5. Cursor movement within input line using Left / Right
355
- if (key.name === "left") {
356
- this.cursorPos = Math.max(0, this.cursorPos - 1);
357
- this.onNeedsRender?.();
358
- return true;
359
- }
360
- if (key.name === "right") {
361
- this.cursorPos = Math.min(this.inputBuffer.length, this.cursorPos + 1);
362
- this.onNeedsRender?.();
363
- return true;
364
- }
365
- if (key.name === "home") {
366
- this.cursorPos = 0;
367
- this.onNeedsRender?.();
368
- return true;
369
- }
370
- if (key.name === "end") {
371
- this.cursorPos = this.inputBuffer.length;
372
- this.onNeedsRender?.();
373
- return true;
374
- }
375
- // 5. Paste from clipboard with Ctrl+V
376
- if (key.ctrl && (key.name === "v" || key.raw === "\x16")) {
377
- const { getClipboardText } = require("../theme.ts");
378
- const pasted = getClipboardText();
379
- if (pasted) {
380
- this.inputBuffer =
381
- this.inputBuffer.slice(0, this.cursorPos) +
382
- pasted +
383
- this.inputBuffer.slice(this.cursorPos);
384
- this.cursorPos += pasted.length;
385
- this.onNeedsRender?.();
386
- return true;
387
- }
388
- }
389
-
390
- // 6. Clear history with Ctrl+L
391
- if (key.ctrl && key.name === "l") {
392
- this.messages = [];
393
- this.statusNote = "Histórico do chat limpo.";
394
- this.onNeedsRender?.();
395
- return true;
396
- }
397
-
398
- // 6. Clear input line with single Ctrl+C (standard CLI behavior)
399
- if (key.ctrl && key.name === "c" && this.inputBuffer.length > 0) {
400
- this.inputBuffer = "";
401
- this.cursorPos = 0;
402
- this.onNeedsRender?.();
403
- return true;
404
- }
405
-
406
- // 7. Backspace at cursor
407
- if (key.name === "backspace") {
408
- if (this.cursorPos > 0) {
409
- this.inputBuffer =
410
- this.inputBuffer.slice(0, this.cursorPos - 1) +
411
- this.inputBuffer.slice(this.cursorPos);
412
- this.cursorPos--;
413
- this.onNeedsRender?.();
414
- }
415
- return true;
416
- }
417
-
418
- // 7. Delete at cursor
419
- if (key.name === "delete") {
420
- if (this.cursorPos < this.inputBuffer.length) {
421
- this.inputBuffer =
422
- this.inputBuffer.slice(0, this.cursorPos) +
423
- this.inputBuffer.slice(this.cursorPos + 1);
424
- this.onNeedsRender?.();
425
- }
426
- return true;
427
- }
428
-
429
- // 8. Submit message on Enter
430
- if (key.name === "return") {
431
- const text = this.inputBuffer.trim();
432
- if (!text || this.isGenerating) return true;
433
-
434
- // Block submitting if there are 0 accounts configured
435
- if (this.lastSnapshot && this.lastSnapshot.accounts.length === 0) {
436
- this.statusNote = theme.yellow("[!] Adicione uma conta na aba [5] Contas antes de iniciar o chat.");
437
- this.onNeedsRender?.();
438
- return true;
439
- }
440
-
441
- const serverState = ServerManager.getInstance().getState();
442
- if (serverState === "warming") {
443
- this.statusNote = theme.yellow("Aguarde: inicializando proxy...");
444
- this.onNeedsRender?.();
445
- return true;
446
- }
447
- if (serverState === "error") {
448
- this.statusNote = theme.red("Servidor com erro. Verifique a aba Logs.");
449
- this.onNeedsRender?.();
450
- return true;
451
- }
452
- this.inputBuffer = "";
453
- this.cursorPos = 0;
454
- this.scrollOffset = 0;
455
- this.sendMessage(text);
456
- return true;
457
- }
458
-
459
- // 9. Ignore Tab key (handled globally for tab switching)
460
- if (key.name === "tab") {
461
- return;
462
- }
463
- // 10. Character typing (insert at cursor position)
464
- if (key.char && !key.ctrl && !key.meta && key.name !== "tab") {
465
- if (key.char >= " ") {
466
- this.inputBuffer =
467
- this.inputBuffer.slice(0, this.cursorPos) +
468
- key.char +
469
- this.inputBuffer.slice(this.cursorPos);
470
- this.cursorPos += key.char.length;
471
- this.onNeedsRender?.();
472
- return true;
473
- }
474
- }
475
- }
476
-
477
- private async sendMessage(userText: string): Promise<void> {
478
- const serverState = ServerManager.getInstance().getState();
479
- if (serverState === "warming") {
480
- this.statusNote = theme.yellow("Aguarde: inicializando proxy...");
481
- return;
482
- }
483
-
484
- const model = this.availableModels[this.selectedModelIndex] || "qwen3.8-max";
485
-
486
- this.messages.push({
487
- role: "user",
488
- content: userText,
489
- });
490
- const assistantMsgIndex = this.messages.length;
491
- this.messages.push({
492
- role: "assistant",
493
- content: "",
494
- reasoning: "",
495
- model,
496
- });
497
- this.isGenerating = true;
498
- this.spinnerIndex = 0;
499
- clearInterval(this.spinnerInterval!);
500
- this.spinnerInterval = setInterval(() => {
501
- this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
502
- this.scheduleRender();
503
- }, 100);
504
- this.statusNote = "";
505
- this.currentAbortController = new AbortController();
506
- this.flushRender();
507
-
508
- const conversationPayload = this.messages
509
- .slice(0, -1)
510
- .map((m) => ({ role: m.role, content: m.content }));
511
-
512
- try {
513
- const isReasoning = classifyModel(model).category === "Texto & Raciocínio";
514
- const result = await streamChatCompletions({
515
- model,
516
- reasoning_effort: isReasoning ? this.selectedEffort : undefined,
517
- messages: conversationPayload,
518
- signal: this.currentAbortController.signal,
519
- onReasoning: (chunk) => {
520
- const current = this.messages[assistantMsgIndex];
521
- if (current) {
522
- current.reasoning = (current.reasoning || "") + chunk;
523
- this.scheduleRender();
524
- }
525
- },
526
- onToken: (chunk) => {
527
- const current = this.messages[assistantMsgIndex];
528
- if (current) {
529
- current.content += chunk;
530
- this.scheduleRender();
531
- }
532
- },
533
- });
534
-
535
- const current = this.messages[assistantMsgIndex];
536
- if (current) {
537
- current.ttfbMs = result.ttfbMs;
538
- current.totalTimeMs = result.totalTimeMs;
539
- }
540
- this.statusNote = theme.green(
541
- `✓ Resposta concluída em ${(result.totalTimeMs / 1000).toFixed(2)}s (TTFB: ${result.ttfbMs}ms)`,
542
- );
543
- } catch (err: any) {
544
- const current = this.messages[assistantMsgIndex];
545
- let rawMsg = err?.message || String(err);
546
- try {
547
- const jsonMatch = rawMsg.match(/\{.*"error"\s*:\s*\{.*\}\s*\}/s);
548
- if (jsonMatch) {
549
- const parsed = JSON.parse(jsonMatch[0]);
550
- if (parsed?.error?.message) {
551
- rawMsg = parsed.error.message;
552
- }
553
- }
554
- } catch {}
555
-
556
- if (
557
- rawMsg.includes("Target page, context or browser has been closed") ||
558
- rawMsg.includes("browserType.launchPersistentContext")
559
- ) {
560
- rawMsg = "Falha ao iniciar o navegador da conta. Feche outras instâncias do QwenProxy ou do Chrome e execute 'qpx reset'.";
561
- }
562
-
563
- if (current) {
564
- current.content = `\n ${theme.red("❌ " + rawMsg)}`;
565
- }
566
- this.statusNote = theme.red(`✗ ${rawMsg.slice(0, 80)}`);
567
- } finally {
568
- clearInterval(this.spinnerInterval!);
569
- this.spinnerInterval = null;
570
- this.isGenerating = false;
571
- this.currentAbortController = null;
572
- this.flushRender();
573
- }
574
- }
575
-
576
- public render(width: number, height: number, snapshot?: ProxyStatusSnapshot | null): string[] {
577
- if (snapshot !== undefined) {
578
- this.lastSnapshot = snapshot ?? null;
579
- }
580
- const hasAccounts = !this.lastSnapshot || this.lastSnapshot.accounts.length > 0;
581
- const totalLines: string[] = [];
582
-
583
- // 1. Model Header with F2 Shortcut
584
- const currentModel = this.availableModels[this.selectedModelIndex] || "qwen3.8-max";
585
- const currentInfo = classifyModel(currentModel);
586
- const totalModels = this.availableModels.length;
587
- const isReasoning = currentInfo.category === "Texto & Raciocínio";
588
-
589
- const effortBadge = isReasoning
590
- ? this.selectedEffort === "high"
591
- ? theme.green(`[ Effort: High (Thinking) ]`)
592
- : this.selectedEffort === "medium"
593
- ? theme.yellow(`[ Effort: Medium (Auto) ]`)
594
- : theme.cyan(`[ Effort: Low (Fast) ]`)
595
- : "";
596
-
597
- const headerLine = hasAccounts
598
- ? ` ${theme.bold("Modelo:")} ${theme.cyan(`[ ${currentModel} ]`)} ${currentInfo.badge} ${isReasoning ? effortBadge : theme.muted(`• ${currentInfo.category}`)} ${theme.yellow(`[ F2: Modelo${isReasoning ? " | F3: Effort" : ""} (${this.selectedModelIndex + 1}/${totalModels}) ]`)}`
599
- : ` ${theme.bold("Modelo:")} ${theme.cyan(`[ ${currentModel} ]`)} ${currentInfo.badge} ${theme.yellow("[ [!] Sem Contas: Adicione em [5] Contas ]")}`;
600
- const headerBox = drawBox({
601
- title: "Chat Tester",
602
- width,
603
- height: 3,
604
- borderColor: theme.borderActive,
605
- titleColor: theme.blue,
606
- content: [headerLine],
607
- });
608
- totalLines.push(...headerBox);
609
-
610
- // 2. Main Middle Area: Either Vertical Model Modal OR Chat Conversation
611
- const chatHeight = Math.max(6, height - 6);
612
- const innerChatW = width - 4;
613
-
614
- if (this.isModelModalOpen) {
615
- const modalLines: string[] = [""];
616
- for (let i = 0; i < this.availableModels.length; i++) {
617
- const m = this.availableModels[i];
618
- const isSel = i === this.modalSelectedIndex;
619
- const pointer = isSel ? theme.cyan("▸ ") : " ";
620
- const info = classifyModel(m);
621
- const line = `${pointer}${info.badge} ${pad(m, 20)} • ${info.category}`;
622
- modalLines.push(isSel ? theme.bgSelected(line) : line);
623
- }
624
- modalLines.push("");
625
-
626
- const modalBox = drawBox({
627
- title: "Selecionar Modelo [ Enter: Escolher • Esc: Fechar ]",
628
- width,
629
- height: chatHeight,
630
- borderColor: theme.borderActive,
631
- titleColor: theme.cyan,
632
- content: modalLines,
633
- });
634
- totalLines.push(...modalBox);
635
- } else if (this.isEffortModalOpen) {
636
- const modalLines: string[] = [
637
- "",
638
- ` ${theme.bold("Modelo:")} ${theme.cyan(currentModel)} (${currentInfo.category})`,
639
- ` ${theme.dim("Escolha o nível de esforço de raciocínio (reasoning_effort):")}`,
640
- "",
641
- ];
642
- for (let i = 0; i < this.availableEfforts.length; i++) {
643
- const eff = this.availableEfforts[i];
644
- const isSel = i === this.effortSelectedIndex;
645
- const isCurrent = eff.id === this.selectedEffort;
646
- const pointer = isSel ? theme.cyan("▸ ") : " ";
647
- const radio = isCurrent ? theme.green(glyphs.radioOn) : theme.muted(glyphs.radioOff);
648
- const line = `${pointer}${radio} ${eff.badge} ${pad(eff.label, 18)} • ${eff.desc}`;
649
- modalLines.push(isSel ? theme.bgSelected(line) : line);
650
- }
651
- modalLines.push("");
652
-
653
- const modalBox = drawBox({
654
- title: "Nível de Raciocínio / Effort [ Enter: Confirmar • Esc: Manter ]",
655
- width,
656
- height: chatHeight,
657
- borderColor: theme.borderActive,
658
- titleColor: theme.yellow,
659
- content: modalLines,
660
- });
661
- totalLines.push(...modalBox);
662
- } else {
663
- const chatContent: string[] = [];
664
-
665
- if (this.lastSnapshot && this.lastSnapshot.accounts.length === 0) {
666
- chatContent.push("");
667
- chatContent.push(` ${theme.yellow("[!] Nenhuma conta Qwen configurada no servidor.")}`);
668
- chatContent.push(` ${theme.muted(" Pressione ")}${theme.cyan("Tab")}${theme.muted(" para ir até ")}${theme.bold(theme.white("[5] Contas"))}${theme.muted(" e pressione ")}${theme.bold(theme.white("'A'"))}${theme.muted(" para adicionar seu e-mail e senha.")}`);
669
- } else if (this.messages.length === 0) {
670
- chatContent.push("");
671
- const serverState = ServerManager.getInstance().getState();
672
- if (serverState === "warming") {
673
- chatContent.push(theme.yellow(" [!] Inicializando proxy..."));
674
- } else {
675
- chatContent.push(theme.muted(" Digite sua mensagem..."));
676
- }
677
- }
678
- for (const msg of this.messages) {
679
- chatContent.push("");
680
- if (msg.role === "user") {
681
- const userLines = msg.content.split(/\r?\n/);
682
- for (let u = 0; u < userLines.length; u++) {
683
- if (u === 0) {
684
- chatContent.push(` ${theme.blue(glyphs.pointer + " Você:")} ${theme.white(userLines[u])}`);
685
- } else {
686
- chatContent.push(` ${theme.white(userLines[u])}`);
687
- }
688
- }
689
- } else {
690
- const messageModel = msg.model || currentModel;
691
- chatContent.push(` ${theme.green(glyphs.bullet + " Qwen (" + messageModel + "):")}`);
692
- // 1. Dedicated Thinking (Reasoning) Container - Opaque, Dimmed, and Cached
693
- if (msg.reasoning && msg.reasoning.trim().length > 0) {
694
- const thinkWidth = Math.max(20, innerChatW - 4);
695
- let thinkLines: string[];
696
-
697
- if (msg.cachedWidth === innerChatW && msg.cachedReasoningBox) {
698
- thinkLines = msg.cachedReasoningBox;
699
- } else {
700
- const rLines = formatReasoning(msg.reasoning, thinkWidth - 4).map((l) => ` ${l}`);
701
- if (this.isGenerating && !msg.content && this.messages.indexOf(msg) === this.messages.length - 1) {
702
- const spinner = this.spinnerFrames[this.spinnerIndex] || "⠋";
703
- rLines.push("");
704
- rLines.push(` ${theme.yellow(`${spinner} Raciocinando...`)}`);
705
- }
706
- thinkLines = drawBox({
707
- title: "🧠 Raciocínio",
708
- width: thinkWidth,
709
- borderColor: theme.borderInactive,
710
- titleColor: theme.muted,
711
- content: rLines,
712
- });
713
- if (!this.isGenerating) {
714
- msg.cachedReasoningBox = thinkLines;
715
- msg.cachedWidth = innerChatW;
716
- }
717
- }
718
-
719
- for (const line of thinkLines) {
720
- chatContent.push(` ${line}`);
721
- }
722
- chatContent.push("");
723
- }
724
-
725
- // 2. Final Response - Bright, crisp, full rich Markdown!
726
- if (msg.content) {
727
- let contentLines: string[];
728
- if (msg.cachedWidth === innerChatW && msg.cachedContentLines) {
729
- contentLines = msg.cachedContentLines;
730
- } else {
731
- contentLines = formatMarkdown(msg.content, innerChatW - 6, { dim: false });
732
- if (!this.isGenerating) {
733
- msg.cachedContentLines = contentLines;
734
- msg.cachedWidth = innerChatW;
735
- }
736
- }
737
- for (const line of contentLines) {
738
- chatContent.push(` ${line}`);
739
- }
740
- } else if (this.isGenerating && !msg.reasoning && this.messages.indexOf(msg) === this.messages.length - 1) {
741
- const spinner = this.spinnerFrames[this.spinnerIndex] || "⠋";
742
- chatContent.push(` ${theme.yellow(`${spinner} Pensando...`)}`);
743
- }
744
-
745
- if (msg.totalTimeMs) {
746
- chatContent.push(
747
- ` ${theme.dim(`[TTFB: ${msg.ttfbMs}ms | Total: ${(msg.totalTimeMs / 1000).toFixed(2)}s]`)}`,
748
- );
749
- }
750
- }
751
- }
752
-
753
- // Flatten and pre-wrap chatContent to innerChatW so every element maps strictly 1:1 to terminal rows
754
- const flatChatContent: string[] = [];
755
- for (const item of chatContent) {
756
- const sub = String(item ?? "").split(/\r?\n/);
757
- for (const s of sub) {
758
- if (stringWidth(s) <= innerChatW) {
759
- flatChatContent.push(s);
760
- } else {
761
- flatChatContent.push(...wrapContentLine(s, innerChatW));
762
- }
763
- }
764
- }
765
-
766
- // Auto-scroll window calculation supporting 1:1 smooth and precise history scrolling
767
- const visibleCapacity = Math.max(1, chatHeight - 2);
768
- const maxOffset = Math.max(0, flatChatContent.length - visibleCapacity);
769
- const clampedOffset = Math.min(this.scrollOffset, maxOffset);
770
- const startIndex = Math.max(0, flatChatContent.length - visibleCapacity - clampedOffset);
771
- const visibleChatLines = flatChatContent.slice(startIndex, startIndex + visibleCapacity);
772
- const scrollIndicator =
773
- clampedOffset > 0 ? theme.yellow(` [▲ Rolar: +${clampedOffset}]`) : "";
774
-
775
- const historyBox = drawBox({
776
- title: `Conversa (${this.messages.length})${scrollIndicator}`,
777
- width,
778
- height: chatHeight,
779
- borderColor: theme.borderInactive,
780
- titleColor: theme.lavender,
781
- content: visibleChatLines,
782
- });
783
- totalLines.push(...historyBox);
784
- }
785
-
786
- // 3. Bottom Input Box
787
- const inputPrompt = " ❯ ";
788
- const beforeCursor = this.inputBuffer.slice(0, this.cursorPos);
789
- const atCursor = this.inputBuffer[this.cursorPos] || " ";
790
- const afterCursor = this.inputBuffer.slice(this.cursorPos + 1);
791
- const styledCursor = this.isGenerating ? "" : theme.inverse(atCursor);
792
- const formattedInput = `${beforeCursor}${styledCursor}${afterCursor}`;
793
- const availableInputW = width - stringWidth(inputPrompt) - 4;
794
- const displayInput = truncate(formattedInput, availableInputW);
795
- const spinner = this.spinnerFrames[this.spinnerIndex] || "⠋";
796
-
797
- const inputContent = [`${theme.cyan(inputPrompt)}${displayInput}`];
798
- const actionLabel = !hasAccounts
799
- ? "[!] Nenhuma conta configurada — adicione uma conta na aba [5] Contas"
800
- : currentInfo.category === "Geração de Imagem"
801
- ? "Prompt da Imagem"
802
- : currentInfo.category === "Geração de Vídeo"
803
- ? "Prompt do Vídeo"
804
- : "Mensagem";
805
-
806
- const inputTitle = this.isGenerating
807
- ? `${spinner} Gerando... (Esc para cancelar)`
808
- : actionLabel;
809
-
810
- const inputBox = drawBox({
811
- title: inputTitle,
812
- width,
813
- height: 3,
814
- borderColor: this.isGenerating ? theme.yellow : theme.borderActive,
815
- titleColor: this.isGenerating ? theme.yellow : theme.cyan,
816
- footer: this.statusNote ? stripAnsi(this.statusNote) : undefined,
817
- content: inputContent,
818
- });
819
- totalLines.push(...inputBox);
820
-
821
- return totalLines;
822
- }
823
- }
1
+ /**
2
+ * QwenProxy TUI - Interactive Chat Tester View (Tab 2)
3
+ */
4
+
5
+ import type { TuiView, ProxyStatusSnapshot } from "../types.ts";
6
+ import type { KeyEvent } from "../screen.ts";
7
+ import { theme, glyphs, drawBox, stringWidth, truncate, stripAnsi, pad, wrapContentLine } from "../theme.ts";
8
+ import { streamChatCompletions, fetchLiveModels } from "../proxy-client.ts";
9
+ import { ServerManager } from "../server-manager.ts";
10
+ import { formatMarkdown, formatReasoning } from "../markdown.ts";
11
+
12
+ interface ChatMessage {
13
+ role: "user" | "assistant";
14
+ content: string;
15
+ reasoning?: string;
16
+ model?: string;
17
+ ttfbMs?: number;
18
+ totalTimeMs?: number;
19
+ cachedContentLines?: string[];
20
+ cachedReasoningBox?: string[];
21
+ cachedWidth?: number;
22
+ }
23
+ export function classifyModel(modelId: string): { badge: string; category: string } {
24
+ const lower = modelId.toLowerCase();
25
+ if (
26
+ lower.includes("image") ||
27
+ lower.startsWith("z-image") ||
28
+ lower.includes("t2i") ||
29
+ lower.includes("i2i")
30
+ ) {
31
+ return { badge: theme.lavender("[Imagem]"), category: "Geração de Imagem" };
32
+ }
33
+ if (lower.includes("video") || lower.includes("t2v") || lower.includes("i2v")) {
34
+ return { badge: theme.peach("[Vídeo] "), category: "Geração de Vídeo" };
35
+ }
36
+ return { badge: theme.cyan("[Texto] "), category: "Texto & Raciocínio" };
37
+ }
38
+
39
+ export class ChatView implements TuiView {
40
+ public readonly id = "chat";
41
+ public readonly title = "Chat";
42
+ public readonly tabNumber = 2;
43
+
44
+ private availableModels = [
45
+ "qwen3.8-max",
46
+ "qwen3.7-plus",
47
+ "qwen3.7-max",
48
+ "z-image-turbo",
49
+ "qwen-image-3.0-pro",
50
+ "wan3.0-video",
51
+ ];
52
+ private selectedModelIndex = 0;
53
+ private messages: ChatMessage[] = [];
54
+ private inputBuffer = "";
55
+ private cursorPos = 0;
56
+ private scrollOffset = 0; // 0 = follow bottom (newest messages)
57
+ private lastWidth = 80;
58
+ private lastHeight = 24;
59
+ private lastMaxOffset = 0;
60
+ private lastVisibleCapacity = 0;
61
+ private hoveredHeaderBtn: "model" | "effort" | null = null;
62
+ private isScrollbarHovered = false;
63
+ private isDraggingScrollbar = false;
64
+ private modelBtnStartCol = 0;
65
+ private modelBtnEndCol = 0;
66
+ private effortBtnStartCol = 0;
67
+ private effortBtnEndCol = 0;
68
+ private isModelModalOpen = false;
69
+ private modalSelectedIndex = 0;
70
+ private availableEfforts: Array<{
71
+ id: "high" | "medium" | "low";
72
+ label: string;
73
+ desc: string;
74
+ badge: string;
75
+ }> = [
76
+ {
77
+ id: "high",
78
+ label: "High (Thinking)",
79
+ desc: "Raciocínio profundo ativado (ideal para código)",
80
+ badge: theme.green("[High]"),
81
+ },
82
+ {
83
+ id: "medium",
84
+ label: "Medium (Auto)",
85
+ desc: "Raciocínio dinâmico (Qwen decide quando pensar)",
86
+ badge: theme.yellow("[Medium]"),
87
+ },
88
+ {
89
+ id: "low",
90
+ label: "Low (Fast)",
91
+ desc: "Raciocínio desativado (respostas ultrarrápidas)",
92
+ badge: theme.cyan("[Low]"),
93
+ },
94
+ ];
95
+ private selectedEffort: "high" | "medium" | "low" = "high";
96
+ private isEffortModalOpen = false;
97
+ private effortSelectedIndex = 0;
98
+ private isGenerating = false;
99
+ private currentAbortController: AbortController | null = null;
100
+ private statusNote = "";
101
+ private lastSnapshot: ProxyStatusSnapshot | null = null;
102
+ private onNeedsRender?: () => void;
103
+ private renderThrottleTimer: NodeJS.Timeout | null = null;
104
+ private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
105
+ private spinnerIndex = 0;
106
+ private spinnerInterval: NodeJS.Timeout | null = null;
107
+
108
+ private scheduleRender(): void {
109
+ if (this.renderThrottleTimer) return;
110
+ this.renderThrottleTimer = setTimeout(() => {
111
+ this.renderThrottleTimer = null;
112
+ this.onNeedsRender?.();
113
+ }, 40);
114
+ }
115
+ private flushRender(): void {
116
+ if (this.renderThrottleTimer) {
117
+ clearTimeout(this.renderThrottleTimer);
118
+ this.renderThrottleTimer = null;
119
+ }
120
+ this.onNeedsRender?.();
121
+ }
122
+
123
+ constructor(onNeedsRender?: () => void) {
124
+ this.onNeedsRender = onNeedsRender;
125
+ void this.refreshModels();
126
+ }
127
+ public onActivate(): void {
128
+ void this.refreshModels();
129
+ }
130
+
131
+ public isModalOpen(): boolean {
132
+ return this.isModelModalOpen || this.isEffortModalOpen;
133
+ }
134
+
135
+ public async refreshModels(): Promise<void> {
136
+ try {
137
+ const live = await fetchLiveModels();
138
+ if (live.length > 0) {
139
+ const current = this.availableModels[this.selectedModelIndex];
140
+ this.availableModels = live;
141
+ const foundIdx = this.availableModels.indexOf(current);
142
+ this.selectedModelIndex = foundIdx !== -1 ? foundIdx : 0;
143
+ this.onNeedsRender?.();
144
+ }
145
+ } catch {}
146
+ }
147
+ public getShortcuts(): Array<{ key: string; label: string }> {
148
+ if (this.isModelModalOpen) {
149
+ return [
150
+ { key: "Enter", label: `${glyphs.enter} Escolher` },
151
+ { key: "Esc", label: `${glyphs.cross} Fechar` },
152
+ ];
153
+ }
154
+ if (this.isEffortModalOpen) {
155
+ return [
156
+ { key: "Enter", label: `${glyphs.enter} Confirmar` },
157
+ { key: "Esc", label: `${glyphs.cross} Manter` },
158
+ ];
159
+ }
160
+ return [
161
+ { key: "Enter", label: `${glyphs.enter} Enviar` },
162
+ { key: "Esc", label: `${glyphs.cross} Parar` },
163
+ { key: "Ctrl+L", label: `${glyphs.broom} Limpar` },
164
+ ];
165
+ }
166
+
167
+ private applyChosenModel(idx: number): void {
168
+ const chosen = this.availableModels[idx];
169
+ if (!chosen) return;
170
+ this.selectedModelIndex = idx;
171
+ this.isModelModalOpen = false;
172
+
173
+ const info = classifyModel(chosen);
174
+ if (info.category === "Texto & Raciocínio") {
175
+ this.isEffortModalOpen = true;
176
+ const effIdx = this.availableEfforts.findIndex((e) => e.id === this.selectedEffort);
177
+ this.effortSelectedIndex = effIdx !== -1 ? effIdx : 0;
178
+ this.statusNote = `Modelo ${chosen} escolhido. Escolha o esforço de raciocínio (Effort):`;
179
+ } else {
180
+ this.statusNote = `Modelo alterado para ${chosen}`;
181
+ }
182
+ this.onNeedsRender?.();
183
+ }
184
+
185
+ public async handleKey(key: KeyEvent): Promise<boolean | void> {
186
+ if (this.isModelModalOpen) {
187
+ if (key.name === "hover" && key.mouse) {
188
+ const { row } = key.mouse;
189
+ if (row >= 9 && row < 9 + this.availableModels.length) {
190
+ const hoverIdx = row - 9;
191
+ if (this.modalSelectedIndex !== hoverIdx) {
192
+ this.modalSelectedIndex = hoverIdx;
193
+ this.onNeedsRender?.();
194
+ return true;
195
+ }
196
+ }
197
+ }
198
+ if (key.name === "click" && key.mouse) {
199
+ const { row } = key.mouse;
200
+ if (row >= 9 && row < 9 + this.availableModels.length) {
201
+ const chosenIdx = row - 9;
202
+ this.applyChosenModel(chosenIdx);
203
+ return true;
204
+ }
205
+ this.isModelModalOpen = false;
206
+ this.onNeedsRender?.();
207
+ return true;
208
+ }
209
+ if (key.name === "up" || key.name === "wheelup" || (key.name === "k" && !key.ctrl)) {
210
+ this.modalSelectedIndex = Math.max(0, this.modalSelectedIndex - 1);
211
+ this.onNeedsRender?.();
212
+ return true;
213
+ }
214
+ if (key.name === "down" || key.name === "wheeldown" || (key.name === "j" && !key.ctrl)) {
215
+ this.modalSelectedIndex = Math.min(
216
+ this.availableModels.length - 1,
217
+ this.modalSelectedIndex + 1,
218
+ );
219
+ this.onNeedsRender?.();
220
+ return true;
221
+ }
222
+ if (key.name === "return") {
223
+ this.applyChosenModel(this.modalSelectedIndex);
224
+ return true;
225
+ }
226
+ if (key.name === "escape") {
227
+ this.isModelModalOpen = false;
228
+ this.onNeedsRender?.();
229
+ return true;
230
+ }
231
+ return true;
232
+ }
233
+ // 2. Effort Selection Modal Active
234
+ if (this.isEffortModalOpen) {
235
+ if (key.name === "hover" && key.mouse) {
236
+ const { row } = key.mouse;
237
+ if (row >= 9 && row < 9 + this.availableEfforts.length) {
238
+ const hoverIdx = row - 9;
239
+ if (this.effortSelectedIndex !== hoverIdx) {
240
+ this.effortSelectedIndex = hoverIdx;
241
+ this.onNeedsRender?.();
242
+ return true;
243
+ }
244
+ }
245
+ }
246
+ if (key.name === "click" && key.mouse) {
247
+ const { row } = key.mouse;
248
+ if (row >= 9 && row < 9 + this.availableEfforts.length) {
249
+ this.selectedEffort = this.availableEfforts[row - 9].id;
250
+ this.isEffortModalOpen = false;
251
+ const currentM = this.availableModels[this.selectedModelIndex];
252
+ this.statusNote = `Modelo: ${currentM} | Effort: ${this.availableEfforts[row - 9].label}`;
253
+ this.onNeedsRender?.();
254
+ return true;
255
+ }
256
+ this.isEffortModalOpen = false;
257
+ this.onNeedsRender?.();
258
+ return true;
259
+ }
260
+ if (key.name === "up" || key.name === "wheelup" || (key.name === "k" && !key.ctrl)) {
261
+ this.effortSelectedIndex = Math.max(0, this.effortSelectedIndex - 1);
262
+ this.onNeedsRender?.();
263
+ return true;
264
+ }
265
+ if (key.name === "down" || key.name === "wheeldown" || (key.name === "j" && !key.ctrl)) {
266
+ this.effortSelectedIndex = Math.min(
267
+ this.availableEfforts.length - 1,
268
+ this.effortSelectedIndex + 1,
269
+ );
270
+ this.onNeedsRender?.();
271
+ return true;
272
+ }
273
+ if (key.name === "return") {
274
+ this.selectedEffort = this.availableEfforts[this.effortSelectedIndex].id;
275
+ this.isEffortModalOpen = false;
276
+ const currentM = this.availableModels[this.selectedModelIndex];
277
+ this.statusNote = `Modelo: ${currentM} | Effort: ${this.availableEfforts[this.effortSelectedIndex].label}`;
278
+ this.onNeedsRender?.();
279
+ return true;
280
+ }
281
+ if (key.name === "escape") {
282
+ this.isEffortModalOpen = false;
283
+ this.onNeedsRender?.();
284
+ return true;
285
+ }
286
+ return true;
287
+ }
288
+
289
+ // 3. Header button hover (rows 4 to 6: Model, Effort)
290
+ if (key.name === "hover" && key.mouse) {
291
+ const { row, col } = key.mouse;
292
+ if (row >= 4 && row <= 6 && !this.isModelModalOpen && !this.isEffortModalOpen) {
293
+ let target: "model" | "effort" | null = null;
294
+ if (this.modelBtnStartCol > 0 && col >= this.modelBtnStartCol - 1 && col <= this.modelBtnEndCol + 1) {
295
+ target = "model";
296
+ } else if (this.effortBtnStartCol > 0 && col >= this.effortBtnStartCol - 1 && col <= this.effortBtnEndCol + 1) {
297
+ target = "effort";
298
+ }
299
+ if (this.hoveredHeaderBtn !== target) {
300
+ this.hoveredHeaderBtn = target;
301
+ this.onNeedsRender?.();
302
+ return true;
303
+ }
304
+ } else if (this.hoveredHeaderBtn !== null) {
305
+ this.hoveredHeaderBtn = null;
306
+ this.onNeedsRender?.();
307
+ return true;
308
+ }
309
+ }
310
+
311
+ // 4. Header button click (rows 4 to 6: Model, Effort)
312
+ if (
313
+ key.name === "click" &&
314
+ key.mouse &&
315
+ key.mouse.row >= 4 &&
316
+ key.mouse.row <= 6 &&
317
+ !this.isModelModalOpen &&
318
+ !this.isEffortModalOpen
319
+ ) {
320
+ const col = key.mouse.col;
321
+ if (this.modelBtnStartCol > 0 && col >= this.modelBtnStartCol - 1 && col <= this.modelBtnEndCol + 1) {
322
+ this.isModelModalOpen = true;
323
+ this.modalSelectedIndex = this.selectedModelIndex;
324
+ this.hoveredHeaderBtn = null;
325
+ this.onNeedsRender?.();
326
+ return true;
327
+ }
328
+ if (this.effortBtnStartCol > 0 && col >= this.effortBtnStartCol - 1 && col <= this.effortBtnEndCol + 1) {
329
+ this.isEffortModalOpen = true;
330
+ const idx = this.availableEfforts.findIndex((e) => e.id === this.selectedEffort);
331
+ this.effortSelectedIndex = idx !== -1 ? idx : 0;
332
+ this.hoveredHeaderBtn = null;
333
+ this.onNeedsRender?.();
334
+ return true;
335
+ }
336
+ }
337
+
338
+ // Keyboard shortcuts to open modals
339
+ if (
340
+ key.name === "f2" ||
341
+ (key.ctrl && key.name === "o") ||
342
+ (key.meta && key.name === "m")
343
+ ) {
344
+ this.isModelModalOpen = true;
345
+ this.modalSelectedIndex = this.selectedModelIndex;
346
+ this.onNeedsRender?.();
347
+ return true;
348
+ }
349
+
350
+ if (key.name === "f3") {
351
+ const currentM = this.availableModels[this.selectedModelIndex] || "qwen3.8-max";
352
+ const info = classifyModel(currentM);
353
+ if (info.category === "Texto & Raciocínio") {
354
+ this.isEffortModalOpen = true;
355
+ const idx = this.availableEfforts.findIndex((e) => e.id === this.selectedEffort);
356
+ this.effortSelectedIndex = idx !== -1 ? idx : 0;
357
+ this.onNeedsRender?.();
358
+ return true;
359
+ }
360
+ }
361
+
362
+ // 5. Scrollbar hover, click & drag
363
+ const isMouseOnScrollbar = (col: number, row: number) => {
364
+ return (
365
+ col >= this.lastWidth - 2 &&
366
+ col <= this.lastWidth &&
367
+ row >= 8 &&
368
+ row <= 7 + this.lastVisibleCapacity
369
+ );
370
+ };
371
+
372
+ if (key.name === "hover" && key.mouse && !this.isModelModalOpen && !this.isEffortModalOpen) {
373
+ const onScrollbar = isMouseOnScrollbar(key.mouse.col, key.mouse.row);
374
+ if (this.isScrollbarHovered !== onScrollbar) {
375
+ this.isScrollbarHovered = onScrollbar;
376
+ this.onNeedsRender?.();
377
+ return true;
378
+ }
379
+ }
380
+
381
+ if (key.name === "click" && key.mouse && !this.isModelModalOpen && !this.isEffortModalOpen) {
382
+ if (isMouseOnScrollbar(key.mouse.col, key.mouse.row)) {
383
+ if (this.lastMaxOffset > 0 && this.lastVisibleCapacity > 0) {
384
+ this.isDraggingScrollbar = true;
385
+ const r = key.mouse.row - 8;
386
+ const pct = Math.max(0, Math.min(1, r / Math.max(1, this.lastVisibleCapacity - 1)));
387
+ const targetScrollFromTop = Math.round(pct * this.lastMaxOffset);
388
+ this.scrollOffset = Math.max(0, Math.min(this.lastMaxOffset, this.lastMaxOffset - targetScrollFromTop));
389
+ this.onNeedsRender?.();
390
+ return true;
391
+ }
392
+ }
393
+ }
394
+
395
+ if (key.name === "drag" && key.mouse && !this.isModelModalOpen && !this.isEffortModalOpen) {
396
+ if (this.isDraggingScrollbar && this.lastMaxOffset > 0 && this.lastVisibleCapacity > 0) {
397
+ const r = key.mouse.row - 8;
398
+ const pct = Math.max(0, Math.min(1, r / Math.max(1, this.lastVisibleCapacity - 1)));
399
+ const targetScrollFromTop = Math.round(pct * this.lastMaxOffset);
400
+ this.scrollOffset = Math.max(0, Math.min(this.lastMaxOffset, this.lastMaxOffset - targetScrollFromTop));
401
+ this.onNeedsRender?.();
402
+ return true;
403
+ }
404
+ }
405
+
406
+ if (key.name === "release") {
407
+ if (this.isDraggingScrollbar) {
408
+ this.isDraggingScrollbar = false;
409
+ this.onNeedsRender?.();
410
+ return true;
411
+ }
412
+ }
413
+
414
+ // 5. Chat history scrolling (Mouse wheel / PageUp / PageDown / Up / Down)
415
+ if (key.name === "wheelup") {
416
+ this.scrollOffset = this.lastMaxOffset > 0 ? Math.min(this.lastMaxOffset, this.scrollOffset + 2) : this.scrollOffset + 2;
417
+ this.onNeedsRender?.();
418
+ return true;
419
+ }
420
+ if (key.name === "wheeldown") {
421
+ this.scrollOffset = Math.max(0, this.scrollOffset - 2);
422
+ this.onNeedsRender?.();
423
+ return true;
424
+ }
425
+ if (key.name === "pageup") {
426
+ this.scrollOffset = this.lastMaxOffset > 0 ? Math.min(this.lastMaxOffset, this.scrollOffset + 6) : this.scrollOffset + 6;
427
+ this.onNeedsRender?.();
428
+ return true;
429
+ }
430
+ if (key.name === "pagedown") {
431
+ this.scrollOffset = Math.max(0, this.scrollOffset - 6);
432
+ this.onNeedsRender?.();
433
+ return true;
434
+ }
435
+ if ((key.ctrl || key.shift) && key.name === "up") {
436
+ this.scrollOffset = this.lastMaxOffset > 0 ? Math.min(this.lastMaxOffset, this.scrollOffset + 2) : this.scrollOffset + 2;
437
+ this.onNeedsRender?.();
438
+ return true;
439
+ }
440
+ if ((key.ctrl || key.shift) && key.name === "down") {
441
+ this.scrollOffset = Math.max(0, this.scrollOffset - 2);
442
+ this.onNeedsRender?.();
443
+ return true;
444
+ }
445
+ if (!this.isGenerating && this.inputBuffer.length === 0 && key.name === "up") {
446
+ this.scrollOffset = this.lastMaxOffset > 0 ? Math.min(this.lastMaxOffset, this.scrollOffset + 1) : this.scrollOffset + 1;
447
+ this.onNeedsRender?.();
448
+ return true;
449
+ }
450
+ if (!this.isGenerating && this.inputBuffer.length === 0 && key.name === "down") {
451
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
452
+ this.onNeedsRender?.();
453
+ return true;
454
+ }
455
+ if (key.name === "home") {
456
+ this.scrollOffset = this.lastMaxOffset;
457
+ this.onNeedsRender?.();
458
+ return true;
459
+ }
460
+ if (key.name === "end") {
461
+ this.scrollOffset = 0;
462
+ this.onNeedsRender?.();
463
+ return true;
464
+ }
465
+
466
+ // 5. Cursor movement within input line using Left / Right
467
+ if (key.name === "left") {
468
+ this.cursorPos = Math.max(0, this.cursorPos - 1);
469
+ this.onNeedsRender?.();
470
+ return true;
471
+ }
472
+ if (key.name === "right") {
473
+ this.cursorPos = Math.min(this.inputBuffer.length, this.cursorPos + 1);
474
+ this.onNeedsRender?.();
475
+ return true;
476
+ }
477
+ if (key.name === "home") {
478
+ this.cursorPos = 0;
479
+ this.onNeedsRender?.();
480
+ return true;
481
+ }
482
+ if (key.name === "end") {
483
+ this.cursorPos = this.inputBuffer.length;
484
+ this.onNeedsRender?.();
485
+ return true;
486
+ }
487
+ // 5. Paste from clipboard with Ctrl+V
488
+ if (key.ctrl && (key.name === "v" || key.raw === "\x16")) {
489
+ const { getClipboardText } = require("../theme.ts");
490
+ const pasted = getClipboardText();
491
+ if (pasted) {
492
+ this.inputBuffer =
493
+ this.inputBuffer.slice(0, this.cursorPos) +
494
+ pasted +
495
+ this.inputBuffer.slice(this.cursorPos);
496
+ this.cursorPos += pasted.length;
497
+ this.onNeedsRender?.();
498
+ return true;
499
+ }
500
+ }
501
+
502
+ // 6. Clear history with Ctrl+L
503
+ if (key.ctrl && key.name === "l") {
504
+ this.messages = [];
505
+ this.statusNote = "Histórico do chat limpo.";
506
+ this.onNeedsRender?.();
507
+ return true;
508
+ }
509
+
510
+ // 6. Clear input line with single Ctrl+C (standard CLI behavior)
511
+ if (key.ctrl && key.name === "c" && this.inputBuffer.length > 0) {
512
+ this.inputBuffer = "";
513
+ this.cursorPos = 0;
514
+ this.onNeedsRender?.();
515
+ return true;
516
+ }
517
+
518
+ // 7. Backspace at cursor
519
+ if (key.name === "backspace") {
520
+ if (this.cursorPos > 0) {
521
+ this.inputBuffer =
522
+ this.inputBuffer.slice(0, this.cursorPos - 1) +
523
+ this.inputBuffer.slice(this.cursorPos);
524
+ this.cursorPos--;
525
+ this.onNeedsRender?.();
526
+ }
527
+ return true;
528
+ }
529
+
530
+ // 7. Delete at cursor
531
+ if (key.name === "delete") {
532
+ if (this.cursorPos < this.inputBuffer.length) {
533
+ this.inputBuffer =
534
+ this.inputBuffer.slice(0, this.cursorPos) +
535
+ this.inputBuffer.slice(this.cursorPos + 1);
536
+ this.onNeedsRender?.();
537
+ }
538
+ return true;
539
+ }
540
+
541
+ // 8. Submit message on Enter
542
+ if (key.name === "return") {
543
+ const text = this.inputBuffer.trim();
544
+ if (!text || this.isGenerating) return true;
545
+
546
+ // Block submitting if there are 0 accounts configured
547
+ if (this.lastSnapshot && this.lastSnapshot.accounts.length === 0) {
548
+ this.statusNote = theme.yellow("[!] Adicione uma conta na aba [5] Contas antes de iniciar o chat.");
549
+ this.onNeedsRender?.();
550
+ return true;
551
+ }
552
+
553
+ const serverState = ServerManager.getInstance().getState();
554
+ if (serverState === "warming") {
555
+ this.statusNote = theme.yellow("Aguarde: inicializando proxy...");
556
+ this.onNeedsRender?.();
557
+ return true;
558
+ }
559
+ if (serverState === "error") {
560
+ this.statusNote = theme.red("Servidor com erro. Verifique a aba Logs.");
561
+ this.onNeedsRender?.();
562
+ return true;
563
+ }
564
+ this.inputBuffer = "";
565
+ this.cursorPos = 0;
566
+ this.scrollOffset = 0;
567
+ this.sendMessage(text);
568
+ return true;
569
+ }
570
+
571
+ // 9. Ignore Tab key (handled globally for tab switching)
572
+ if (key.name === "tab") {
573
+ return;
574
+ }
575
+ // 10. Character typing (insert at cursor position)
576
+ if (key.char && !key.ctrl && !key.meta && key.name !== "tab") {
577
+ if (key.char >= " ") {
578
+ this.inputBuffer =
579
+ this.inputBuffer.slice(0, this.cursorPos) +
580
+ key.char +
581
+ this.inputBuffer.slice(this.cursorPos);
582
+ this.cursorPos += key.char.length;
583
+ this.onNeedsRender?.();
584
+ return true;
585
+ }
586
+ }
587
+ }
588
+
589
+ private async sendMessage(userText: string): Promise<void> {
590
+ const serverState = ServerManager.getInstance().getState();
591
+ if (serverState === "warming") {
592
+ this.statusNote = theme.yellow("Aguarde: inicializando proxy...");
593
+ return;
594
+ }
595
+
596
+ const model = this.availableModels[this.selectedModelIndex] || "qwen3.8-max";
597
+
598
+ this.messages.push({
599
+ role: "user",
600
+ content: userText,
601
+ });
602
+ const assistantMsgIndex = this.messages.length;
603
+ this.messages.push({
604
+ role: "assistant",
605
+ content: "",
606
+ reasoning: "",
607
+ model,
608
+ });
609
+ this.isGenerating = true;
610
+ this.spinnerIndex = 0;
611
+ clearInterval(this.spinnerInterval!);
612
+ this.spinnerInterval = setInterval(() => {
613
+ this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
614
+ this.scheduleRender();
615
+ }, 100);
616
+ this.statusNote = "";
617
+ this.currentAbortController = new AbortController();
618
+ this.flushRender();
619
+
620
+ const conversationPayload = this.messages
621
+ .slice(0, -1)
622
+ .map((m) => ({ role: m.role, content: m.content }));
623
+
624
+ try {
625
+ const isReasoning = classifyModel(model).category === "Texto & Raciocínio";
626
+ const result = await streamChatCompletions({
627
+ model,
628
+ reasoning_effort: isReasoning ? this.selectedEffort : undefined,
629
+ messages: conversationPayload,
630
+ signal: this.currentAbortController.signal,
631
+ onReasoning: (chunk) => {
632
+ const current = this.messages[assistantMsgIndex];
633
+ if (current) {
634
+ current.reasoning = (current.reasoning || "") + chunk;
635
+ this.scheduleRender();
636
+ }
637
+ },
638
+ onToken: (chunk) => {
639
+ const current = this.messages[assistantMsgIndex];
640
+ if (current) {
641
+ current.content += chunk;
642
+ this.scheduleRender();
643
+ }
644
+ },
645
+ });
646
+
647
+ const current = this.messages[assistantMsgIndex];
648
+ if (current) {
649
+ current.ttfbMs = result.ttfbMs;
650
+ current.totalTimeMs = result.totalTimeMs;
651
+ }
652
+ this.statusNote = theme.green(
653
+ `✓ Resposta concluída em ${(result.totalTimeMs / 1000).toFixed(2)}s (TTFB: ${result.ttfbMs}ms)`,
654
+ );
655
+ } catch (err: any) {
656
+ const current = this.messages[assistantMsgIndex];
657
+ let rawMsg = err?.message || String(err);
658
+ try {
659
+ const jsonMatch = rawMsg.match(/\{.*"error"\s*:\s*\{.*\}\s*\}/s);
660
+ if (jsonMatch) {
661
+ const parsed = JSON.parse(jsonMatch[0]);
662
+ if (parsed?.error?.message) {
663
+ rawMsg = parsed.error.message;
664
+ }
665
+ }
666
+ } catch {}
667
+
668
+ if (
669
+ rawMsg.includes("Target page, context or browser has been closed") ||
670
+ rawMsg.includes("browserType.launchPersistentContext")
671
+ ) {
672
+ rawMsg = "Falha ao iniciar o navegador da conta. Feche outras instâncias do QwenProxy ou do Chrome e execute 'qpx reset'.";
673
+ }
674
+
675
+ if (current) {
676
+ current.content = `\n ${theme.red("❌ " + rawMsg)}`;
677
+ }
678
+ this.statusNote = theme.red(`✗ ${rawMsg.slice(0, 80)}`);
679
+ } finally {
680
+ clearInterval(this.spinnerInterval!);
681
+ this.spinnerInterval = null;
682
+ this.isGenerating = false;
683
+ this.currentAbortController = null;
684
+ this.flushRender();
685
+ }
686
+ }
687
+
688
+ public render(width: number, height: number, snapshot?: ProxyStatusSnapshot | null): string[] {
689
+ this.lastWidth = width;
690
+ this.lastHeight = height;
691
+ if (snapshot !== undefined) {
692
+ this.lastSnapshot = snapshot ?? null;
693
+ }
694
+ const hasAccounts = !this.lastSnapshot || this.lastSnapshot.accounts.length > 0;
695
+ const totalLines: string[] = [];
696
+
697
+ // 1. Model Header with F2 Shortcut
698
+ const currentModel = this.availableModels[this.selectedModelIndex] || "qwen3.8-max";
699
+ const currentInfo = classifyModel(currentModel);
700
+ const totalModels = this.availableModels.length;
701
+ const isReasoning = currentInfo.category === "Texto & Raciocínio";
702
+
703
+ const modelLabel = `[ ${currentModel} ]`;
704
+ const styledModel = this.hoveredHeaderBtn === "model"
705
+ ? theme.bgHover(` ${theme.bold(theme.white(modelLabel))} `)
706
+ : theme.cyan(modelLabel);
707
+
708
+ const effortLabel = isReasoning
709
+ ? this.selectedEffort === "high"
710
+ ? "[ Effort: High (Thinking) ]"
711
+ : this.selectedEffort === "medium"
712
+ ? "[ Effort: Medium (Auto) ]"
713
+ : "[ Effort: Low (Fast) ]"
714
+ : "";
715
+
716
+ let styledEffort = "";
717
+ if (isReasoning) {
718
+ styledEffort = this.hoveredHeaderBtn === "effort"
719
+ ? theme.bgHover(` ${theme.bold(theme.white(effortLabel))} `)
720
+ : this.selectedEffort === "high"
721
+ ? theme.green(effortLabel)
722
+ : this.selectedEffort === "medium"
723
+ ? theme.yellow(effortLabel)
724
+ : theme.cyan(effortLabel);
725
+ }
726
+
727
+ const shortcutsLabel = `[ F2: Modelo${isReasoning ? " | F3: Effort" : ""} (${this.selectedModelIndex + 1}/${totalModels}) ]`;
728
+ const styledShortcuts = theme.yellow(shortcutsLabel);
729
+
730
+ // Non-text models show their category badge ([Imagem] / [Vídeo]), while text models omit [Texto]
731
+ const nonTextBadge = !isReasoning && currentInfo.badge ? `${currentInfo.badge} ` : "";
732
+ const nonTextCategory = !isReasoning ? theme.muted(`• ${currentInfo.category}`) : "";
733
+
734
+ const headerLine = hasAccounts
735
+ ? ` ${theme.bold("Modelo:")} ${styledModel} ${nonTextBadge}${isReasoning ? styledEffort : nonTextCategory} ${styledShortcuts}`
736
+ : ` ${theme.bold("Modelo:")} ${styledModel} ${theme.yellow("[ [!] Sem Contas: Adicione em [5] Contas ]")}`;
737
+
738
+ // Compute dynamic interactive column bounds:
739
+ const modelStart = 1 + stringWidth(" Modelo: ") + 1; // col 12
740
+ const modelEnd = modelStart + stringWidth(modelLabel) - 1;
741
+ this.modelBtnStartCol = modelStart;
742
+ this.modelBtnEndCol = modelEnd;
743
+
744
+ if (isReasoning) {
745
+ const effortStart = modelEnd + 3; // 2 spaces
746
+ const effortEnd = effortStart + stringWidth(effortLabel) - 1;
747
+ this.effortBtnStartCol = effortStart;
748
+ this.effortBtnEndCol = effortEnd;
749
+ } else {
750
+ this.effortBtnStartCol = 0;
751
+ this.effortBtnEndCol = 0;
752
+ }
753
+
754
+ const headerBox = drawBox({
755
+ title: "Chat Tester",
756
+ width,
757
+ height: 3,
758
+ borderColor: theme.borderActive,
759
+ titleColor: theme.blue,
760
+ content: [headerLine],
761
+ });
762
+ totalLines.push(...headerBox);
763
+
764
+ // 2. Main Middle Area: Either Vertical Model Modal OR Chat Conversation
765
+ const chatHeight = Math.max(6, height - 6);
766
+ const innerChatW = width - 4;
767
+
768
+ if (this.isModelModalOpen) {
769
+ const modalLines: string[] = [""];
770
+ for (let i = 0; i < this.availableModels.length; i++) {
771
+ const m = this.availableModels[i];
772
+ const isSel = i === this.modalSelectedIndex;
773
+ const pointer = isSel ? theme.cyan("▸ ") : " ";
774
+ const info = classifyModel(m);
775
+ const line = `${pointer}${info.badge} ${pad(m, 20)} • ${info.category}`;
776
+ modalLines.push(isSel ? theme.bgSelected(line) : line);
777
+ }
778
+ modalLines.push("");
779
+
780
+ const modalBox = drawBox({
781
+ title: "Selecionar Modelo [ Enter: Escolher • Esc: Fechar ]",
782
+ width,
783
+ height: chatHeight,
784
+ borderColor: theme.borderActive,
785
+ titleColor: theme.cyan,
786
+ content: modalLines,
787
+ });
788
+ totalLines.push(...modalBox);
789
+ } else if (this.isEffortModalOpen) {
790
+ const modalLines: string[] = [
791
+ "",
792
+ ` ${theme.bold("Modelo:")} ${theme.cyan(currentModel)} (${currentInfo.category})`,
793
+ ` ${theme.dim("Escolha o nível de esforço de raciocínio (reasoning_effort):")}`,
794
+ "",
795
+ ];
796
+ for (let i = 0; i < this.availableEfforts.length; i++) {
797
+ const eff = this.availableEfforts[i];
798
+ const isSel = i === this.effortSelectedIndex;
799
+ const isCurrent = eff.id === this.selectedEffort;
800
+ const pointer = isSel ? theme.cyan("▸ ") : " ";
801
+ const radio = isCurrent ? theme.green(glyphs.radioOn) : theme.muted(glyphs.radioOff);
802
+ const line = `${pointer}${radio} ${eff.badge} ${pad(eff.label, 18)} • ${eff.desc}`;
803
+ modalLines.push(isSel ? theme.bgSelected(line) : line);
804
+ }
805
+ modalLines.push("");
806
+
807
+ const modalBox = drawBox({
808
+ title: "Nível de Raciocínio / Effort [ Enter: Confirmar • Esc: Manter ]",
809
+ width,
810
+ height: chatHeight,
811
+ borderColor: theme.borderActive,
812
+ titleColor: theme.yellow,
813
+ content: modalLines,
814
+ });
815
+ totalLines.push(...modalBox);
816
+ } else {
817
+ const chatContent: string[] = [];
818
+
819
+ if (this.lastSnapshot && this.lastSnapshot.accounts.length === 0) {
820
+ chatContent.push("");
821
+ chatContent.push(` ${theme.yellow("[!] Nenhuma conta Qwen configurada no servidor.")}`);
822
+ chatContent.push(` ${theme.muted(" Pressione ")}${theme.cyan("Tab")}${theme.muted(" para ir até ")}${theme.bold(theme.white("[5] Contas"))}${theme.muted(" e pressione ")}${theme.bold(theme.white("'A'"))}${theme.muted(" para adicionar seu e-mail e senha.")}`);
823
+ } else if (this.messages.length === 0) {
824
+ chatContent.push("");
825
+ const serverState = ServerManager.getInstance().getState();
826
+ if (serverState === "warming") {
827
+ chatContent.push(theme.yellow(" [!] Inicializando proxy..."));
828
+ } else {
829
+ chatContent.push(theme.muted(" Digite sua mensagem..."));
830
+ }
831
+ }
832
+ for (const msg of this.messages) {
833
+ chatContent.push("");
834
+ if (msg.role === "user") {
835
+ const userLines = msg.content.split(/\r?\n/);
836
+ for (let u = 0; u < userLines.length; u++) {
837
+ if (u === 0) {
838
+ chatContent.push(` ${theme.blue(glyphs.pointer + " Você:")} ${theme.white(userLines[u])}`);
839
+ } else {
840
+ chatContent.push(` ${theme.white(userLines[u])}`);
841
+ }
842
+ }
843
+ } else {
844
+ const messageModel = msg.model || currentModel;
845
+ chatContent.push(` ${theme.green(glyphs.bullet + " Qwen (" + messageModel + "):")}`);
846
+ // 1. Dedicated Thinking (Reasoning) Container - Opaque, Dimmed, and Cached
847
+ if (msg.reasoning && msg.reasoning.trim().length > 0) {
848
+ const thinkWidth = Math.max(20, innerChatW - 4);
849
+ let thinkLines: string[];
850
+
851
+ if (msg.cachedWidth === innerChatW && msg.cachedReasoningBox) {
852
+ thinkLines = msg.cachedReasoningBox;
853
+ } else {
854
+ const rLines = formatReasoning(msg.reasoning, thinkWidth - 4).map((l) => ` ${l}`);
855
+ if (this.isGenerating && !msg.content && this.messages.indexOf(msg) === this.messages.length - 1) {
856
+ const spinner = this.spinnerFrames[this.spinnerIndex] || "⠋";
857
+ rLines.push("");
858
+ rLines.push(` ${theme.yellow(`${spinner} Raciocinando...`)}`);
859
+ }
860
+ thinkLines = drawBox({
861
+ title: "🧠 Raciocínio",
862
+ width: thinkWidth,
863
+ borderColor: theme.borderInactive,
864
+ titleColor: theme.muted,
865
+ content: rLines,
866
+ });
867
+ if (!this.isGenerating) {
868
+ msg.cachedReasoningBox = thinkLines;
869
+ msg.cachedWidth = innerChatW;
870
+ }
871
+ }
872
+
873
+ for (const line of thinkLines) {
874
+ chatContent.push(` ${line}`);
875
+ }
876
+ chatContent.push("");
877
+ }
878
+
879
+ // 2. Final Response - Bright, crisp, full rich Markdown!
880
+ if (msg.content) {
881
+ let contentLines: string[];
882
+ if (msg.cachedWidth === innerChatW && msg.cachedContentLines) {
883
+ contentLines = msg.cachedContentLines;
884
+ } else {
885
+ contentLines = formatMarkdown(msg.content, innerChatW - 6, { dim: false });
886
+ if (!this.isGenerating) {
887
+ msg.cachedContentLines = contentLines;
888
+ msg.cachedWidth = innerChatW;
889
+ }
890
+ }
891
+ for (const line of contentLines) {
892
+ chatContent.push(` ${line}`);
893
+ }
894
+ } else if (this.isGenerating && !msg.reasoning && this.messages.indexOf(msg) === this.messages.length - 1) {
895
+ const spinner = this.spinnerFrames[this.spinnerIndex] || "⠋";
896
+ chatContent.push(` ${theme.yellow(`${spinner} Pensando...`)}`);
897
+ }
898
+
899
+ if (msg.totalTimeMs) {
900
+ chatContent.push(
901
+ ` ${theme.dim(`[TTFB: ${msg.ttfbMs}ms | Total: ${(msg.totalTimeMs / 1000).toFixed(2)}s]`)}`,
902
+ );
903
+ }
904
+ }
905
+ }
906
+
907
+ // Flatten and pre-wrap chatContent to innerChatW so every element maps strictly 1:1 to terminal rows
908
+ const flatChatContent: string[] = [];
909
+ for (const item of chatContent) {
910
+ const sub = String(item ?? "").split(/\r?\n/);
911
+ for (const s of sub) {
912
+ if (stringWidth(s) <= innerChatW) {
913
+ flatChatContent.push(s);
914
+ } else {
915
+ flatChatContent.push(...wrapContentLine(s, innerChatW));
916
+ }
917
+ }
918
+ }
919
+
920
+ // Auto-scroll window calculation supporting 1:1 smooth and precise history scrolling
921
+ const visibleCapacity = Math.max(1, chatHeight - 2);
922
+ const total = flatChatContent.length;
923
+ const maxOffset = Math.max(0, total - visibleCapacity);
924
+ this.lastMaxOffset = maxOffset;
925
+ this.lastVisibleCapacity = visibleCapacity;
926
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
927
+ const clampedOffset = this.scrollOffset;
928
+ const scrollFromTop = maxOffset - clampedOffset;
929
+
930
+ const startIndex = Math.max(0, total - visibleCapacity - clampedOffset);
931
+ const visibleChatLines = flatChatContent.slice(startIndex, startIndex + visibleCapacity);
932
+
933
+ // Lateral scrollbar calculation
934
+ const hasScrollbar = total > visibleCapacity;
935
+ const thumbSize = hasScrollbar
936
+ ? Math.max(1, Math.round((visibleCapacity / total) * visibleCapacity))
937
+ : 0;
938
+ const trackRange = Math.max(1, visibleCapacity - thumbSize);
939
+ const thumbTop = hasScrollbar
940
+ ? Math.min(
941
+ visibleCapacity - thumbSize,
942
+ Math.max(0, Math.round((scrollFromTop / maxOffset) * trackRange)),
943
+ )
944
+ : 0;
945
+
946
+ const boxInnerW = Math.max(1, width - 2);
947
+ const formattedChatRows: string[] = [];
948
+ for (let r = 0; r < visibleCapacity; r++) {
949
+ if (r < visibleChatLines.length) {
950
+ const line = visibleChatLines[r];
951
+ if (hasScrollbar) {
952
+ const isThumb = r >= thumbTop && r < thumbTop + thumbSize;
953
+ const isHighlighted = this.isScrollbarHovered || this.isDraggingScrollbar;
954
+ let scrollChar: string;
955
+ if (isThumb) {
956
+ scrollChar = isHighlighted ? `\x1b[48;2;45;35;85m\x1b[38;2;0;255;255m\x1b[1m█\x1b[0m` : theme.cyan("█");
957
+ } else {
958
+ scrollChar = isHighlighted ? theme.cyan("│") : theme.dark("│");
959
+ }
960
+ const padded = pad(line, boxInnerW - 1);
961
+ formattedChatRows.push(`${padded}${scrollChar}`);
962
+ } else {
963
+ formattedChatRows.push(line);
964
+ }
965
+ } else {
966
+ formattedChatRows.push("");
967
+ }
968
+ }
969
+
970
+ const historyBox = drawBox({
971
+ title: `Conversa (${this.messages.length})`,
972
+ width,
973
+ height: chatHeight,
974
+ borderColor: theme.borderInactive,
975
+ titleColor: theme.lavender,
976
+ content: formattedChatRows,
977
+ });
978
+ totalLines.push(...historyBox);
979
+ }
980
+
981
+ // 3. Bottom Input Box
982
+ const inputPrompt = " ❯ ";
983
+ const beforeCursor = this.inputBuffer.slice(0, this.cursorPos);
984
+ const atCursor = this.inputBuffer[this.cursorPos] || " ";
985
+ const afterCursor = this.inputBuffer.slice(this.cursorPos + 1);
986
+ const styledCursor = this.isGenerating ? "" : theme.inverse(atCursor);
987
+ const formattedInput = `${beforeCursor}${styledCursor}${afterCursor}`;
988
+ const availableInputW = width - stringWidth(inputPrompt) - 4;
989
+ const displayInput = truncate(formattedInput, availableInputW);
990
+ const spinner = this.spinnerFrames[this.spinnerIndex] || "⠋";
991
+
992
+ const inputContent = [`${theme.cyan(inputPrompt)}${displayInput}`];
993
+ const actionLabel = !hasAccounts
994
+ ? "[!] Nenhuma conta configurada — adicione uma conta na aba [5] Contas"
995
+ : currentInfo.category === "Geração de Imagem"
996
+ ? "Prompt da Imagem"
997
+ : currentInfo.category === "Geração de Vídeo"
998
+ ? "Prompt do Vídeo"
999
+ : "Mensagem";
1000
+
1001
+ const inputTitle = this.isGenerating
1002
+ ? `${spinner} Gerando... (Esc para cancelar)`
1003
+ : actionLabel;
1004
+
1005
+ const inputBox = drawBox({
1006
+ title: inputTitle,
1007
+ width,
1008
+ height: 3,
1009
+ borderColor: this.isGenerating ? theme.yellow : theme.borderActive,
1010
+ titleColor: this.isGenerating ? theme.yellow : theme.cyan,
1011
+ footer: this.statusNote ? stripAnsi(this.statusNote) : undefined,
1012
+ content: inputContent,
1013
+ });
1014
+ totalLines.push(...inputBox);
1015
+
1016
+ return totalLines;
1017
+ }
1018
+ }