@dotrino/vaultd 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui/app.js CHANGED
@@ -11,10 +11,20 @@
11
11
  * Cada "bóveda" es un PERFIL (maestra propia, dir propio, dispositivos y secretos
12
12
  * propios). Las acciones operan sobre la bóveda ACTIVA; para operar otra, cámbiala
13
13
  * en la pantalla de bóvedas.
14
+ *
15
+ * BILINGÜE (CONVENCIONES §9): todo el texto sale de `i18n.js` según `st.lang`; la
16
+ * tecla `l` conmuta es/en en cualquier pantalla y recuerda la elección.
17
+ *
18
+ * LAS TECLAS NO CAMBIAN CON EL IDIOMA: son mnemónicos en INGLÉS y son las mismas
19
+ * en español (lo que se traduce es la palabra que las explica en la barra de
20
+ * ayuda). new · rename · delete · password · unlock · locK · pair · approve ·
21
+ * reject · reVoke · refresh · back · language · quit. Por eso el candado dejó de
22
+ * ser `l` (hoy idioma) y es `k`, la contraseña es `p` y emparejar es `p`.
14
23
  */
15
24
  import { execFile } from 'node:child_process'
16
- import { createTerm } from './term.js'
25
+ import { createTerm, widthOf } from './term.js'
17
26
  import { qrToString } from '../qr.js'
27
+ import { dict, otherLang, loadLang, saveLang } from './i18n.js'
18
28
  import * as vc from '../vaultControl.js'
19
29
 
20
30
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
@@ -25,9 +35,24 @@ const KEY_RE = /^[A-Z0-9_]{1,64}$/
25
35
 
26
36
  // ------------------------------- utilidades --------------------------------
27
37
 
