izteamslots 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.
@@ -0,0 +1,1175 @@
1
+ import { StyledText, bg, bold, createCliRenderer, fg, stringToStyledText, t } from "@opentui/core"
2
+ import * as OpenTUI from "@opentui/core"
3
+ import { execFileSync } from "node:child_process"
4
+
5
+ import { formatDashboard, formatMenu, formatTable } from "../menus/format"
6
+ import { getDashboard, getHint, getMenuOptions, getTable, parentMenu } from "../menus/mainMenus"
7
+ import type { AppState, MenuContext, MenuName, MenuOption } from "../menus/types"
8
+ import { StdioRpcClient } from "../transport/stdioClient"
9
+
10
+ const EMPTY_STATE: AppState = {
11
+ admins: [],
12
+ workers: [],
13
+ accounts: [],
14
+ }
15
+
16
+ const HERO_LOGO = [
17
+ " izTeamSlots",
18
+ " Локальный центр управления слотами",
19
+ ].join("\n")
20
+
21
+ type RpcJobResult = { job_id: string }
22
+
23
+ type PromptOption = {
24
+ label: string
25
+ value: string
26
+ hint?: string
27
+ destructive?: boolean
28
+ }
29
+
30
+ type PromptState = {
31
+ active: boolean
32
+ mode: "input" | "select"
33
+ question: string
34
+ hidden: boolean
35
+ value: string
36
+ options: PromptOption[]
37
+ selectedIndex: number
38
+ resolve: ((value: string | null) => void) | null
39
+ }
40
+
41
+ type AnyRenderable = { content?: unknown }
42
+
43
+ type ScrollableTextRenderable = AnyRenderable & {
44
+ scrollY?: number
45
+ maxScrollY?: number
46
+ selectable?: boolean
47
+ getSelectedText?: () => string
48
+ }
49
+
50
+ function setRenderableText(node: AnyRenderable, value: unknown) {
51
+ node.content = value
52
+ }
53
+
54
+ function joinStyledLines(lines: StyledText[]): StyledText {
55
+ const chunks: StyledText["chunks"] = []
56
+ for (let i = 0; i < lines.length; i++) {
57
+ chunks.push(...lines[i].chunks)
58
+ if (i < lines.length - 1) {
59
+ chunks.push(...stringToStyledText("\n").chunks)
60
+ }
61
+ }
62
+ return new StyledText(chunks)
63
+ }
64
+
65
+ function copyToSystemClipboard(text: string): boolean {
66
+ const value = text.trim()
67
+ if (!value) return false
68
+
69
+ try {
70
+ execFileSync("pbcopy", [], { input: value })
71
+ return true
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ function formatShortDate(value: string | null | undefined): string {
78
+ if (!value) return "не было"
79
+ const date = new Date(value)
80
+ if (Number.isNaN(date.getTime())) return value
81
+ return new Intl.DateTimeFormat("ru-RU", {
82
+ day: "2-digit",
83
+ month: "2-digit",
84
+ hour: "2-digit",
85
+ minute: "2-digit",
86
+ }).format(date)
87
+ }
88
+
89
+ function timeStamp(): string {
90
+ return new Intl.DateTimeFormat("ru-RU", {
91
+ hour: "2-digit",
92
+ minute: "2-digit",
93
+ second: "2-digit",
94
+ }).format(new Date())
95
+ }
96
+
97
+ function boolLabel(value: boolean): string {
98
+ return value ? "есть" : "нет"
99
+ }
100
+
101
+ function statusLabel(value: string | null | undefined): string {
102
+ if (!value) return "неизвестно"
103
+ if (value === "created") return "создан"
104
+ if (value === "invited") return "приглашён"
105
+ if (value === "registered") return "зарегистрирован"
106
+ if (value === "logged_in") return "в работе"
107
+ return value
108
+ }
109
+
110
+ function hasFlag(row: unknown, key: string): boolean {
111
+ if (!row || typeof row !== "object") return false
112
+ return Boolean((row as Record<string, unknown>)[key])
113
+ }
114
+
115
+ function getMenuOptionDescription(option: MenuOption | undefined): string {
116
+ const extra = option as MenuOption & { description?: string }
117
+ if (extra?.description) return extra.description
118
+ const optionId = option?.id ?? ""
119
+
120
+ if (optionId === "menu_admins") return "Добавление, логин и обслуживание администраторов."
121
+ if (optionId === "menu_slots") return "Создание слотов, перелогин и работа с профилями."
122
+ if (optionId === "menu_mail") return "Проверка входящих писем админов и слотов."
123
+ if (optionId === "menu_exit") return "Аккуратно завершить интерфейс."
124
+ if (optionId === "adm_add") return "Создать нового админа: в авто-режиме ввод в TUI, в ручном режиме вход сразу в браузере."
125
+ if (optionId === "adm_relogin") return "Выбрать админа и режим перелогина: автоматический или ручной."
126
+ if (optionId === "adm_open") return "Открыть локальный профиль браузера администратора."
127
+ if (optionId === "adm_delete") return "Удалить админа и отвязать связанные слоты."
128
+ if (optionId === "slots_create") return "Запустить полный пайплайн создания новых слотов."
129
+ if (optionId === "slots_relogin") return "Выбрать режим перелогина: один конкретный слот или все доступные сразу."
130
+ if (optionId === "slots_open") return "Открыть профиль браузера выбранного слота."
131
+ if (optionId === "slots_delete") return "Удалить слот и его локальные файлы."
132
+ if (optionId.startsWith("mail_pick:")) return "Открыть входящие письма этого аккаунта."
133
+ if (optionId.startsWith("pick_admin:")) return "Выбрать админа для следующего действия."
134
+ if (optionId.startsWith("pick_worker:")) return "Выбрать слот для следующего действия."
135
+ if (optionId === "confirm_yes") return "Подтвердить действие без возможности быстрого отката."
136
+ if (optionId === "confirm_no") return "Вернуться без изменений."
137
+
138
+ return "Действие доступно через Enter."
139
+ }
140
+
141
+ export class MainScreen {
142
+ private readonly rpc = new StdioRpcClient()
143
+
144
+ private state: AppState = EMPTY_STATE
145
+ private backendOnline = false
146
+ private connectionError = ""
147
+ private menuName: MenuName = "main"
148
+ private menuCtx: MenuContext = {}
149
+ private menuOptions: MenuOption[] = []
150
+ private selectedIndex = 0
151
+ private busy = false
152
+ private busyTitle = ""
153
+
154
+ private logs: string[] = []
155
+
156
+ private prompt: PromptState = {
157
+ active: false,
158
+ mode: "input",
159
+ question: "",
160
+ hidden: false,
161
+ value: "",
162
+ options: [],
163
+ selectedIndex: 0,
164
+ resolve: null,
165
+ }
166
+
167
+ private renderer!: Awaited<ReturnType<typeof createCliRenderer>>
168
+ private summaryBox!: OpenTUI.BoxRenderable
169
+ private primaryBox!: OpenTUI.BoxRenderable
170
+ private detailBox!: OpenTUI.BoxRenderable
171
+ private menuBox!: OpenTUI.BoxRenderable
172
+
173
+ private summaryText!: AnyRenderable
174
+ private primaryText!: AnyRenderable
175
+ private detailText!: ScrollableTextRenderable
176
+ private menuText!: AnyRenderable
177
+ private statusText!: AnyRenderable
178
+
179
+ async start() {
180
+ this.renderer = await createCliRenderer({ exitOnCtrlC: false })
181
+ this.buildUI()
182
+
183
+ this.rpc.onEvent((evt) => {
184
+ this.backendOnline = true
185
+ if (evt.event === "job.started") {
186
+ this.busy = true
187
+ this.busyTitle = String(evt.data.title ?? "")
188
+ this.pushLog(`Запуск: ${this.busyTitle || "операция"}`)
189
+ if (evt.data.log_path) {
190
+ this.pushLog(`Лог-файл: ${String(evt.data.log_path)}`)
191
+ }
192
+ }
193
+ if (evt.event === "job.log") {
194
+ this.pushLog(String(evt.data.message ?? ""))
195
+ }
196
+ if (evt.event === "job.progress") {
197
+ const current = Number(evt.data.current ?? 0)
198
+ const total = Number(evt.data.total ?? 0)
199
+ const message = String(evt.data.message ?? "")
200
+ this.busyTitle = total > 0 ? `${current}/${total} ${message}`.trim() : message || "Выполняется"
201
+ }
202
+ if (evt.event === "job.done") {
203
+ this.busy = false
204
+ this.busyTitle = ""
205
+ this.pushLog("Операция завершена")
206
+ void this.refreshState()
207
+ }
208
+ if (evt.event === "job.error") {
209
+ this.busy = false
210
+ this.busyTitle = ""
211
+ this.pushLog(`Ошибка: ${String(evt.data.error ?? "unknown")}`)
212
+ if (evt.data.log_path) {
213
+ this.pushLog(`Подробности в файле: ${String(evt.data.log_path)}`)
214
+ }
215
+ void this.refreshState()
216
+ }
217
+ this.render()
218
+ })
219
+
220
+ this.renderer.keyInput.on("keypress", (key: any) => {
221
+ void this.onKey(key).catch((error) => {
222
+ this.pushLog(`Ошибка: ${String(error)}`)
223
+ this.render()
224
+ })
225
+ })
226
+
227
+ this.renderer.keyInput.on("paste", (event: any) => {
228
+ this.onPaste(event)
229
+ })
230
+
231
+ this.rpc.start()
232
+ await this.refreshState(false)
233
+ if (this.backendOnline) {
234
+ this.pushLog("Интерфейс готов к работе")
235
+ }
236
+ this.render()
237
+ }
238
+
239
+ private buildUI() {
240
+ const root = new OpenTUI.BoxRenderable(this.renderer, {
241
+ id: "root",
242
+ width: "100%",
243
+ height: "100%",
244
+ flexDirection: "column",
245
+ justifyContent: "space-between",
246
+ padding: 0,
247
+ backgroundColor: "#020a2b",
248
+ })
249
+
250
+ this.summaryBox = new OpenTUI.BoxRenderable(this.renderer, {
251
+ id: "summary_box",
252
+ title: "izTeamSlots",
253
+ border: true,
254
+ borderColor: "#334155",
255
+ height: 7,
256
+ width: "100%",
257
+ backgroundColor: "#020a2b",
258
+ paddingLeft: 1,
259
+ paddingRight: 1,
260
+ flexShrink: 0,
261
+ })
262
+
263
+ this.primaryBox = new OpenTUI.BoxRenderable(this.renderer, {
264
+ id: "primary_box",
265
+ title: "Быстрый старт",
266
+ border: true,
267
+ borderColor: "#334155",
268
+ flexGrow: 2,
269
+ width: "100%",
270
+ backgroundColor: "#08111f",
271
+ paddingLeft: 1,
272
+ paddingRight: 1,
273
+ })
274
+
275
+ this.detailBox = new OpenTUI.BoxRenderable(this.renderer, {
276
+ id: "detail_box",
277
+ title: "События и контекст",
278
+ border: true,
279
+ borderColor: "#334155",
280
+ height: 9,
281
+ flexGrow: 3,
282
+ width: "100%",
283
+ backgroundColor: "#020617",
284
+ paddingLeft: 1,
285
+ paddingRight: 1,
286
+ })
287
+
288
+ this.menuBox = new OpenTUI.BoxRenderable(this.renderer, {
289
+ id: "menu_box",
290
+ title: "Действия",
291
+ border: true,
292
+ borderColor: "#334155",
293
+ height: 6,
294
+ width: "100%",
295
+ backgroundColor: "#08111f",
296
+ paddingLeft: 1,
297
+ paddingRight: 1,
298
+ flexShrink: 0,
299
+ })
300
+
301
+ const statusBox = new OpenTUI.BoxRenderable(this.renderer, {
302
+ id: "status_box",
303
+ border: false,
304
+ backgroundColor: "#1f2937",
305
+ height: 1,
306
+ width: "100%",
307
+ padding: 0,
308
+ })
309
+
310
+ this.summaryText = new OpenTUI.TextRenderable(this.renderer, {
311
+ id: "summary_text",
312
+ content: "",
313
+ })
314
+ this.primaryText = new OpenTUI.TextRenderable(this.renderer, {
315
+ id: "primary_text",
316
+ content: "",
317
+ })
318
+ this.detailText = new OpenTUI.TextRenderable(this.renderer, {
319
+ id: "detail_text",
320
+ content: "",
321
+ selectable: true,
322
+ selectionBg: "#1d4ed8",
323
+ selectionFg: "#eff6ff",
324
+ }) as ScrollableTextRenderable
325
+ this.menuText = new OpenTUI.TextRenderable(this.renderer, {
326
+ id: "menu_text",
327
+ content: "",
328
+ })
329
+ this.statusText = new OpenTUI.TextRenderable(this.renderer, {
330
+ id: "status_text",
331
+ content: "",
332
+ })
333
+
334
+ this.summaryBox.add(this.summaryText)
335
+ this.primaryBox.add(this.primaryText)
336
+ this.detailBox.add(this.detailText)
337
+ this.menuBox.add(this.menuText)
338
+ statusBox.add(this.statusText)
339
+
340
+ root.add(this.summaryBox)
341
+ root.add(this.primaryBox)
342
+ root.add(this.detailBox)
343
+ root.add(this.menuBox)
344
+ root.add(statusBox)
345
+
346
+ this.renderer.root.add(root)
347
+ }
348
+
349
+ private async refreshState(logErrors = true) {
350
+ try {
351
+ const state = (await this.rpc.request<AppState>("state.get")) ?? EMPTY_STATE
352
+ this.state = state
353
+ this.backendOnline = true
354
+ this.connectionError = ""
355
+ } catch (error) {
356
+ this.state = EMPTY_STATE
357
+ this.backendOnline = false
358
+ this.connectionError = String(error)
359
+ if (logErrors) {
360
+ this.pushLog(`Не удалось подключиться к backend: ${this.getShortConnectionError()}`)
361
+ }
362
+ }
363
+
364
+ this.rebuildMenuOptions()
365
+ this.render()
366
+ }
367
+
368
+ private rebuildMenuOptions() {
369
+ this.menuOptions = getMenuOptions(this.menuName, this.state)
370
+ if (this.selectedIndex >= this.menuOptions.length) {
371
+ this.selectedIndex = Math.max(0, this.menuOptions.length - 1)
372
+ }
373
+ }
374
+
375
+ private setStatus() {
376
+ if (this.prompt.active) {
377
+ if (this.prompt.mode === "select") {
378
+ const option = this.prompt.options[this.prompt.selectedIndex]
379
+ const label = option ? option.label : "нет вариантов"
380
+ const hint = option?.hint ? ` • ${option.hint}` : ""
381
+ setRenderableText(
382
+ this.statusText,
383
+ t`${bg("#111827")(fg("#e5e7eb")(bold(` ${this.prompt.question}: ${label}${hint} `)))}`,
384
+ )
385
+ return
386
+ }
387
+ const typed = this.prompt.hidden ? "*".repeat(this.prompt.value.length) : this.prompt.value
388
+ const value = typed || "_"
389
+ setRenderableText(
390
+ this.statusText,
391
+ t`${bg("#111827")(fg("#e5e7eb")(bold(` ${this.prompt.question}: ${value} `)))}`,
392
+ )
393
+ return
394
+ }
395
+
396
+ const hint = getHint(this.menuName, this.menuCtx)
397
+ const backendSeg = this.backendOnline
398
+ ? bg("#14532d")(fg("#dcfce7")(" backend: online "))
399
+ : bg("#7f1d1d")(fg("#fee2e2")(" backend: offline "))
400
+ const hintSeg = bg("#334155")(fg("#e2e8f0")(` ${hint} `))
401
+
402
+ if (this.busy) {
403
+ const title = this.busyTitle || "Выполняется..."
404
+ const busySeg = bg("#7c2d12")(fg("#ffedd5")(bold(` ● ${title} `)))
405
+ setRenderableText(this.statusText, t`${busySeg}${hintSeg}${backendSeg}`)
406
+ } else {
407
+ const readySeg = bg("#1e293b")(fg("#e2e8f0")(" ○ Готово "))
408
+ setRenderableText(this.statusText, t`${hintSeg}${readySeg}${backendSeg}`)
409
+ }
410
+ }
411
+
412
+ private pushLog(message: string) {
413
+ this.logs.push(`[${timeStamp()}] ${message}`)
414
+ this.scrollDetailToEnd()
415
+ }
416
+
417
+ private scrollDetailToEnd() {
418
+ if (typeof this.detailText.maxScrollY === "number") {
419
+ this.detailText.scrollY = this.detailText.maxScrollY
420
+ }
421
+ }
422
+
423
+ private render() {
424
+ this.rebuildMenuOptions()
425
+ const table = getTable(this.menuName, this.state, this.menuCtx)
426
+ const promptMenuOptions: MenuOption[] = this.prompt.mode === "select"
427
+ ? this.prompt.options.map((option) => ({
428
+ id: option.value,
429
+ label: option.label,
430
+ hint: option.hint,
431
+ destructive: option.destructive,
432
+ }))
433
+ : []
434
+ const visibleMenuOptions = this.prompt.active && this.prompt.mode === "select"
435
+ ? promptMenuOptions
436
+ : this.menuOptions
437
+ const visibleMenuIndex = this.prompt.active && this.prompt.mode === "select"
438
+ ? this.prompt.selectedIndex
439
+ : this.selectedIndex
440
+
441
+ this.summaryBox.title = this.menuName === "main" ? "izTeamSlots" : `Раздел: ${this.getScreenTitle()}`
442
+ this.primaryBox.title = this.menuName === "main" ? "Быстрый старт" : this.getPrimaryTitle()
443
+ this.detailBox.title = this.prompt.active && this.prompt.mode === "select"
444
+ ? "Выбор"
445
+ : this.menuName === "main" ? "События и подсказки" : "Контекст"
446
+ this.menuBox.title = this.prompt.active && this.prompt.mode === "select" ? "Варианты" : "Действия"
447
+ this.menuBox.height = Math.max(6, Math.min(10, visibleMenuOptions.length + 2))
448
+
449
+ setRenderableText(this.summaryText, this.buildSummaryPanel())
450
+
451
+ if (this.menuName === "main") {
452
+ setRenderableText(this.primaryText, this.buildMainPanel())
453
+ } else if (table.rows.length > 0) {
454
+ setRenderableText(
455
+ this.primaryText,
456
+ formatTable(table.headers, table.rows, Math.max(72, process.stdout.columns || 120)),
457
+ )
458
+ } else {
459
+ setRenderableText(this.primaryText, this.buildEmptyStatePanel())
460
+ }
461
+
462
+ setRenderableText(this.detailText, this.buildDetailPanel())
463
+ setRenderableText(
464
+ this.menuText,
465
+ formatMenu(visibleMenuOptions, visibleMenuIndex, Math.max(48, (process.stdout.columns || 120) - 6)),
466
+ )
467
+ this.setStatus()
468
+ this.renderer.requestRender()
469
+ }
470
+
471
+ private move(delta: number) {
472
+ if (this.menuOptions.length === 0) return
473
+ const last = this.menuOptions.length - 1
474
+ if (delta > 0) {
475
+ this.selectedIndex = this.selectedIndex >= last ? 0 : this.selectedIndex + 1
476
+ } else {
477
+ this.selectedIndex = this.selectedIndex <= 0 ? last : this.selectedIndex - 1
478
+ }
479
+ this.render()
480
+ }
481
+
482
+ private getScreenTitle(): string {
483
+ if (this.menuName === "admins") return "Админы"
484
+ if (this.menuName === "slots") return "Слоты"
485
+ if (this.menuName === "mail") return "Почта"
486
+ if (this.menuName === "pick_admin") return "Выбор админа"
487
+ if (this.menuName === "pick_worker") return "Выбор слота"
488
+ if (this.menuName === "pick_account") return "Выбор аккаунта"
489
+ if (this.menuName === "confirm") return "Подтверждение"
490
+ return "Обзор"
491
+ }
492
+
493
+ private getPrimaryTitle(): string {
494
+ if (this.menuCtx.title) return this.menuCtx.title
495
+ return this.getScreenTitle()
496
+ }
497
+
498
+ private buildSummaryPanel(): StyledText {
499
+ const connectionLine = this.backendOnline
500
+ ? t`${fg("#4ade80")("Подключение активно")}`
501
+ : t`${fg("#fca5a5")("Подключение потеряно")}`
502
+ const contextLine = this.backendOnline
503
+ ? t`${fg("#94a3b8")("Экран")} ${fg("#e2e8f0")(this.getScreenTitle())}`
504
+ : t`${fg("#94a3b8")("Причина")} ${fg("#fca5a5")(this.getShortConnectionError())}`
505
+
506
+ return joinStyledLines([
507
+ t`${fg("#f8fafc")(bold(HERO_LOGO))}`,
508
+ contextLine,
509
+ connectionLine,
510
+ formatDashboard(getDashboard(this.state)),
511
+ ])
512
+ }
513
+
514
+ private buildMainPanel(): StyledText {
515
+ if (!this.backendOnline) {
516
+ return this.buildRecoveryPanel()
517
+ }
518
+
519
+ const adminsReady = this.state.admins.filter((admin) => hasFlag(admin, "has_access_token")).length
520
+ const workersReady = this.state.workers.filter((worker) => hasFlag(worker, "has_access_token")).length
521
+ const lines: StyledText[] = [
522
+ t`${fg("#f8fafc")(bold("Что делать дальше"))}`,
523
+ ]
524
+
525
+ for (const step of this.getRecommendedSteps()) {
526
+ lines.push(t`${fg("#94a3b8")("•")} ${fg("#e2e8f0")(step)}`)
527
+ }
528
+
529
+ lines.push(t`${fg("#475569")(" ")}`)
530
+ lines.push(
531
+ t`${fg("#94a3b8")("Готовность")} ${fg("#e2e8f0")(`${adminsReady}/${this.state.admins.length || 0} админов, ${workersReady}/${this.state.workers.length || 0} слотов`)}`
532
+ )
533
+ lines.push(
534
+ t`${fg("#94a3b8")("Подсказка")} ${fg("#cbd5e1")("Стрелки и цифры работают и в меню, и в режимах выбора. Enter подтверждает, r обновляет состояние.")}`
535
+ )
536
+
537
+ return joinStyledLines(lines)
538
+ }
539
+
540
+ private buildRecoveryPanel(): StyledText {
541
+ const lines: StyledText[] = [
542
+ t`${fg("#f8fafc")(bold("Backend недоступен"))}`,
543
+ t`${fg("#fca5a5")(this.getShortConnectionError())}`,
544
+ t`${fg("#94a3b8")("Проверьте окружение и повторите обновление клавишей r.")}`,
545
+ ]
546
+
547
+ const error = this.connectionError.toLowerCase()
548
+ if (error.includes("no module named 'requests'")) {
549
+ lines.push(t`${fg("#93c5fd")("Подсказка: установите Python-пакет requests.")}`)
550
+ } else if (error.includes("no module named 'seleniumbase'")) {
551
+ lines.push(t`${fg("#93c5fd")("Подсказка: установите seleniumbase.")}`)
552
+ } else {
553
+ lines.push(t`${fg("#93c5fd")("Подсказка: проверьте python-зависимости и запуск backend модуля.")}`)
554
+ }
555
+
556
+ return joinStyledLines(lines)
557
+ }
558
+
559
+ private getRecommendedSteps(): string[] {
560
+ if (this.state.admins.length === 0) {
561
+ return [
562
+ "Добавьте первого админа через раздел «Админы».",
563
+ "После добавления выполните логин, чтобы сохранить токен и профиль браузера.",
564
+ "Затем создайте слоты и проверьте входящие письма.",
565
+ ]
566
+ }
567
+
568
+ const adminsWithoutLogin = this.state.admins.filter((admin) => !hasFlag(admin, "has_access_token")).length
569
+ if (adminsWithoutLogin > 0) {
570
+ return [
571
+ "Перелогиньте хотя бы одного админа.",
572
+ "Убедитесь, что у него есть токен и рабочий браузерный профиль.",
573
+ "После этого можно запускать создание слотов.",
574
+ ]
575
+ }
576
+
577
+ if (this.state.workers.length === 0) {
578
+ return [
579
+ "Откройте раздел «Слоты».",
580
+ "Запустите создание слотов под нужным админом.",
581
+ "После завершения проверьте почту и недавние события.",
582
+ ]
583
+ }
584
+
585
+ const workersWithoutPassword = this.state.workers.filter((worker) => !worker.has_openai_password).length
586
+ if (workersWithoutPassword > 0) {
587
+ return [
588
+ "Часть слотов ещё не готова к перелогину.",
589
+ "Проверьте, что для нужных слотов сохранён OpenAI-пароль.",
590
+ "Используйте раздел «Почта», чтобы контролировать инвайты и письма.",
591
+ ]
592
+ }
593
+
594
+ return [
595
+ "Система выглядит рабочей.",
596
+ "Для обслуживания слотов используйте разделы «Слоты» и «Почта».",
597
+ "Последние события и ошибки всегда видны в нижней панели.",
598
+ ]
599
+ }
600
+
601
+ private buildDetailPanel(): StyledText {
602
+ if (!this.backendOnline) {
603
+ return this.buildRecoveryPanel()
604
+ }
605
+
606
+ if (this.prompt.active && this.prompt.mode === "select") {
607
+ const option = this.prompt.options[this.prompt.selectedIndex]
608
+ const lines: StyledText[] = [
609
+ t`${fg("#f8fafc")(bold(this.prompt.question))}`,
610
+ ]
611
+ if (option) {
612
+ lines.push(t`${fg("#93c5fd")("•")} ${fg("#e2e8f0")(option.label)}`)
613
+ if (option.hint) {
614
+ lines.push(t`${fg("#94a3b8")(option.hint)}`)
615
+ }
616
+ }
617
+ lines.push(t`${fg("#64748b")("Enter выбрать • Esc отмена • Стрелки перемещают курсор")}`)
618
+ return joinStyledLines(lines)
619
+ }
620
+
621
+ const lines: StyledText[] = []
622
+ const selected = this.menuOptions[this.selectedIndex]
623
+ const extra = selected as MenuOption & { badge?: string }
624
+
625
+ if (selected) {
626
+ const badge = extra?.badge ? ` • ${extra.badge}` : ""
627
+ lines.push(t`${fg("#f8fafc")(bold(`${selected.label}${badge}`))}`)
628
+ lines.push(t`${fg("#94a3b8")(getMenuOptionDescription(selected))}`)
629
+ }
630
+
631
+ for (const note of this.getContextNotes()) {
632
+ lines.push(t`${fg("#93c5fd")("•")} ${fg("#cbd5e1")(note)}`)
633
+ }
634
+
635
+ const recentLogs = this.logs.slice(-15)
636
+ if (recentLogs.length > 0) {
637
+ lines.push(t`${fg("#475569")(" ")}`)
638
+ lines.push(t`${fg("#f8fafc")(bold("Последние события"))}`)
639
+ for (const log of recentLogs) {
640
+ lines.push(t`${fg("#94a3b8")(log)}`)
641
+ }
642
+ } else {
643
+ lines.push(t`${fg("#64748b")("Событий пока нет.")}`)
644
+ }
645
+
646
+ return joinStyledLines(lines)
647
+ }
648
+
649
+ private buildEmptyStatePanel(): StyledText {
650
+ const lines: StyledText[] = [t`${fg("#f8fafc")(bold("Пока пусто"))}`]
651
+
652
+ if (this.menuName === "admins") {
653
+ lines.push(t`${fg("#cbd5e1")("Сначала добавьте первого админа, затем выполните логин.")}`)
654
+ } else if (this.menuName === "slots") {
655
+ lines.push(t`${fg("#cbd5e1")("Слоты появятся после запуска пайплайна от выбранного админа.")}`)
656
+ } else if (this.menuName === "mail") {
657
+ lines.push(t`${fg("#cbd5e1")("Почтовые аккаунты появятся после добавления админа или создания слотов.")}`)
658
+ } else if (this.menuName === "pick_admin" || this.menuName === "pick_worker") {
659
+ lines.push(t`${fg("#cbd5e1")("Нет доступных элементов для выбора. Вернитесь назад и подготовьте данные.")}`)
660
+ } else {
661
+ lines.push(t`${fg("#cbd5e1")("Добавьте данные или вернитесь на главный экран.")}`)
662
+ }
663
+
664
+ return joinStyledLines(lines)
665
+ }
666
+
667
+ private getContextNotes(): string[] {
668
+ if (this.menuName === "admins") {
669
+ const ready = this.state.admins.filter((admin) => hasFlag(admin, "has_access_token")).length
670
+ return [
671
+ `Всего админов: ${this.state.admins.length}. Готовы к работе: ${ready}.`,
672
+ "Для создания слотов нужен админ с токеном и рабочим браузерным профилем.",
673
+ ]
674
+ }
675
+
676
+ if (this.menuName === "slots") {
677
+ const withPassword = this.state.workers.filter((worker) => worker.has_openai_password).length
678
+ return [
679
+ `Всего слотов: ${this.state.workers.length}. С OpenAI-паролем: ${withPassword}.`,
680
+ "Массовый перелогин работает только для слотов с сохранённым паролем.",
681
+ ]
682
+ }
683
+
684
+ if (this.menuName === "mail") {
685
+ const notes = [
686
+ `Доступно почтовых аккаунтов: ${this.state.accounts.length}.`,
687
+ "Выберите аккаунт, чтобы подтянуть последние письма в лог.",
688
+ ]
689
+ const selectedId = this.menuOptions[this.selectedIndex]?.id ?? ""
690
+ if (selectedId.startsWith("mail_pick:")) {
691
+ const index = Number(selectedId.split(":", 2)[1])
692
+ const account = this.state.accounts[index]
693
+ if (account) {
694
+ notes.push(`Текущий выбор: ${account.email} (${account.kind}).`)
695
+ }
696
+ }
697
+ return notes
698
+ }
699
+
700
+ if (this.menuName === "pick_admin") {
701
+ const option = this.menuOptions[this.selectedIndex]
702
+ const email = option?.id.split(":", 2)[1]
703
+ const admin = this.state.admins.find((item) => item.email === email)
704
+ if (!admin) return ["Админ не найден в текущем состоянии."]
705
+
706
+ return [
707
+ `${admin.email}`,
708
+ `Токен: ${boolLabel(admin.has_access_token)} • Профиль: ${boolLabel(hasFlag(admin, "has_browser_profile"))}`,
709
+ `Последний логин: ${formatShortDate(admin.last_login)}`,
710
+ ]
711
+ }
712
+
713
+ if (this.menuName === "pick_worker") {
714
+ const option = this.menuOptions[this.selectedIndex]
715
+ const email = option?.id.split(":", 2)[1]
716
+ const worker = this.state.workers.find((item) => item.email === email)
717
+ if (!worker) return ["Слот не найден в текущем состоянии."]
718
+
719
+ return [
720
+ `${worker.email}`,
721
+ `Статус: ${statusLabel(worker.status)} • Профиль: ${boolLabel(hasFlag(worker, "has_browser_profile"))}`,
722
+ `Админ: ${worker.admin_email ?? "не назначен"} • OpenAI-пароль: ${boolLabel(worker.has_openai_password)}`,
723
+ ]
724
+ }
725
+
726
+ if (this.menuName === "confirm") {
727
+ return [
728
+ `Действие: ${this.menuCtx.confirm_action ?? "подтверждение"}`,
729
+ `Объект: ${this.menuCtx.target ?? "не выбран"}`,
730
+ ]
731
+ }
732
+
733
+ return []
734
+ }
735
+
736
+ private getShortConnectionError(): string {
737
+ const message = this.connectionError || "ошибка подключения"
738
+ if (message.includes("RPC process exited before response")) {
739
+ return "backend завершился во время запуска или не смог ответить"
740
+ }
741
+ if (message.includes("RPC spawn error")) {
742
+ return "не удалось запустить backend-процесс"
743
+ }
744
+ return message.length > 120 ? `${message.slice(0, 119)}…` : message
745
+ }
746
+
747
+ private async goBack() {
748
+ if (this.busy || this.prompt.active) return
749
+ if (this.menuName === "main") return
750
+ this.menuName = parentMenu(this.menuName, this.menuCtx)
751
+ this.menuCtx = {}
752
+ this.selectedIndex = 0
753
+ await this.refreshState()
754
+ }
755
+
756
+ private async promptInput(question: string, hidden = false): Promise<string | null> {
757
+ this.prompt.active = true
758
+ this.prompt.mode = "input"
759
+ this.prompt.question = question
760
+ this.prompt.hidden = hidden
761
+ this.prompt.value = ""
762
+ this.prompt.options = []
763
+ this.prompt.selectedIndex = 0
764
+ this.render()
765
+
766
+ return await new Promise((resolve) => {
767
+ this.prompt.resolve = resolve
768
+ })
769
+ }
770
+
771
+ private async promptSelect(question: string, options: PromptOption[]): Promise<string | null> {
772
+ if (options.length === 0) return null
773
+ this.prompt.active = true
774
+ this.prompt.mode = "select"
775
+ this.prompt.question = question
776
+ this.prompt.hidden = false
777
+ this.prompt.value = ""
778
+ this.prompt.options = options
779
+ this.prompt.selectedIndex = 0
780
+ this.render()
781
+
782
+ return await new Promise((resolve) => {
783
+ this.prompt.resolve = resolve
784
+ })
785
+ }
786
+
787
+ private finishPrompt(value: string | null) {
788
+ const resolve = this.prompt.resolve
789
+ this.prompt.active = false
790
+ this.prompt.mode = "input"
791
+ this.prompt.question = ""
792
+ this.prompt.hidden = false
793
+ this.prompt.value = ""
794
+ this.prompt.options = []
795
+ this.prompt.selectedIndex = 0
796
+ this.prompt.resolve = null
797
+ resolve?.(value)
798
+ this.render()
799
+ }
800
+
801
+ private async promptLoginMode(): Promise<"auto" | "manual" | null> {
802
+ const value = await this.promptSelect("Режим входа", [
803
+ { value: "auto", label: "Авто", hint: "Email и пароль вводятся в TUI, дальше логин идёт автоматически." },
804
+ { value: "manual", label: "Вручную", hint: "Браузер откроется сразу, вход и код подтверждения вы вводите сами." },
805
+ ])
806
+ if (value === "auto" || value === "manual") return value
807
+ return null
808
+ }
809
+
810
+ private async startAdminLogin(email: string, mode: "auto" | "manual") {
811
+ const method = mode === "manual" ? "job.login_admin_manual" : "job.login_admin"
812
+ await this.startJob(method, { email })
813
+ }
814
+
815
+ private async promptSlotReloginScope(): Promise<"one" | "all" | null> {
816
+ const value = await this.promptSelect("Перелогин слотов", [
817
+ { value: "one", label: "Один слот", hint: "Сначала выберете конкретный слот, потом запустится перелогин." },
818
+ { value: "all", label: "Все слоты", hint: "Запустить перелогин для всех слотов с сохранённым паролем." },
819
+ ])
820
+ if (value === "one" || value === "all") return value
821
+ return null
822
+ }
823
+
824
+ private async submitOption(optionId: string) {
825
+ if (this.menuName === "main") {
826
+ if (optionId === "menu_admins") this.menuName = "admins"
827
+ else if (optionId === "menu_slots") this.menuName = "slots"
828
+ else if (optionId === "menu_mail") this.menuName = "mail"
829
+ else if (optionId === "menu_exit") { await this.exit(); return }
830
+ this.menuCtx = {}
831
+ this.selectedIndex = 0
832
+ await this.refreshState()
833
+ return
834
+ }
835
+
836
+ if (this.menuName === "admins") {
837
+ if (optionId === "adm_add") {
838
+ const loginMode = await this.promptLoginMode()
839
+ if (!loginMode) return
840
+ if (loginMode === "manual") {
841
+ await this.startJob("job.add_admin_manual")
842
+ return
843
+ }
844
+
845
+ const email = await this.promptInput("Email админа")
846
+ if (!email) return
847
+ const password = await this.promptInput("Пароль почты", true)
848
+ if (!password) return
849
+ try {
850
+ await this.rpc.request("admin.add", { email, password })
851
+ } catch (error) {
852
+ this.pushLog(`Ошибка: ${String(error)}`)
853
+ this.render()
854
+ return
855
+ }
856
+ await this.startAdminLogin(email, loginMode)
857
+ return
858
+ }
859
+ if (optionId === "adm_relogin") {
860
+ this.goToPicker("pick_admin", "admins", "relogin_admin", "Выберите админа")
861
+ return
862
+ }
863
+ if (optionId === "adm_open") {
864
+ this.goToPicker("pick_admin", "admins", "open_admin", "Открыть браузер админа")
865
+ return
866
+ }
867
+ if (optionId === "adm_delete") {
868
+ this.goToPicker("pick_admin", "admins", "delete_admin", "Удалить админа")
869
+ return
870
+ }
871
+ }
872
+
873
+ if (this.menuName === "slots") {
874
+ if (optionId === "slots_create") {
875
+ this.goToPicker("pick_admin", "slots", "slots_create", "Выберите админа для слотов")
876
+ return
877
+ }
878
+ if (optionId === "slots_relogin") {
879
+ const scope = await this.promptSlotReloginScope()
880
+ if (!scope) return
881
+ if (scope === "all") {
882
+ await this.startJob("job.relogin_all_workers")
883
+ return
884
+ }
885
+ this.goToPicker("pick_worker", "slots", "relogin_worker", "Выберите слот")
886
+ return
887
+ }
888
+ if (optionId === "slots_open") {
889
+ this.goToPicker("pick_worker", "slots", "open_worker", "Открыть браузер слота")
890
+ return
891
+ }
892
+ if (optionId === "slots_delete") {
893
+ this.goToPicker("pick_worker", "slots", "delete_worker", "Удалить слот")
894
+ return
895
+ }
896
+ }
897
+
898
+ if (this.menuName === "mail" && optionId.startsWith("mail_pick:")) {
899
+ const index = Number(optionId.split(":", 2)[1])
900
+ if (!Number.isInteger(index) || index < 0 || index >= this.state.accounts.length) return
901
+ const email = this.state.accounts[index].email
902
+ this.menuName = "mail"
903
+ this.menuCtx = {}
904
+ await this.startJob("job.fetch_mail", { email })
905
+ return
906
+ }
907
+
908
+ if (this.menuName === "pick_admin" && optionId.startsWith("pick_admin:")) {
909
+ const email = optionId.split(":", 2)[1]
910
+ const action = this.menuCtx.action
911
+ const parent = (this.menuCtx.parent ?? "main") as MenuName
912
+
913
+ if (action === "relogin_admin") {
914
+ const loginMode = await this.promptLoginMode()
915
+ if (!loginMode) return
916
+ this.menuName = parent
917
+ this.menuCtx = {}
918
+ await this.startAdminLogin(email, loginMode)
919
+ return
920
+ }
921
+ if (action === "open_admin") {
922
+ this.menuName = parent
923
+ this.menuCtx = {}
924
+ await this.startJob("job.open_admin_browser", { email })
925
+ return
926
+ }
927
+ if (action === "delete_admin") {
928
+ this.goToConfirm(parent, "delete_admin", "Удаление админа", email)
929
+ return
930
+ }
931
+ if (action === "slots_create") {
932
+ const countRaw = await this.promptInput(`Количество слотов для ${email}`)
933
+ if (!countRaw) return
934
+ const count = Number(countRaw)
935
+ if (!Number.isInteger(count) || count <= 0) {
936
+ this.pushLog("Некорректное число")
937
+ this.render()
938
+ return
939
+ }
940
+ this.menuName = parent
941
+ this.menuCtx = {}
942
+ await this.startJob("job.run_slots", { admin_email: email, count })
943
+ return
944
+ }
945
+ }
946
+
947
+ if (this.menuName === "pick_worker" && optionId.startsWith("pick_worker:")) {
948
+ const email = optionId.split(":", 2)[1]
949
+ const action = this.menuCtx.action
950
+ const parent = (this.menuCtx.parent ?? "slots") as MenuName
951
+
952
+ if (action === "relogin_worker") {
953
+ this.menuName = parent
954
+ this.menuCtx = {}
955
+ await this.startJob("job.relogin_worker", { email })
956
+ return
957
+ }
958
+ if (action === "open_worker") {
959
+ this.menuName = parent
960
+ this.menuCtx = {}
961
+ await this.startJob("job.open_worker_browser", { email })
962
+ return
963
+ }
964
+ if (action === "delete_worker") {
965
+ this.goToConfirm(parent, "delete_worker", "Удаление слота", email)
966
+ return
967
+ }
968
+ }
969
+
970
+ if (this.menuName === "confirm") {
971
+ const action = this.menuCtx.action
972
+ const target = this.menuCtx.target
973
+ const parent = (this.menuCtx.parent ?? "main") as MenuName
974
+
975
+ if (optionId === "confirm_no") {
976
+ this.menuName = parent
977
+ this.menuCtx = {}
978
+ await this.refreshState()
979
+ return
980
+ }
981
+
982
+ if (optionId === "confirm_yes" && target) {
983
+ try {
984
+ if (action === "delete_admin") {
985
+ await this.rpc.request("admin.delete", { email: target })
986
+ this.pushLog(`Удалён админ: ${target}`)
987
+ }
988
+ if (action === "delete_worker") {
989
+ await this.rpc.request("worker.delete", { email: target })
990
+ this.pushLog(`Удалён слот: ${target}`)
991
+ }
992
+ } catch (error) {
993
+ this.pushLog(`Ошибка: ${String(error)}`)
994
+ }
995
+ this.menuName = parent
996
+ this.menuCtx = {}
997
+ await this.refreshState()
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ private goToPicker(picker: MenuName, parent: MenuName, action: string, title: string) {
1003
+ this.menuName = picker
1004
+ this.menuCtx = { parent, action, title }
1005
+ this.selectedIndex = 0
1006
+ this.render()
1007
+ }
1008
+
1009
+ private goToConfirm(parent: MenuName, action: string, confirmAction: string, target: string) {
1010
+ this.menuName = "confirm"
1011
+ this.menuCtx = {
1012
+ parent,
1013
+ action,
1014
+ confirm_action: confirmAction,
1015
+ title: `${confirmAction}: ${target}`,
1016
+ target,
1017
+ }
1018
+ this.selectedIndex = 1
1019
+ this.render()
1020
+ }
1021
+
1022
+ private async startJob(method: string, params: Record<string, unknown> = {}) {
1023
+ if (this.busy) return
1024
+ this.busy = true
1025
+ try {
1026
+ const result = await this.rpc.request<RpcJobResult>(method, params)
1027
+ this.backendOnline = true
1028
+ this.pushLog(`Задача ${result.job_id.slice(0, 8)} поставлена в очередь`)
1029
+ } catch (error) {
1030
+ this.busy = false
1031
+ this.pushLog(`Ошибка: ${String(error)}`)
1032
+ }
1033
+ this.render()
1034
+ }
1035
+
1036
+ private onPaste(event: any) {
1037
+ if (!this.prompt.active) return
1038
+ if (this.prompt.mode !== "input") return
1039
+ const text = String(event?.text ?? "")
1040
+ if (!text) return
1041
+ const normalized = text.replace(/\r\n?/g, "\n").split("\n")[0]
1042
+ this.prompt.value += normalized
1043
+ this.render()
1044
+ }
1045
+
1046
+ private copySelectedLogToClipboard() {
1047
+ const selected = this.renderer.getSelection()?.getSelectedText()?.trim() ?? ""
1048
+ const fallback = this.detailText.getSelectedText?.().trim() ?? ""
1049
+ const text = selected || fallback
1050
+ if (!text) return
1051
+ const copiedViaOsc52 = this.renderer.copyToClipboardOSC52(text)
1052
+ const copied = copiedViaOsc52 || copyToSystemClipboard(text)
1053
+ this.pushLog(copied ? "Скопировано в буфер обмена" : "Копирование не поддерживается")
1054
+ this.render()
1055
+ }
1056
+
1057
+ private async onKey(key: any) {
1058
+ if (this.prompt.active) {
1059
+ if (this.prompt.mode === "select") {
1060
+ if (key.name === "return") {
1061
+ const option = this.prompt.options[this.prompt.selectedIndex]
1062
+ this.finishPrompt(option?.value ?? null)
1063
+ return
1064
+ }
1065
+ if (key.name === "escape") {
1066
+ this.finishPrompt(null)
1067
+ return
1068
+ }
1069
+ if (key.name === "up" || key.name === "k") {
1070
+ const total = this.prompt.options.length
1071
+ if (total > 0) {
1072
+ this.prompt.selectedIndex = this.prompt.selectedIndex <= 0 ? total - 1 : this.prompt.selectedIndex - 1
1073
+ this.render()
1074
+ }
1075
+ return
1076
+ }
1077
+ if (key.name === "down" || key.name === "j") {
1078
+ const total = this.prompt.options.length
1079
+ if (total > 0) {
1080
+ this.prompt.selectedIndex = this.prompt.selectedIndex >= total - 1 ? 0 : this.prompt.selectedIndex + 1
1081
+ this.render()
1082
+ }
1083
+ return
1084
+ }
1085
+ if (key.sequence >= "1" && key.sequence <= "9" && !key.ctrl && !key.meta) {
1086
+ const index = Number(key.sequence) - 1
1087
+ if (index < this.prompt.options.length) {
1088
+ this.prompt.selectedIndex = index
1089
+ this.render()
1090
+ }
1091
+ return
1092
+ }
1093
+ return
1094
+ }
1095
+
1096
+ if (key.name === "return") {
1097
+ const value = this.prompt.value.trim()
1098
+ this.finishPrompt(value ? value : null)
1099
+ return
1100
+ }
1101
+ if (key.name === "escape") {
1102
+ this.finishPrompt(null)
1103
+ return
1104
+ }
1105
+ if (key.name === "backspace") {
1106
+ this.prompt.value = this.prompt.value.slice(0, -1)
1107
+ this.render()
1108
+ return
1109
+ }
1110
+ if (typeof key.sequence === "string" && key.sequence.length === 1 && !key.ctrl && !key.meta) {
1111
+ this.prompt.value += key.sequence
1112
+ this.render()
1113
+ }
1114
+ return
1115
+ }
1116
+
1117
+ if (key.name === "y" && !key.ctrl && !key.meta) {
1118
+ this.copySelectedLogToClipboard()
1119
+ return
1120
+ }
1121
+ if ((key.ctrl || key.meta) && key.name === "c") {
1122
+ this.copySelectedLogToClipboard()
1123
+ return
1124
+ }
1125
+
1126
+ if (this.busy && key.name !== "q") {
1127
+ return
1128
+ }
1129
+
1130
+ if (key.name === "r" && !key.ctrl && !key.meta) {
1131
+ await this.refreshState()
1132
+ return
1133
+ }
1134
+
1135
+ if (key.sequence >= "1" && key.sequence <= "9" && !key.ctrl && !key.meta) {
1136
+ const index = Number(key.sequence) - 1
1137
+ if (index < this.menuOptions.length) {
1138
+ this.selectedIndex = index
1139
+ await this.submitOption(this.menuOptions[index].id)
1140
+ }
1141
+ return
1142
+ }
1143
+
1144
+ if (key.name === "up" || key.name === "k") {
1145
+ this.move(-1)
1146
+ return
1147
+ }
1148
+ if (key.name === "down" || key.name === "j") {
1149
+ this.move(1)
1150
+ return
1151
+ }
1152
+ if (key.name === "escape") {
1153
+ await this.goBack()
1154
+ return
1155
+ }
1156
+ if (key.name === "return") {
1157
+ const selected = this.menuOptions[this.selectedIndex]
1158
+ if (selected) await this.submitOption(selected.id)
1159
+ return
1160
+ }
1161
+ if (key.name === "q") {
1162
+ await this.exit()
1163
+ }
1164
+ }
1165
+
1166
+ private async exit() {
1167
+ try {
1168
+ await this.rpc.shutdown()
1169
+ } catch {
1170
+ // no-op
1171
+ }
1172
+ this.renderer.destroy()
1173
+ process.exit(0)
1174
+ }
1175
+ }