qwenproxy-cli 1.0.32 → 1.1.0

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.
@@ -9,6 +9,7 @@ import { streamChatCompletions, fetchLiveModels } from "../proxy-client.ts";
9
9
  import { ServerManager } from "../server-manager.ts";
10
10
  import { formatMarkdown, formatReasoning } from "../markdown.ts";
11
11
  import { loadTuiSettings, saveTuiSettings } from "../settings.ts";
12
+ import { setRuntimeChatMode, getRuntimeChatMode } from "../../core/config.ts";
12
13
 
13
14
  interface ChatMessage {
14
15
  role: "user" | "assistant";
@@ -59,13 +60,15 @@ export class ChatView implements TuiView {
59
60
  private lastHeight = 24;
60
61
  private lastMaxOffset = 0;
61
62
  private lastVisibleCapacity = 0;
62
- private hoveredHeaderBtn: "model" | "effort" | null = null;
63
+ private hoveredHeaderBtn: "model" | "effort" | "mode" | null = null;
63
64
  private isScrollbarHovered = false;
64
65
  private isDraggingScrollbar = false;
65
66
  private modelBtnStartCol = 0;
66
67
  private modelBtnEndCol = 0;
67
68
  private effortBtnStartCol = 0;
68
69
  private effortBtnEndCol = 0;
70
+ private modeBtnStartCol = 0;
71
+ private modeBtnEndCol = 0;
69
72
  private isModelModalOpen = false;
70
73
  private modalSelectedIndex = 0;
71
74
  private availableEfforts: Array<{
@@ -96,6 +99,40 @@ export class ChatView implements TuiView {
96
99
  private selectedEffort: "high" | "medium" | "low" = "high";
97
100
  private isEffortModalOpen = false;
98
101
  private effortSelectedIndex = 0;
102
+ private availableModes: Array<{
103
+ id: "thread" | "thread-temp" | "stateless" | "stateless-temp";
104
+ label: string;
105
+ desc: string;
106
+ badge: string;
107
+ }> = [
108
+ {
109
+ id: "thread",
110
+ label: "thread (Padrão)",
111
+ desc: "Persistente Web (Delta ~1KB, salva na conta Qwen)",
112
+ badge: theme.cyan("[thread]"),
113
+ },
114
+ {
115
+ id: "thread-temp",
116
+ label: "thread-temp",
117
+ desc: "Efêmero Rápido (Delta ~1KB, não salva no site)",
118
+ badge: theme.green("[thread-temp]"),
119
+ },
120
+ {
121
+ id: "stateless-temp",
122
+ label: "stateless-temp",
123
+ desc: "Oficial OpenAI Efêmero (Histórico total, não salva)",
124
+ badge: theme.yellow("[stateless-temp]"),
125
+ },
126
+ {
127
+ id: "stateless",
128
+ label: "stateless",
129
+ desc: "Oficial OpenAI Salvo (Histórico total, salva no site)",
130
+ badge: theme.lavender("[stateless]"),
131
+ },
132
+ ];
133
+ private selectedChatMode: "thread" | "thread-temp" | "stateless" | "stateless-temp" = "thread";
134
+ private isModeModalOpen = false;
135
+ private modeSelectedIndex = 0;
99
136
  private isGenerating = false;
100
137
  private currentAbortController: AbortController | null = null;
101
138
  private statusNote = "";
@@ -138,14 +175,26 @@ export class ChatView implements TuiView {
138
175
  this.effortSelectedIndex = effIdx;
139
176
  }
140
177
  }
178
+ const runtimeMode = getRuntimeChatMode();
179
+ this.selectedChatMode = runtimeMode;
180
+ const mIdx = this.availableModes.findIndex((m) => m.id === runtimeMode);
181
+ if (mIdx !== -1) {
182
+ this.modeSelectedIndex = mIdx;
183
+ }
141
184
  void this.refreshModels();
142
185
  }
143
186
  public onActivate(): void {
187
+ const runtimeMode = getRuntimeChatMode();
188
+ this.selectedChatMode = runtimeMode;
189
+ const mIdx = this.availableModes.findIndex((m) => m.id === runtimeMode);
190
+ if (mIdx !== -1) {
191
+ this.modeSelectedIndex = mIdx;
192
+ }
144
193
  void this.refreshModels();
145
194
  }
146
195
 
147
196
  public isModalOpen(): boolean {
148
- return this.isModelModalOpen || this.isEffortModalOpen;
197
+ return this.isModelModalOpen || this.isEffortModalOpen || this.isModeModalOpen;
149
198
  }
150
199
 
151
200
  public async refreshModels(): Promise<void> {
@@ -179,6 +228,12 @@ export class ChatView implements TuiView {
179
228
  { key: "Esc", label: `${glyphs.cross} Manter` },
180
229
  ];
181
230
  }
231
+ if (this.isModeModalOpen) {
232
+ return [
233
+ { key: "Enter", label: `${glyphs.enter} Confirmar` },
234
+ { key: "Esc", label: `${glyphs.cross} Manter` },
235
+ ];
236
+ }
182
237
  return [
183
238
  { key: "Enter", label: `${glyphs.enter} Enviar` },
184
239
  { key: "Esc", label: `${glyphs.cross} Parar` },
@@ -195,6 +250,7 @@ export class ChatView implements TuiView {
195
250
  chat: {
196
251
  model: chosen,
197
252
  effort: this.selectedEffort,
253
+ mode: this.selectedChatMode,
198
254
  },
199
255
  });
200
256
  const info = classifyModel(chosen);
@@ -311,6 +367,7 @@ export class ChatView implements TuiView {
311
367
  chat: {
312
368
  model: currentM,
313
369
  effort: this.selectedEffort,
370
+ mode: this.selectedChatMode,
314
371
  },
315
372
  });
316
373
  this.statusNote = `Modelo: ${currentM} | Effort: ${this.availableEfforts[this.effortSelectedIndex].label}`;
@@ -325,15 +382,89 @@ export class ChatView implements TuiView {
325
382
  return true;
326
383
  }
327
384
 
328
- // 3. Header button hover (rows 4 to 6: Model, Effort)
385
+ // 3. Chat Mode Selection Modal Active
386
+ if (this.isModeModalOpen) {
387
+ if (key.name === "hover" && key.mouse) {
388
+ const { row } = key.mouse;
389
+ const startRow = 11;
390
+ if (row >= startRow && row < startRow + this.availableModes.length) {
391
+ const hoverIdx = row - startRow;
392
+ if (this.modeSelectedIndex !== hoverIdx) {
393
+ this.modeSelectedIndex = hoverIdx;
394
+ this.onNeedsRender?.();
395
+ return true;
396
+ }
397
+ }
398
+ }
399
+ if (key.name === "click" && key.mouse) {
400
+ const { row } = key.mouse;
401
+ const startRow = 11;
402
+ if (row >= startRow && row < startRow + this.availableModes.length) {
403
+ this.selectedChatMode = this.availableModes[row - startRow].id;
404
+ this.isModeModalOpen = false;
405
+ setRuntimeChatMode(this.selectedChatMode);
406
+ saveTuiSettings({
407
+ chat: {
408
+ model: this.availableModels[this.selectedModelIndex],
409
+ effort: this.selectedEffort,
410
+ mode: this.selectedChatMode,
411
+ },
412
+ });
413
+ this.statusNote = theme.green(`✓ Modo global da API alterado para: ${this.selectedChatMode}`);
414
+ this.onNeedsRender?.();
415
+ return true;
416
+ }
417
+ this.isModeModalOpen = false;
418
+ this.onNeedsRender?.();
419
+ return true;
420
+ }
421
+ if (key.name === "up" || key.name === "wheelup" || (key.name === "k" && !key.ctrl)) {
422
+ this.modeSelectedIndex = Math.max(0, this.modeSelectedIndex - 1);
423
+ this.onNeedsRender?.();
424
+ return true;
425
+ }
426
+ if (key.name === "down" || key.name === "wheeldown" || (key.name === "j" && !key.ctrl)) {
427
+ this.modeSelectedIndex = Math.min(
428
+ this.availableModes.length - 1,
429
+ this.modeSelectedIndex + 1,
430
+ );
431
+ this.onNeedsRender?.();
432
+ return true;
433
+ }
434
+ if (key.name === "return") {
435
+ this.selectedChatMode = this.availableModes[this.modeSelectedIndex].id;
436
+ this.isModeModalOpen = false;
437
+ setRuntimeChatMode(this.selectedChatMode);
438
+ saveTuiSettings({
439
+ chat: {
440
+ model: this.availableModels[this.selectedModelIndex],
441
+ effort: this.selectedEffort,
442
+ mode: this.selectedChatMode,
443
+ },
444
+ });
445
+ this.statusNote = theme.green(`✓ Modo global da API alterado para: ${this.selectedChatMode}`);
446
+ this.onNeedsRender?.();
447
+ return true;
448
+ }
449
+ if (key.name === "escape") {
450
+ this.isModeModalOpen = false;
451
+ this.onNeedsRender?.();
452
+ return true;
453
+ }
454
+ return true;
455
+ }
456
+
457
+ // 4. Header button hover (rows 4 to 6: Model, Effort, Mode)
329
458
  if (key.name === "hover" && key.mouse) {
330
459
  const { row, col } = key.mouse;
331
- if (row >= 4 && row <= 6 && !this.isModelModalOpen && !this.isEffortModalOpen) {
332
- let target: "model" | "effort" | null = null;
460
+ if (row >= 4 && row <= 6 && !this.isModelModalOpen && !this.isEffortModalOpen && !this.isModeModalOpen) {
461
+ let target: "model" | "effort" | "mode" | null = null;
333
462
  if (this.modelBtnStartCol > 0 && col >= this.modelBtnStartCol - 1 && col <= this.modelBtnEndCol + 1) {
334
463
  target = "model";
335
464
  } else if (this.effortBtnStartCol > 0 && col >= this.effortBtnStartCol - 1 && col <= this.effortBtnEndCol + 1) {
336
465
  target = "effort";
466
+ } else if (this.modeBtnStartCol > 0 && col >= this.modeBtnStartCol - 1 && col <= this.modeBtnEndCol + 1) {
467
+ target = "mode";
337
468
  }
338
469
  if (this.hoveredHeaderBtn !== target) {
339
470
  this.hoveredHeaderBtn = target;
@@ -347,14 +478,15 @@ export class ChatView implements TuiView {
347
478
  }
348
479
  }
349
480
 
350
- // 4. Header button click (rows 4 to 6: Model, Effort)
481
+ // 5. Header button click (rows 4 to 6: Model, Effort, Mode)
351
482
  if (
352
483
  key.name === "click" &&
353
484
  key.mouse &&
354
485
  key.mouse.row >= 4 &&
355
486
  key.mouse.row <= 6 &&
356
487
  !this.isModelModalOpen &&
357
- !this.isEffortModalOpen
488
+ !this.isEffortModalOpen &&
489
+ !this.isModeModalOpen
358
490
  ) {
359
491
  const col = key.mouse.col;
360
492
  if (this.modelBtnStartCol > 0 && col >= this.modelBtnStartCol - 1 && col <= this.modelBtnEndCol + 1) {
@@ -372,6 +504,14 @@ export class ChatView implements TuiView {
372
504
  this.onNeedsRender?.();
373
505
  return true;
374
506
  }
507
+ if (this.modeBtnStartCol > 0 && col >= this.modeBtnStartCol - 1 && col <= this.modeBtnEndCol + 1) {
508
+ this.isModeModalOpen = true;
509
+ const idx = this.availableModes.findIndex((m) => m.id === this.selectedChatMode);
510
+ this.modeSelectedIndex = idx !== -1 ? idx : 0;
511
+ this.hoveredHeaderBtn = null;
512
+ this.onNeedsRender?.();
513
+ return true;
514
+ }
375
515
  }
376
516
 
377
517
  // Keyboard shortcuts to open modals
@@ -399,6 +539,14 @@ export class ChatView implements TuiView {
399
539
  }
400
540
  }
401
541
 
542
+ if (key.name === "f4") {
543
+ this.isModeModalOpen = true;
544
+ const idx = this.availableModes.findIndex((m) => m.id === this.selectedChatMode);
545
+ this.modeSelectedIndex = idx !== -1 ? idx : 0;
546
+ this.onNeedsRender?.();
547
+ return true;
548
+ }
549
+
402
550
  // 5. Scrollbar hover, click & drag
403
551
  const isMouseOnScrollbar = (col: number, row: number) => {
404
552
  return (
@@ -666,6 +814,7 @@ export class ChatView implements TuiView {
666
814
  const result = await streamChatCompletions({
667
815
  model,
668
816
  reasoning_effort: isReasoning ? this.selectedEffort : undefined,
817
+ chatMode: this.selectedChatMode,
669
818
  messages: conversationPayload,
670
819
  signal: this.currentAbortController.signal,
671
820
  onReasoning: (chunk) => {
@@ -747,10 +896,10 @@ export class ChatView implements TuiView {
747
896
 
748
897
  const effortLabel = isReasoning
749
898
  ? this.selectedEffort === "high"
750
- ? "[ Effort: High (Thinking) ]"
899
+ ? "[ Effort: High ]"
751
900
  : this.selectedEffort === "medium"
752
- ? "[ Effort: Medium (Auto) ]"
753
- : "[ Effort: Low (Fast) ]"
901
+ ? "[ Effort: Med ]"
902
+ : "[ Effort: Low ]"
754
903
  : "";
755
904
 
756
905
  let styledEffort = "";
@@ -764,7 +913,18 @@ export class ChatView implements TuiView {
764
913
  : theme.cyan(effortLabel);
765
914
  }
766
915
 
767
- const shortcutsLabel = `[ F2: Modelo${isReasoning ? " | F3: Effort" : ""} (${this.selectedModelIndex + 1}/${totalModels}) ]`;
916
+ const modeLabel = `[ Modo: ${this.selectedChatMode} ]`;
917
+ const styledMode = this.hoveredHeaderBtn === "mode"
918
+ ? theme.bgHover(` ${theme.bold(theme.white(modeLabel))} `)
919
+ : this.selectedChatMode === "thread"
920
+ ? theme.cyan(modeLabel)
921
+ : this.selectedChatMode === "thread-temp"
922
+ ? theme.green(modeLabel)
923
+ : this.selectedChatMode === "stateless-temp"
924
+ ? theme.yellow(modeLabel)
925
+ : theme.lavender(modeLabel);
926
+
927
+ const shortcutsLabel = `[ F2: Modelo${isReasoning ? " | F3: Effort" : ""} | F4: Modo ]`;
768
928
  const styledShortcuts = theme.yellow(shortcutsLabel);
769
929
 
770
930
  // Non-text models show their category badge ([Imagem] / [Vídeo]), while text models omit [Texto]
@@ -772,7 +932,7 @@ export class ChatView implements TuiView {
772
932
  const nonTextCategory = !isReasoning ? theme.muted(`• ${currentInfo.category}`) : "";
773
933
 
774
934
  const headerLine = hasAccounts
775
- ? ` ${theme.bold("Modelo:")} ${styledModel} ${nonTextBadge}${isReasoning ? styledEffort : nonTextCategory} ${styledShortcuts}`
935
+ ? ` ${theme.bold("Modelo:")} ${styledModel} ${nonTextBadge}${isReasoning ? styledEffort + " " : nonTextCategory + " "}${styledMode} ${styledShortcuts}`
776
936
  : ` ${theme.bold("Modelo:")} ${styledModel} ${theme.yellow("[ [!] Sem Contas: Adicione em [5] Contas ]")}`;
777
937
 
778
938
  // Compute dynamic interactive column bounds:
@@ -781,16 +941,25 @@ export class ChatView implements TuiView {
781
941
  this.modelBtnStartCol = modelStart;
782
942
  this.modelBtnEndCol = modelEnd;
783
943
 
944
+ let nextStart = modelEnd + 3;
784
945
  if (isReasoning) {
785
- const effortStart = modelEnd + 3; // 2 spaces
946
+ const effortStart = nextStart;
786
947
  const effortEnd = effortStart + stringWidth(effortLabel) - 1;
787
948
  this.effortBtnStartCol = effortStart;
788
949
  this.effortBtnEndCol = effortEnd;
950
+ nextStart = effortEnd + 3;
789
951
  } else {
790
952
  this.effortBtnStartCol = 0;
791
953
  this.effortBtnEndCol = 0;
954
+ if (nonTextCategory) {
955
+ nextStart = modelEnd + stringWidth(` ${nonTextBadge}${nonTextCategory} `);
956
+ }
792
957
  }
793
958
 
959
+ const modeStart = nextStart;
960
+ const modeEnd = modeStart + stringWidth(modeLabel) - 1;
961
+ this.modeBtnStartCol = modeStart;
962
+ this.modeBtnEndCol = modeEnd;
794
963
  const headerBox = drawBox({
795
964
  title: "Chat Tester",
796
965
  width,
@@ -853,6 +1022,33 @@ export class ChatView implements TuiView {
853
1022
  content: modalLines,
854
1023
  });
855
1024
  totalLines.push(...modalBox);
1025
+ } else if (this.isModeModalOpen) {
1026
+ const modalLines: string[] = [
1027
+ "",
1028
+ ` ${theme.bold("Modo de Conversa:")} ${theme.cyan(this.selectedChatMode)}`,
1029
+ ` ${theme.dim("Escolha como o histórico é transmitido e persistido no Qwen:")}`,
1030
+ "",
1031
+ ];
1032
+ for (let i = 0; i < this.availableModes.length; i++) {
1033
+ const m = this.availableModes[i];
1034
+ const isSel = i === this.modeSelectedIndex;
1035
+ const isCurrent = m.id === this.selectedChatMode;
1036
+ const pointer = isSel ? theme.cyan("▸ ") : " ";
1037
+ const radio = isCurrent ? theme.green(glyphs.radioOn) : theme.muted(glyphs.radioOff);
1038
+ const line = `${pointer}${radio} ${pad(m.badge, 17)} ${pad(m.label, 17)} • ${m.desc}`;
1039
+ modalLines.push(isSel ? theme.bgSelected(line) : line);
1040
+ }
1041
+ modalLines.push("");
1042
+
1043
+ const modalBox = drawBox({
1044
+ title: "Selecionar Modo de Conversa [ Enter: Confirmar • Esc: Manter ]",
1045
+ width,
1046
+ height: chatHeight,
1047
+ borderColor: theme.borderActive,
1048
+ titleColor: theme.cyan,
1049
+ content: modalLines,
1050
+ });
1051
+ totalLines.push(...modalBox);
856
1052
  } else {
857
1053
  const chatContent: string[] = [];
858
1054
 
@@ -887,7 +1083,6 @@ export class ChatView implements TuiView {
887
1083
  if (msg.reasoning && msg.reasoning.trim().length > 0) {
888
1084
  const thinkWidth = Math.max(20, innerChatW - 4);
889
1085
  let thinkLines: string[];
890
-
891
1086
  if (msg.cachedWidth === innerChatW && msg.cachedReasoningBox) {
892
1087
  thinkLines = msg.cachedReasoningBox;
893
1088
  } else {
@@ -7,6 +7,8 @@ import type { KeyEvent } from "../screen.ts";
7
7
  import { theme, glyphs, drawBox, pad, truncate } from "../theme.ts";
8
8
  import { fetchProxyStatus, resetAllCooldowns, formatUptime } from "../proxy-client.ts";
9
9
  import { ServerManager } from "../server-manager.ts";
10
+ import { getRuntimeChatMode, cycleNextChatMode } from "../../core/config.ts";
11
+ import { saveTuiSettings } from "../settings.ts";
10
12
 
11
13
  export function renderProgressBar(
12
14
  pct: number,
@@ -62,6 +64,7 @@ export class StatusView implements TuiView {
62
64
  private lastLeftW = 38;
63
65
  private lastActionRecarregarRow = 20;
64
66
  private lastActionZerarRow = 21;
67
+ private lastActionModoRow = 22;
65
68
  constructor() {
66
69
  this.refresh();
67
70
  }
@@ -86,6 +89,7 @@ export class StatusView implements TuiView {
86
89
  return [
87
90
  { key: "r", label: "Recarregar" },
88
91
  { key: "z", label: "Zerar Cooldowns" },
92
+ { key: "m", label: "Alternar Modo" },
89
93
  ];
90
94
  }
91
95
 
@@ -105,7 +109,9 @@ export class StatusView implements TuiView {
105
109
  if (
106
110
  col >= 2 &&
107
111
  col <= leftW - 1 &&
108
- (row === this.lastActionRecarregarRow || row === this.lastActionZerarRow)
112
+ (row === this.lastActionRecarregarRow ||
113
+ row === this.lastActionZerarRow ||
114
+ row === this.lastActionModoRow)
109
115
  ) {
110
116
  if (this.hoveredActionRow !== row) {
111
117
  this.hoveredActionRow = row;
@@ -133,9 +139,15 @@ export class StatusView implements TuiView {
133
139
  this.setMessage(theme.green(`✓ Cooldowns zerados: ${cleared} conta(s) liberada(s)`));
134
140
  return true;
135
141
  }
142
+ if (row === this.lastActionModoRow) {
143
+ const nextMode = cycleNextChatMode();
144
+ saveTuiSettings({ chat: { mode: nextMode } });
145
+ await this.refresh();
146
+ this.setMessage(theme.green(`✓ Modo global da API: ${nextMode}`));
147
+ return true;
148
+ }
136
149
  }
137
150
  }
138
-
139
151
  if ((key.name === "r" || key.name === "R") && !key.ctrl) {
140
152
  await this.refresh();
141
153
  this.setMessage(theme.green("✓ Status atualizado"));
@@ -148,6 +160,14 @@ export class StatusView implements TuiView {
148
160
  this.setMessage(theme.green(`✓ Cooldowns zerados: ${cleared} conta(s) liberada(s)`));
149
161
  return true;
150
162
  }
163
+
164
+ if ((key.name === "m" || key.name === "M") && !key.ctrl) {
165
+ const nextMode = cycleNextChatMode();
166
+ saveTuiSettings({ chat: { mode: nextMode } });
167
+ await this.refresh();
168
+ this.setMessage(theme.green(`✓ Modo global da API: ${nextMode}`));
169
+ return true;
170
+ }
151
171
  }
152
172
 
153
173
  public render(width: number, height: number, snapshot?: ProxyStatusSnapshot | null): string[] {
@@ -214,8 +234,16 @@ export class StatusView implements TuiView {
214
234
  const leftContent: string[] = [
215
235
  ` ${theme.bold(lbl("Status:"))} ${onlineBadge}`,
216
236
  ` ${theme.bold(lbl("Base URL:"))} ${theme.cyan(baseUrl)}`,
237
+ ` ${theme.bold(lbl("Modo API:"))} ${
238
+ getRuntimeChatMode() === "thread"
239
+ ? theme.cyan("[thread]")
240
+ : getRuntimeChatMode() === "thread-temp"
241
+ ? theme.green("[thread-temp]")
242
+ : getRuntimeChatMode() === "stateless-temp"
243
+ ? theme.yellow("[stateless-temp]")
244
+ : theme.lavender("[stateless]")
245
+ } ${theme.dim("('M' alternar)")}`,
217
246
  ` ${theme.bold(lbl("Uptime:"))} ${theme.cyan(uptimeStr)}`,
218
- ` ${theme.bold(lbl("Memória:"))} ${theme.cyan(ramStr)}`,
219
247
  ` ${theme.bold(lbl("Conexões:"))} ${connsStr}`,
220
248
  ` ${theme.dim("───────────────────────────────────────")}`,
221
249
  ` ${theme.bold("Tráfego & Performance:")}`,
@@ -235,10 +263,13 @@ export class StatusView implements TuiView {
235
263
  const zerarIdx = leftContent.length + 1;
236
264
  this.lastActionRecarregarRow = 5 + recarregarIdx;
237
265
  this.lastActionZerarRow = 5 + zerarIdx;
266
+ const modoIdx = leftContent.length + 2;
267
+ this.lastActionModoRow = 5 + modoIdx;
238
268
 
239
269
  leftContent.push(
240
270
  ` ${this.hoveredActionRow === this.lastActionRecarregarRow ? theme.bgHover(` ${theme.cyan("[ R ] Recarregar")} `) : ` ${theme.cyan("[ R ]")} Recarregar`}`,
241
271
  ` ${this.hoveredActionRow === this.lastActionZerarRow ? theme.bgHover(` ${theme.yellow("[ Z ] Zerar Cooldowns")} `) : ` ${theme.yellow("[ Z ]")} Zerar Cooldowns`}`,
272
+ ` ${this.hoveredActionRow === this.lastActionModoRow ? theme.bgHover(` ${theme.lavender("[ M ] Alternar Modo")} `) : ` ${theme.lavender("[ M ]")} Alternar Modo`}`,
242
273
  );
243
274
 
244
275
  const boxHeight = Math.max(contentH, leftContent.length + 2);