28
- function humanErr (e) {
29
- if (e?.code === 'DAEMON_DOWN') return 'El daemon no está corriendo. Arráncalo: systemctl --user start dotrino-vault (o reinicia la TUI).'
30
- return e?.message || String(e)
38
+ /** Diccionario del idioma activo (español si el estado aún no lo trae). */
39
+ const L = (st) => dict(st?.lang)
40
+
41
+ /**
42
+ * Errores en el idioma de la TUI. Los que nacen aquí o en `vaultControl` tienen
43
+ * `code` y se traducen; los que REENVÍA el daemon llegan como texto y se muestran
44
+ * tal cual (son diagnósticos del servicio, no copy de la interfaz).
45
+ */
46
+ function humanErr (e, st) {
47
+ const t = L(st)
48
+ const byCode = {
49
+ DAEMON_DOWN: t.errDaemonDown,
50
+ NO_REPLY: t.errNoReply,
51
+ NOT_APPLIED: t.errNotApplied,
52
+ NOT_DELETED: t.errNotDeleted,
53
+ PAIR_FAILED: t.errPairFailed
54
+ }
55
+ return byCode[e?.code] || e?.message || String(e)
31
56
  }
32
57
 
33
58
  function flash (st, text, kind = 'ok') { st.flash = { text, kind, at: Date.now() } }
@@ -91,52 +116,99 @@ function renderList (rows, selIdx, height, cols, t, scrollRef) {
91
116
  return out
92
117
  }
93
118
 
119
+ /**
120
+ * Barra de ayuda que SIEMPRE deja ver lo global (idioma y salir): si los segmentos
121
+ * no caben, recorta desde el MEDIO y marca el corte con «…». Sin esto, en 80
122
+ * columnas la ayuda se cortaba por la derecha y las teclas del final (justo las
123
+ * globales) no existían para quien no las supiera de memoria.
124
+ */
125
+ function fitHelp (segs, cols) {
126
+ const join = (a) => a.join(' · ')
127
+ if (widthOf(join(segs)) + 1 <= cols) return join(segs)
128
+ const head = segs.slice(0, 1)
129
+ const tail = segs.slice(-2)
130
+ const mid = segs.slice(1, -2)
131
+ while (mid.length) {
132
+ mid.pop()
133
+ const cand = join([...head, ...mid, '…', ...tail])
134
+ if (widthOf(cand) + 1 <= cols) return cand
135
+ }
136
+ return join([...head, '…', ...tail])
137
+ }
138
+
94
139
  // --------------------------------- pantallas -------------------------------
95
140
 
96
141
  function profileRows (st, t) {
142
+ const i = L(st)
97
143
  const list = st.profiles?.profiles || []
98
144
  return list.map((p) => {
99
145
  const mark = p.current ? t.accent('●') : ' '
100
- const lk = !p.protected ? t.muted('sin clave') : (p.locked ? t.warn('🔒 bloqueada') : t.ok('🔓 abierta'))
101
- const name = p.current ? t.bold(p.name || '(sin nombre)') : (p.name || '(sin nombre)')
146
+ const lk = !p.protected ? t.muted(i.noPassword) : (p.locked ? t.warn(i.locked) : t.ok(i.unlocked))
147
+ const name = p.current ? t.bold(p.name || i.noName) : (p.name || i.noName)
102
148
  return { text: ` ${mark} ${name} ${t.muted(p.id)} ${t.muted(p.fingerprint || '—')} ${lk}`, sel: true, meta: p }
103
149
  })
104
150
  }
105
151
 
106
152
  function deviceRows (st, t) {
153
+ const i = L(st)
107
154
  const rows = []
108
155
  const pend = st.pending
109
156
  if (pend) {
110
- rows.push({ text: t.warn(` ⧗ PENDIENTE: ${pend.deviceId}`) + t.muted(' — pulsa A para aprobar, X para rechazar'), sel: false })
157
+ rows.push({ text: t.warn(i.pendingDevice(pend.deviceId)) + t.muted(i.pendingHint), sel: false })
111
158
  rows.push({ text: '', sel: false })
112
159
  }
113
160
  const issued = st.devices?.issued || []
114
161
  if (!issued.length) {
115
- rows.push({ text: t.muted(' (sin dispositivos enrolados — pulsa E para emparejar uno)'), sel: false })
162
+ rows.push({ text: t.muted(i.noDevices), sel: false })
116
163
  }
117
164
  for (const d of issued) {
118
- const label = d.label || t.muted('(sin etiqueta)')
165
+ const label = d.label || t.muted(i.noLabel)
119
166
  const line = ` ${t.bold(d.deviceId)} ${label} ${t.muted('scope:' + shortScope(d.scope))} ${t.muted('exp:' + fmtExp(d.exp))} ${t.muted('nonce:' + (d.nonce ?? '—'))}`
120
167
  rows.push({ text: line, sel: true, meta: d })
121
168
  }
122
169
  const revoked = st.devices?.revoked || []
123
170
  if (revoked.length) {
124
171
  rows.push({ text: '', sel: false })
125
- rows.push({ text: t.muted(` Revocados: ${revoked.length}`), sel: false })
172
+ rows.push({ text: t.muted(i.revokedCount(revoked.length)), sel: false })
126
173
  }
127
174
  return rows
128
175
  }
129
176
 
177
+ /**
178
+ * LA PREGUNTA DEL EMPAREJAMIENTO. La decisión es del vault (es quien lo inicia) y
179
+ * este daemon puede tener varias cuentas: antes de mostrar el QR hay que decir a
180
+ * cuál entra el dispositivo. Hoy se responde con las dos formas que existen —una
181
+ * cuenta que ya vive aquí, o una nueva que se estrena para él—; la tercera
182
+ * («adoptar la que trae el aparato») necesita el protocolo de adopción y se
183
+ * muestra desactivada para no prometer lo que todavía no hace
184
+ * (docs/vinculacion-de-cuentas.md §5).
185
+ */
186
+ function pairModeRows (st, t) {
187
+ const i = L(st)
188
+ const ap = activeProfile(st)
189
+ const rows = [{ text: t.muted(' ' + i.pairModeIntro), sel: false }, { text: '', sel: false }]
190
+ rows.push({ text: ` ${t.bold(i.pairModeHere(ap?.name || ap?.id || '—'))}`, sel: true, meta: { mode: 'here' } })
191
+ rows.push({ text: t.muted(' ' + i.pairModeHereHint), sel: false })
192
+ rows.push({ text: '', sel: false })
193
+ rows.push({ text: ` ${t.bold(i.pairModeNew)}`, sel: true, meta: { mode: 'new' } })
194
+ rows.push({ text: t.muted(' ' + i.pairModeNewHint), sel: false })
195
+ rows.push({ text: '', sel: false })
196
+ rows.push({ text: ' ' + t.muted(i.pairModeAdopt), sel: false })
197
+ rows.push({ text: t.muted(' (' + i.pairModeAdoptSoon + ')'), sel: false })
198
+ return rows
199
+ }
200
+
130
201
  function secretRows (st, t) {
202
+ const i = L(st)
131
203
  const ns = st.secrets || {}
132
204
  const names = Object.keys(ns).sort()
133
205
  const rows = []
134
206
  if (!names.length) {
135
- rows.push({ text: t.muted(' (sin scopes — pulsa N para agregar la primera variable)'), sel: false })
207
+ rows.push({ text: t.muted(i.noScopes), sel: false })
136
208
  return rows
137
209
  }
138
210
  for (const n of names) {
139
- rows.push({ text: t.accent(` ▸ ${n}`) + t.muted(` (scope vault:secrets:${n})`), sel: true, meta: { ns: n, key: null } })
211
+ rows.push({ text: t.accent(` ▸ ${n}`) + t.muted(i.scopeOf(n)), sel: true, meta: { ns: n, key: null } })
140
212
  for (const k of ns[n].slice().sort()) {
141
213
  rows.push({ text: ` ${k} ${t.muted('••••••')}`, sel: true, meta: { ns: n, key: k } })
142
214
  }
@@ -156,11 +228,11 @@ function setConfirm (st, opts) { st.confirm = { ...opts } }
156
228
  async function guard (term, st, msg, fn) {
157
229
  st.busy = msg
158
230
  render(term, st)
159
- try { const v = await fn(); st.busy = null; return { ok: true, v } } catch (e) { st.busy = null; flash(st, humanErr(e), 'danger'); return { ok: false, e } }
231
+ try { const v = await fn(); st.busy = null; return { ok: true, v } } catch (e) { st.busy = null; flash(st, humanErr(e, st), 'danger'); return { ok: false, e } }
160
232
  }
161
233
 
162
234
  async function refreshAll (term, st) {
163
- const r = await guard(term, st, 'Cargando…', () => vc.snapshot(activeId(st)))
235
+ const r = await guard(term, st, L(st).loading, () => vc.snapshot(activeId(st)))
164
236
  if (!r.ok) return
165
237
  const { devices, secrets, profiles } = r.v
166
238
  if (profiles) st.profiles = profiles
@@ -172,28 +244,29 @@ async function refreshAll (term, st) {
172
244
  }
173
245
 
174
246
  async function refreshDevices (term, st) {
175
- const r = await guard(term, st, 'Cargando dispositivos…', () => vc.listDevices(activeId(st)))
247
+ const r = await guard(term, st, L(st).loadingDevices, () => vc.listDevices(activeId(st)))
176
248
  if (r.ok) st.devices = r.v
177
249
  }
178
250
  async function refreshSecrets (term, st) {
179
- const r = await guard(term, st, 'Cargando secretos…', () => vc.listSecrets(activeId(st)))
251
+ const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
180
252
  if (r.ok) st.secrets = r.v
181
253
  }
182
254
  async function refreshProfiles (term, st) {
183
- const r = await guard(term, st, 'Cargando bóvedas…', () => vc.listProfiles())
255
+ const r = await guard(term, st, L(st).loadingVaults, () => vc.listProfiles())
184
256
  if (r.ok) st.profiles = r.v
185
257
  }
186
258
 
187
259
  /** Asegura la bóveda desbloqueada antes de EDITARLA (rename/rm/password). */
188
260
  async function ensureUnlocked (term, st, p, thenFn) {
189
261
  if (!p.protected || !p.locked) return thenFn()
262
+ const i = L(st)
190
263
  setInput(st, {
191
- label: `Contraseña de "${p.name || p.id}"`,
264
+ label: i.passwordOf(p.name || p.id),
192
265
  mask: true,
193
- hint: 'necesaria para editar la bóveda',
266
+ hint: i.passwordToEdit,
194
267
  onSubmit: async (pwd) => {
195
268
  st.input = null
196
- const r = await guard(term, st, 'Desbloqueando…', () => vc.unlockProfile(p.id, pwd))
269
+ const r = await guard(term, st, i.unlocking, () => vc.unlockProfile(p.id, pwd))
197
270
  if (!r.ok) return
198
271
  await refreshProfiles(term, st)
199
272
  const fresh = (st.profiles.profiles || []).find((x) => x.id === p.id) || p
@@ -218,7 +291,15 @@ function moveSel (st, key, screen, count) {
218
291
  else if (key.name === 'end') st.sel[screen] = count - 1
219
292
  }
220
293
 
294
+ /** Conmuta es⇄en, lo recuerda y lo dice en el idioma NUEVO. */
295
+ function toggleLang (st) {
296
+ st.lang = otherLang(st.lang)
297
+ saveLang(st.lang)
298
+ flash(st, L(st).langChanged)
299
+ }
300
+
221
301
  async function onKeyProfiles (term, st, key) {
302
+ const i = L(st)
222
303
  const rows = profileRows(st, term.t)
223
304
  const sels = rows.filter((r) => r.sel).map((r) => r.meta)
224
305
  moveSel(st, key, 'profiles', sels.length)
@@ -229,65 +310,65 @@ async function onKeyProfiles (term, st, key) {
229
310
  // Entrar a la bóveda: la activa (si no lo estaba ya) y pasa a sus pestañas
230
311
  // (Dispositivos/Scopes) — así siempre es explícito de qué bóveda son los ítems.
231
312
  if (!cur.current) {
232
- const r = await guard(term, st, 'Cambiando de bóveda…', () => vc.useProfile(cur.id))
313
+ const r = await guard(term, st, i.switchingVault, () => vc.useProfile(cur.id))
233
314
  if (!r.ok) return true
234
- flash(st, `Bóveda activa: ${cur.name || cur.id}`)
315
+ flash(st, i.vaultNowActive(cur.name || cur.id))
235
316
  await refreshAll(term, st)
236
317
  }
237
318
  st.screen = 'devices'
238
319
  await refreshDevices(term, st)
239
320
  } else if (ch === 'n') {
240
321
  setInput(st, {
241
- label: 'Nombre de la nueva bóveda',
242
- hint: 'crea una identidad nueva y vacía',
322
+ label: i.newVaultLabel,
323
+ hint: i.newVaultHint,
243
324
  onSubmit: async (name) => {
244
325
  st.input = null
245
- if (!name.trim()) { flash(st, 'El nombre no puede estar vacío', 'danger'); return }
246
- const r = await guard(term, st, 'Creando bóveda…', () => vc.addProfile(name.trim()))
247
- if (r.ok) { flash(st, `Bóveda creada: ${name.trim()}`); await refreshProfiles(term, st) }
326
+ if (!name.trim()) { flash(st, i.nameEmpty, 'danger'); return }
327
+ const r = await guard(term, st, i.creatingVault, () => vc.addProfile(name.trim()))
328
+ if (r.ok) { flash(st, i.vaultCreated(name.trim())); await refreshProfiles(term, st) }
248
329
  },
249
330
  onCancel: () => { st.input = null }
250
331
  })
251
332
  } else if (ch === 'r' && cur) {
252
333
  await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
253
- label: `Nuevo nombre para "${p.name || p.id}"`,
334
+ label: i.renameLabel(p.name || p.id),
254
335
  value: p.name || '',
255
336
  onSubmit: async (name) => {
256
337
  st.input = null
257
- if (!name.trim()) { flash(st, 'El nombre no puede estar vacío', 'danger'); return }
258
- const r = await guard(term, st, 'Renombrando…', () => vc.renameProfile(p.id, name.trim()))
259
- if (r.ok) { flash(st, 'Bóveda renombrada'); await refreshProfiles(term, st) }
338
+ if (!name.trim()) { flash(st, i.nameEmpty, 'danger'); return }
339
+ const r = await guard(term, st, i.renaming, () => vc.renameProfile(p.id, name.trim()))
340
+ if (r.ok) { flash(st, i.vaultRenamed); await refreshProfiles(term, st) }
260
341
  },
261
342
  onCancel: () => { st.input = null }
262
343
  }))
263
344
  } else if ((key.name === 'delete' || ch === 'd') && cur) {
264
- if ((st.profiles.profiles || []).length <= 1) { flash(st, 'No se puede borrar la única bóveda', 'danger'); return true }
345
+ if ((st.profiles.profiles || []).length <= 1) { flash(st, i.cantDeleteLast, 'danger'); return true }
265
346
  await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
266
- label: `Escribe "${p.name || p.id}" para BORRARLA (irreversible)`,
267
- hint: 'se pierde su clave; sus dispositivos dejan de funcionar',
347
+ label: i.deleteLabel(p.name || p.id),
348
+ hint: i.deleteHint,
268
349
  onSubmit: async (typed) => {
269
350
  st.input = null
270
- if (typed.trim() !== (p.name || p.id)) { flash(st, 'Cancelado (el nombre no coincide)', 'warn'); return }
271
- const r = await guard(term, st, 'Borrando bóveda…', () => vc.removeProfile(p.id))
272
- if (r.ok) { flash(st, 'Bóveda borrada'); st.sel.profiles = 0; await refreshAll(term, st) }
351
+ if (typed.trim() !== (p.name || p.id)) { flash(st, i.deleteMismatch, 'warn'); return }
352
+ const r = await guard(term, st, i.deletingVault, () => vc.removeProfile(p.id))
353
+ if (r.ok) { flash(st, i.vaultDeleted); st.sel.profiles = 0; await refreshAll(term, st) }
273
354
  },
274
355
  onCancel: () => { st.input = null }
275
356
  }))
276
- } else if (ch === 'k' && cur) {
357
+ } else if (ch === 'p' && cur) { // password
277
358
  await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
278
- label: `Contraseña nueva para "${p.name || p.id}" (mín. 4)`,
359
+ label: i.newPasswordLabel(p.name || p.id),
279
360
  mask: true,
280
361
  onSubmit: async (pwd) => {
281
362
  st.input = null
282
- if (pwd.length < 4) { flash(st, 'La contraseña debe tener al menos 4 caracteres', 'danger'); return }
363
+ if (pwd.length < 4) { flash(st, i.passwordTooShort, 'danger'); return }
283
364
  setInput(st, {
284
- label: 'Repite la contraseña',
365
+ label: i.repeatPassword,
285
366
  mask: true,
286
367
  onSubmit: async (again) => {
287
368
  st.input = null
288
- if (again !== pwd) { flash(st, 'Las contraseñas no coinciden', 'danger'); return }
289
- const r = await guard(term, st, 'Guardando contraseña…', () => vc.setProfilePassword(p.id, pwd))
290
- if (r.ok) { flash(st, 'Contraseña guardada'); await refreshProfiles(term, st) }
369
+ if (again !== pwd) { flash(st, i.passwordMismatch, 'danger'); return }
370
+ const r = await guard(term, st, i.savingPassword, () => vc.setProfilePassword(p.id, pwd))
371
+ if (r.ok) { flash(st, i.passwordSaved); await refreshProfiles(term, st) }
291
372
  },
292
373
  onCancel: () => { st.input = null }
293
374
  })
@@ -295,24 +376,25 @@ async function onKeyProfiles (term, st, key) {
295
376
  onCancel: () => { st.input = null }
296
377
  }))
297
378
  } else if (ch === 'x' && cur) { // quitar contraseña
298
- if (!cur.protected) { flash(st, 'Esta bóveda no tiene contraseña', 'warn'); return true }
379
+ if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
299
380
  await ensureUnlocked(term, st, cur, async (p = cur) => {
300
- const r = await guard(term, st, 'Quitando contraseña…', () => vc.removeProfilePassword(p.id))
301
- if (r.ok) { flash(st, 'Contraseña quitada'); await refreshProfiles(term, st) }
381
+ const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id))
382
+ if (r.ok) { flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
302
383
  })
303
384
  } else if (ch === 'u' && cur) {
304
- if (!cur.protected) { flash(st, 'Esta bóveda no tiene contraseña', 'warn'); return true }
305
- if (!cur.locked) { flash(st, 'Ya está desbloqueada', 'warn'); return true }
306
- await ensureUnlocked(term, st, cur, async () => { flash(st, 'Bóveda desbloqueada'); await refreshProfiles(term, st) })
307
- } else if (ch === 'l' && cur) {
308
- if (!cur.protected) { flash(st, 'Esta bóveda no tiene contraseña', 'warn'); return true }
309
- const r = await guard(term, st, 'Bloqueando…', () => vc.lockProfile(cur.id))
310
- if (r.ok) { flash(st, 'Bóveda bloqueada'); await refreshProfiles(term, st) }
385
+ if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
386
+ if (!cur.locked) { flash(st, i.alreadyUnlocked, 'warn'); return true }
387
+ await ensureUnlocked(term, st, cur, async () => { flash(st, i.vaultUnlocked); await refreshProfiles(term, st) })
388
+ } else if (ch === 'k' && cur) { // locK (antes `l`, que ahora es el idioma)
389
+ if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
390
+ const r = await guard(term, st, i.lockingVault, () => vc.lockProfile(cur.id))
391
+ if (r.ok) { flash(st, i.vaultLocked); await refreshProfiles(term, st) }
311
392
  }
312
393
  return true
313
394
  }
314
395
 
315
396
  async function onKeyDevices (term, st, key) {
397
+ const i = L(st)
316
398
  // Sondea el dispositivo pendiente en cada tick (uno puede conectarse mientras
317
399
  // estás en esta pantalla, no solo en la de emparejamiento).
318
400
  if (key.name === 'tick') { st.pending = vc.pendingEnroll(); return true }
@@ -323,23 +405,24 @@ async function onKeyDevices (term, st, key) {
323
405
  const cur = sels[Math.min(st.sel.devices, sels.length - 1)]
324
406
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
325
407
 
326
- if (ch === 'e') {
327
- const r = await guard(term, st, 'Iniciando emparejamiento…', () => vc.startPairing({ profile: activeId(st) }))
328
- if (r.ok) { st.pairing = r.v; st.pending = null; st.screen = 'pairing' }
408
+ if (ch === 'p') { // pair → primero LA PREGUNTA (a qué cuenta entra), luego el QR
409
+ st.sel.pairmode = 0
410
+ st.scroll.pairmode = { value: 0 }
411
+ st.screen = 'pairmode'
329
412
  } else if (ch === 'a') { // aprobar el pendiente
330
- if (!st.pending) { flash(st, 'No hay ningún dispositivo pendiente', 'warn'); return true }
413
+ if (!st.pending) { flash(st, i.noPending, 'warn'); return true }
331
414
  promptApprove(term, st)
332
415
  } else if (ch === 'x') { // rechazar el pendiente
333
- if (!st.pending) { flash(st, 'No hay ningún dispositivo pendiente para rechazar', 'warn'); return true }
334
- const r = await guard(term, st, 'Rechazando…', () => vc.rejectPending(st.pending.deviceId, activeId(st)))
335
- if (r.ok) { flash(st, 'Dispositivo rechazado'); st.pending = null }
416
+ if (!st.pending) { flash(st, i.noPendingToReject, 'warn'); return true }
417
+ const r = await guard(term, st, i.rejecting, () => vc.rejectPending(st.pending.deviceId, activeId(st)))
418
+ if (r.ok) { flash(st, i.deviceRejected); st.pending = null }
336
419
  } else if ((ch === 'v' || key.name === 'delete') && cur?.nonce != null) { // revocar el enrolado seleccionado
337
420
  setConfirm(st, {
338
- text: `¿Revocar ${cur.deviceId}? Se le ordena autoborrarse al reconectar.`,
421
+ text: i.revokeConfirm(cur.deviceId),
339
422
  onYes: async () => {
340
423
  st.confirm = null
341
- const r = await guard(term, st, 'Revocando…', () => vc.revokeDevice(cur.nonce, activeId(st)))
342
- if (r.ok) { flash(st, `Revocado ${cur.deviceId}`); st.devices = r.v; st.sel.devices = 0 }
424
+ const r = await guard(term, st, i.revoking, () => vc.revokeDevice(cur.nonce, activeId(st)))
425
+ if (r.ok) { flash(st, i.deviceRevoked(cur.deviceId)); st.devices = r.v; st.sel.devices = 0 }
343
426
  },
344
427
  onNo: () => { st.confirm = null }
345
428
  })
@@ -349,21 +432,67 @@ async function onKeyDevices (term, st, key) {
349
432
  return true
350
433
  }
351
434
 
435
+ /** Abre el emparejamiento contra `profile` y salta a la pantalla del QR. */
436
+ async function beginPairing (term, st, profile) {
437
+ const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile }))
438
+ if (r.ok) { st.pairing = r.v; st.pending = null; st.screen = 'pairing' }
439
+ return r.ok
440
+ }
441
+
442
+ async function onKeyPairMode (term, st, key) {
443
+ const i = L(st)
444
+ const rows = pairModeRows(st, term.t)
445
+ const sels = rows.filter((r) => r.sel).map((r) => r.meta)
446
+ moveSel(st, key, 'pairmode', sels.length)
447
+ const cur = sels[Math.min(st.sel.pairmode, sels.length - 1)]
448
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
449
+
450
+ if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; return true }
451
+ if (key.name !== 'enter' || !cur) return true
452
+
453
+ if (cur.mode === 'here') { await beginPairing(term, st, activeId(st)); return true }
454
+
455
+ // Cuenta nueva: se crea aquí, se ACTIVA (así aprobar/rechazar y las listas miran
456
+ // a la misma que el QR) y recién entonces se abre el emparejamiento contra ella.
457
+ setInput(st, {
458
+ label: i.newAccountLabel,
459
+ hint: i.newAccountHint,
460
+ onSubmit: async (name) => {
461
+ st.input = null
462
+ const nombre = name.trim()
463
+ if (!nombre) { flash(st, i.nameEmpty, 'danger'); return }
464
+ const r = await guard(term, st, i.creatingVault, () => vc.addProfile(nombre))
465
+ if (!r.ok) return
466
+ const nuevo = r.v?.id || (r.v?.profiles || []).find((p) => p.name === nombre)?.id
467
+ if (!nuevo) { flash(st, i.errNoReply, 'danger'); return }
468
+ const u = await guard(term, st, i.switchingVault, () => vc.useProfile(nuevo))
469
+ if (!u.ok) return
470
+ await refreshAll(term, st)
471
+ flash(st, i.accountCreated(nombre))
472
+ await beginPairing(term, st, nuevo)
473
+ },
474
+ onCancel: () => { st.input = null }
475
+ })
476
+ return true
477
+ }
478
+
352
479
  function promptApprove (term, st) {
480
+ const i = L(st)
353
481
  setInput(st, {
354
- label: `Código que MUESTRA el dispositivo ${st.pending?.deviceId || ''}`,
355
- hint: 'el vault no lo conoce: compáralo en la otra pantalla',
482
+ label: i.approveLabel(st.pending?.deviceId || ''),
483
+ hint: i.approveHint,
356
484
  onSubmit: async (code) => {
357
485
  st.input = null
358
- if (!code.trim()) { flash(st, 'Falta el código', 'danger'); return }
359
- const r = await guard(term, st, 'Aprobando…', () => vc.approvePending(code.trim(), activeId(st)))
360
- if (r.ok) { flash(st, 'Dispositivo aprobado'); st.devices = r.v; st.pending = null; st.screen = 'devices' }
486
+ if (!code.trim()) { flash(st, i.codeMissing, 'danger'); return }
487
+ const r = await guard(term, st, i.approving, () => vc.approvePending(code.trim(), activeId(st)))
488
+ if (r.ok) { flash(st, i.deviceApproved); st.devices = r.v; st.pending = null; st.screen = 'devices' }
361
489
  },
362
490
  onCancel: () => { st.input = null }
363
491
  })
364
492
  }
365
493
 
366
494
  async function onKeyPairing (term, st, key) {
495
+ const i = L(st)
367
496
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
368
497
  if (key.name === 'tick') {
369
498
  const pend = vc.pendingEnroll()
@@ -372,12 +501,12 @@ async function onKeyPairing (term, st, key) {
372
501
  }
373
502
  if (ch === 'a' && st.pending) { promptApprove(term, st); return true }
374
503
  if (ch === 'x' && st.pending) {
375
- const r = await guard(term, st, 'Rechazando…', () => vc.rejectPending(st.pending.deviceId, activeId(st)))
376
- if (r.ok) { flash(st, 'Dispositivo rechazado'); st.pending = null }
504
+ const r = await guard(term, st, i.rejecting, () => vc.rejectPending(st.pending.deviceId, activeId(st)))
505
+ if (r.ok) { flash(st, i.deviceRejected); st.pending = null }
377
506
  return true
378
507
  }
379
- if (ch === 'e') { // reiniciar emparejamiento
380
- const r = await guard(term, st, 'Reiniciando emparejamiento…', () => vc.startPairing({ profile: activeId(st) }))
508
+ if (ch === 'r') { // restart: reiniciar el emparejamiento
509
+ const r = await guard(term, st, i.restartingPairing, () => vc.startPairing({ profile: activeId(st) }))
381
510
  if (r.ok) { st.pairing = r.v; st.pending = null }
382
511
  return true
383
512
  }
@@ -386,6 +515,7 @@ async function onKeyPairing (term, st, key) {
386
515
  }
387
516
 
388
517
  async function onKeySecrets (term, st, key) {
518
+ const i = L(st)
389
519
  const rows = secretRows(st, term.t)
390
520
  const sels = rows.filter((r) => r.sel).map((r) => r.meta)
391
521
  moveSel(st, key, 'secrets', sels.length)
@@ -397,22 +527,22 @@ async function onKeySecrets (term, st, key) {
397
527
  } else if ((ch === 'x' || key.name === 'delete') && cur) {
398
528
  if (cur.key) {
399
529
  setConfirm(st, {
400
- text: `¿Quitar la variable ${cur.ns}/${cur.key}?`,
530
+ text: i.removeVarConfirm(cur.ns, cur.key),
401
531
  onYes: async () => {
402
532
  st.confirm = null
403
- const r = await guard(term, st, 'Quitando variable…', () => vc.deleteSecret(cur.ns, cur.key, activeId(st)))
404
- if (r.ok) { flash(st, 'Variable quitada'); st.secrets = r.v; st.sel.secrets = Math.max(0, st.sel.secrets - 1) }
533
+ const r = await guard(term, st, i.removingVar, () => vc.deleteSecret(cur.ns, cur.key, activeId(st)))
534
+ if (r.ok) { flash(st, i.varRemoved); st.secrets = r.v; st.sel.secrets = Math.max(0, st.sel.secrets - 1) }
405
535
  },
406
536
  onNo: () => { st.confirm = null }
407
537
  })
408
538
  } else {
409
539
  const count = (st.secrets?.[cur.ns] || []).length
410
540
  setConfirm(st, {
411
- text: `¿Quitar el scope "${cur.ns}" ENTERO (${count} variable(s))?`,
541
+ text: i.removeScopeConfirm(cur.ns, count),
412
542
  onYes: async () => {
413
543
  st.confirm = null
414
- const r = await guard(term, st, 'Quitando scope…', () => vc.deleteScope(cur.ns, activeId(st)))
415
- if (r.ok) { flash(st, `Scope "${cur.ns}" quitado`); st.secrets = r.v; st.sel.secrets = 0 }
544
+ const r = await guard(term, st, i.removingScope, () => vc.deleteScope(cur.ns, activeId(st)))
545
+ if (r.ok) { flash(st, i.scopeRemoved(cur.ns)); st.secrets = r.v; st.sel.secrets = 0 }
416
546
  },
417
547
  onNo: () => { st.confirm = null }
418
548
  })
@@ -424,30 +554,31 @@ async function onKeySecrets (term, st, key) {
424
554
  }
425
555
 
426
556
  function promptNewVariable (term, st) {
557
+ const i = L(st)
427
558
  const existing = Object.keys(st.secrets || {})
428
559
  setInput(st, {
429
- label: 'Scope (namespace del servicio)',
430
- hint: existing.length ? `[a-z0-9-] · existen: ${existing.join(', ')}` : '[a-z0-9-], p. ej. proxy',
560
+ label: i.nsLabel,
561
+ hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
431
562
  onSubmit: (ns) => {
432
563
  const nsv = ns.trim()
433
- if (!NS_RE.test(nsv)) { flash(st, 'Scope inválido: usa [a-z0-9-]{1,32}', 'danger'); promptNewVariable(term, st); return }
564
+ if (!NS_RE.test(nsv)) { flash(st, i.nsInvalid, 'danger'); promptNewVariable(term, st); return }
434
565
  st.input = null
435
566
  setInput(st, {
436
- label: `Variable en "${nsv}" (MAYUSCULAS_CON_GUION_BAJO)`,
437
- hint: '[A-Z0-9_], p. ej. TURN_KEY_ID',
567
+ label: i.keyLabel(nsv),
568
+ hint: i.keyHint,
438
569
  onSubmit: (key) => {
439
570
  const kv = key.trim()
440
- if (!KEY_RE.test(kv)) { flash(st, 'Clave inválida: usa [A-Z0-9_]{1,64}', 'danger'); return }
571
+ if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
441
572
  st.input = null
442
573
  setInput(st, {
443
- label: `Valor de ${nsv}/${kv}`,
574
+ label: i.valueLabel(nsv, kv),
444
575
  mask: true,
445
- hint: 'el valor nunca se muestra; se guarda en la bóveda',
576
+ hint: i.valueHint,
446
577
  onSubmit: async (value) => {
447
578
  st.input = null
448
- if (!value) { flash(st, 'El valor no puede estar vacío', 'danger'); return }
449
- const r = await guard(term, st, 'Guardando variable…', () => vc.setSecret(nsv, kv, value, activeId(st)))
450
- if (r.ok) { flash(st, `Guardado ${nsv}/${kv}`); st.secrets = r.v }
579
+ if (!value) { flash(st, i.valueEmpty, 'danger'); return }
580
+ const r = await guard(term, st, i.savingVar, () => vc.setSecret(nsv, kv, value, activeId(st)))
581
+ if (r.ok) { flash(st, i.varSaved(nsv, kv)); st.secrets = r.v }
451
582
  },
452
583
  onCancel: () => { st.input = null }
453
584
  })
@@ -475,6 +606,8 @@ async function onInputKey (st, key) {
475
606
  async function onConfirmKey (st, key) {
476
607
  const cf = st.confirm
477
608
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
609
+ // La tecla es `y` (yes) en los dos idiomas —como el resto, mnemónico inglés—;
610
+ // `s` (sí) se sigue aceptando por costumbre, pero no se anuncia en la ayuda.
478
611
  if (ch === 's' || ch === 'y') { const f = cf.onYes; st.confirm = null; await f?.() }
479
612
  else if (ch === 'n' || key.name === 'escape' || key.name === 'enter' || key.name === 'ctrl-c') { const f = cf.onNo; st.confirm = null; await f?.() }
480
613
  }
@@ -484,62 +617,74 @@ async function onConfirmKey (st, key) {
484
617
  // Pestañas INTERNAS de una bóveda ya elegida: se cambian con ←→. La lista de
485
618
  // bóvedas (profiles) es el nivel de arriba (se entra con Enter, no es una pestaña).
486
619
  const INNER_TABS = ['devices', 'secrets']
487
- const TAB_LABEL = { devices: 'Dispositivos', secrets: 'Scopes y variables' }
488
-
489
- const HELP = {
490
- profiles: '↑↓ · Enter entrar · n nueva · r renombrar · d borrar · k clave · x quitar-clave · u desbloq · l bloq · q salir',
491
- devices: '←→ pestaña · ↑↓ · e emparejar · a aprobar · x rechazar · v revocar · r refrescar · Esc bóvedas · q salir',
492
- secrets: '←→ pestaña · ↑↓ · n nueva variable · x quitar (variable/scope) · r refrescar · Esc bóvedas · q salir',
493
- pairing: 'a aprobar · x rechazar · e reiniciar · Esc atrás'
494
- }
495
- const TITLE = {
496
- profiles: 'Bóvedas',
497
- pairing: 'Emparejar un dispositivo'
498
- }
620
+ const tabLabel = (i, k) => (k === 'devices' ? i.tabDevices : i.tabSecrets)
621
+
622
+ const helpSegs = (i, screen) => ({
623
+ profiles: i.helpProfiles,
624
+ devices: i.helpDevices,
625
+ secrets: i.helpSecrets,
626
+ pairing: i.helpPairing,
627
+ pairmode: i.helpPairMode
628
+ })[screen] || []
629
+
630
+ const title = (i, screen) => ({
631
+ profiles: i.titleProfiles,
632
+ pairing: i.titlePairing,
633
+ pairmode: i.titlePairMode
634
+ })[screen] || ''
499
635
 
500
636
  /** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
501
637
  function renderTabs (st, t) {
638
+ const i = L(st)
502
639
  return INNER_TABS.map((k) => {
503
640
  const active = st.screen === k
504
- return active ? t.bold(t.accent('▐ ' + TAB_LABEL[k] + ' ▌')) : t.muted(' ' + TAB_LABEL[k] + ' ')
505
- }).join(' ') + t.muted(' (←→ cambiar)')
641
+ return active ? t.bold(t.accent('▐ ' + tabLabel(i, k) + ' ▌')) : t.muted(' ' + tabLabel(i, k) + ' ')
642
+ }).join(' ') + t.muted(i.tabsHint)
506
643
  }
507
644
 
508
645
  function pairingBody (st, t, cols, height) {
646
+ const i = L(st)
509
647
  const info = st.pairing
510
648
  const lines = []
649
+ // QUÉ CUENTA se está compartiendo: el vault puede tener varias y el QR sale de
650
+ // UNA (la bóveda en la que entraste). Decirlo aquí evita enrolar un dispositivo
651
+ // en la cuenta equivocada sin enterarse.
652
+ const ap = activeProfile(st)
653
+ const acct = info.profileName || ap?.name || info.profile || ap?.id || '—'
654
+ lines.push(t.bold(i.pairAccount(acct)))
511
655
  const left = Math.max(0, Math.round((info.expiresAt - Date.now()) / 60000))
512
- lines.push(t.muted(`Válido ~${left} min. Escanéalo o abre la URL en el dispositivo.`))
656
+ lines.push(t.muted(i.pairValid(left)))
513
657
  lines.push('')
514
658
  // QR solo si entra cómodo (es "alto": ~ (módulos+8)/2 filas).
515
659
  let qr = ''
516
660
  try { qr = qrToString(info.url) } catch (_) {}
517
661
  const qrLines = qr ? qr.replace(/\n$/, '').split('\n') : []
518
662
  const qrWidth = qrLines.length ? Math.max(...qrLines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').length)) : 0
519
- const reserved = 8 // encabezado + URL + payload + aviso
663
+ const reserved = 9 // encabezado + cuenta + URL + payload + aviso
520
664
  if (qrLines.length && qrWidth <= cols && qrLines.length <= height - reserved) {
521
665
  for (const l of qrLines) lines.push(l)
522
666
  lines.push('')
523
667
  }
524
- lines.push(t.bold('URL: ') + info.url)
668
+ lines.push(t.bold(i.pairUrl) + info.url)
525
669
  lines.push('')
526
- lines.push(t.muted('O pega este código en la pestaña #vault de profile.dotrino.com:'))
670
+ lines.push(t.muted(i.pairPaste))
527
671
  lines.push(info.payload)
528
672
  lines.push('')
529
- lines.push(t.danger('⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.'))
673
+ lines.push(t.danger(i.pairWarning))
530
674
  lines.push('')
531
- if (st.pending) lines.push(t.warn(`⧗ Se conectó: ${st.pending.deviceId} — pulsa A y escribe el código que muestra.`))
532
- else lines.push(t.muted('Esperando a que el dispositivo se conecte…'))
675
+ if (st.pending) lines.push(t.warn(i.pairConnected(st.pending.deviceId)))
676
+ else lines.push(t.muted(i.pairWaiting))
533
677
  return lines
534
678
  }
535
679
 
536
680
  function render (term, st) {
537
681
  const t = term.t
682
+ const i = L(st)
538
683
  const { cols, rows } = term.size()
539
684
  // La distribución necesita: header+contexto (5) + 1 de contenido + estado + ayuda.
540
685
  // En un terminal más chico, en vez de escribir en índices fuera de rango, avisamos.
541
686
  if (rows < 9 || cols < 24) {
542
- term.render([t.warn('Terminal muy pequeño'), `Agranda a ≥ 24×9 (hay ${cols}×${rows}).`])
687
+ term.render([t.warn(i.tooSmall), i.tooSmallHint(cols, rows)])
543
688
  return
544
689
  }
545
690
  const lines = new Array(rows).fill('')
@@ -547,16 +692,16 @@ function render (term, st) {
547
692
  const s = st.state
548
693
  const up = st.daemonUp
549
694
  const ver = s?.version || 'dev'
550
- const daemonTxt = up ? 'corriendo' : 'DETENIDO'
695
+ const daemonTxt = up ? i.daemonRunning : i.daemonStopped
551
696
  lines[0] = t.bar(`dotrino-vault ${ver} daemon: ${daemonTxt} ${vc.vaultDir()}`, cols)
552
697
 
553
698
  const ap = activeProfile(st)
554
- const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || '(sin nombre)')} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
555
- lines[1] = ' Bóveda activa: ' + apTxt
699
+ const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || i.noName)} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
700
+ lines[1] = ' ' + i.activeVault + apTxt
556
701
  lines[2] = ''
557
702
  // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
558
703
  // el resto muestra su título simple.
559
- lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + (TITLE[st.screen] || ''))
704
+ lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
560
705
  lines[4] = ''
561
706
 
562
707
  const top = 5
@@ -568,12 +713,13 @@ function render (term, st) {
568
713
  if (st.screen === 'profiles') body = renderList(profileRows(st, t), st.sel.profiles, contentH, cols, t, scrollRef)
569
714
  else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
570
715
  else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
716
+ else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
571
717
  else if (st.screen === 'pairing') {
572
718
  const pb = pairingBody(st, t, cols, contentH)
573
719
  body = pb.slice(0, contentH)
574
720
  while (body.length < contentH) body.push('')
575
721
  }
576
- for (let i = 0; i < contentH; i++) lines[top + i] = body[i] ?? ''
722
+ for (let n = 0; n < contentH; n++) lines[top + n] = body[n] ?? ''
577
723
 
578
724
  // línea de estado: input / confirm / flash / busy
579
725
  const statusRow = rows - 2
@@ -584,7 +730,7 @@ function render (term, st) {
584
730
  const hint = inp.hint ? t.muted(' [' + inp.hint + ']') : ''
585
731
  lines[statusRow] = ' ' + t.bold(inp.label + ': ') + shown + t.accent('▏') + hint
586
732
  } else if (st.confirm) {
587
- lines[statusRow] = ' ' + t.warn(st.confirm.text) + t.muted(' (s / N)')
733
+ lines[statusRow] = ' ' + t.warn(st.confirm.text) + t.muted(i.confirmKeys)
588
734
  } else if (st.flash) {
589
735
  const kind = st.flash.kind
590
736
  const style = kind === 'danger' ? t.danger : kind === 'warn' ? t.warn : t.ok
@@ -592,9 +738,9 @@ function render (term, st) {
592
738
  } else lines[statusRow] = ''
593
739
 
594
740
  // barra de ayuda
595
- let help = HELP[st.screen] || ''
596
- if (st.input) help = 'Enter confirmar · Esc cancelar · Ctrl-U limpiar'
597
- else if (st.confirm) help = 's confirmar · n/Esc cancelar'
741
+ let help = fitHelp(helpSegs(i, st.screen), cols)
742
+ if (st.input) help = i.helpInput
743
+ else if (st.confirm) help = i.helpConfirm
598
744
  lines[rows - 1] = t.bar(help, cols)
599
745
 
600
746
  term.render(lines)
@@ -605,39 +751,42 @@ function render (term, st) {
605
751
  async function daemonDownScreen (term, st) {
606
752
  while (true) {
607
753
  const t = term.t
754
+ const i = L(st)
608
755
  const { cols, rows } = term.size()
609
756
  const lines = new Array(Math.max(rows, 2)).fill('')
610
757
  // Contenido en orden; se coloca desde la fila 2 y se corta si no cabe (no se
611
758
  // escribe nunca en índices fijos que se salgan de un terminal pequeño).
612
759
  const content = [
613
- t.danger('El daemon del vault no está corriendo.'),
760
+ t.danger(i.downTitle),
614
761
  '',
615
- 'La TUI le da órdenes al daemon (custodio de tu clave). Sin él no puede',
616
- 'crear bóvedas, listar dispositivos ni tocar secretos.',
762
+ i.downBody1,
763
+ i.downBody2,
617
764
  '',
618
- t.bold('S') + ' intentar arrancarlo: ' + t.muted('systemctl --user start dotrino-vault'),
619
- t.bold('R') + ' volver a comprobar',
620
- t.bold('Q') + ' salir',
765
+ t.bold('S') + i.downStart + t.muted('systemctl --user start dotrino-vault'),
766
+ t.bold('R') + i.downRecheck,
767
+ t.bold('l') + i.downLang,
768
+ t.bold('Q') + i.downQuit,
621
769
  '',
622
- t.muted('En desarrollo, arráncalo a mano: node bin/dotrino-vaultd.js')
770
+ t.muted(i.downDev)
623
771
  ]
624
772
  if (st.flash) content.push('', (st.flash.kind === 'danger' ? t.danger : t.warn)(st.flash.text))
625
- lines[0] = t.bar('dotrino-vault daemon: DETENIDO', cols)
626
- for (let i = 0; i < content.length && 2 + i < rows - 1; i++) lines[2 + i] = ' ' + content[i]
627
- lines[rows - 1] = t.bar('S arrancar · R comprobar · Q salir', cols)
773
+ lines[0] = t.bar(i.downHeader, cols)
774
+ for (let n = 0; n < content.length && 2 + n < rows - 1; n++) lines[2 + n] = ' ' + content[n]
775
+ lines[rows - 1] = t.bar(fitHelp(i.downHelp, cols), cols)
628
776
  term.render(lines)
629
777
 
630
778
  const key = await term.readKey()
631
779
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
632
780
  if (ch === 'q' || key.name === 'ctrl-c') return false
633
- if (ch === 'r') { if (vc.daemonAlive()) return true; flash(st, 'Sigue sin responder', 'warn') }
781
+ if (ch === 'l') { toggleLang(st); continue }
782
+ if (ch === 'r') { if (vc.daemonAlive()) return true; flash(st, L(st).stillDown, 'warn') }
634
783
  if (ch === 's') {
635
- st.busy = 'Arrancando el servicio…'; // (no re-render aquí; mensaje simple)
636
- flash(st, 'Arrancando…', 'warn'); term.render(lines)
784
+ st.busy = L(st).starting // (no re-render aquí; mensaje simple)
785
+ flash(st, L(st).startingShort, 'warn'); term.render(lines)
637
786
  const r = await startDaemonService()
638
787
  await sleep(1500)
639
788
  if (vc.daemonAlive()) return true
640
- flash(st, r.ok ? 'Arrancó pero aún no responde; pulsa R' : ('No se pudo arrancar: ' + r.err), 'danger')
789
+ flash(st, r.ok ? L(st).startedNotReady : L(st).startFailed(r.err), 'danger')
641
790
  st.busy = null
642
791
  }
643
792
  }
@@ -649,7 +798,8 @@ export async function runTui () {
649
798
  const term = createTerm()
650
799
  const st = {
651
800
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
652
- sel: { profiles: 0, devices: 0, secrets: 0 },
801
+ lang: loadLang(), // es/en se conmuta con `l` y se recuerda en prefs.json
802
+ sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0 },
653
803
  scroll: {},
654
804
  profiles: null,
655
805
  devices: null,
@@ -696,10 +846,12 @@ export async function runTui () {
696
846
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
697
847
  // 'q' global sale.
698
848
  if (ch === 'q') { running = false; continue }
849
+ // 'l' global: idioma es⇄en en cualquier pantalla (por eso el candado es 'c').
850
+ if (ch === 'l') { toggleLang(st); continue }
699
851
  // ←→ cambia entre las pestañas de la bóveda entrada (Dispositivos/Scopes).
700
852
  if ((key.name === 'left' || key.name === 'right') && INNER_TABS.includes(st.screen)) {
701
- const i = INNER_TABS.indexOf(st.screen)
702
- st.screen = INNER_TABS[(i + (key.name === 'right' ? 1 : -1) + INNER_TABS.length) % INNER_TABS.length]
853
+ const n = INNER_TABS.indexOf(st.screen)
854
+ st.screen = INNER_TABS[(n + (key.name === 'right' ? 1 : -1) + INNER_TABS.length) % INNER_TABS.length]
703
855
  continue
704
856
  }
705
857
  // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
@@ -711,6 +863,7 @@ export async function runTui () {
711
863
  if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
712
864
  else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
713
865
  else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
866
+ else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
714
867
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
715
868
  }
716
869
  } finally {
@@ -719,4 +872,4 @@ export async function runTui () {
719
872
  }
720
873
 
721
874
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
722
- export const __test = { render, profileRows, deviceRows, secretRows, pairingBody }
875
+ export const __test = { render, profileRows, deviceRows, secretRows, pairModeRows, pairingBody, fitHelp, toggleLang }