@agentprojectcontext/apx 1.42.1 → 1.43.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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/core/channels/telegram/api.js +62 -0
  3. package/src/core/channels/telegram/ask-callbacks.js +238 -0
  4. package/src/core/config/index.js +2 -0
  5. package/src/core/config/redact.js +2 -0
  6. package/src/core/confirmation/adapters/telegram.js +20 -37
  7. package/src/core/desktop/process.js +126 -0
  8. package/src/core/voice/stt-hardware.js +87 -0
  9. package/src/core/voice/stt-models.js +97 -0
  10. package/src/core/voice/transcription.js +147 -16
  11. package/src/host/daemon/api/desktop.js +54 -8
  12. package/src/host/daemon/api/transcribe.js +40 -1
  13. package/src/host/daemon/plugins/desktop/index.js +6 -1
  14. package/src/host/daemon/plugins/telegram/index.js +61 -351
  15. package/src/host/daemon/whisper-server.js +18 -8
  16. package/src/host/daemon/whisper-server.py +71 -44
  17. package/src/interfaces/cli/commands/desktop.js +13 -68
  18. package/src/interfaces/desktop/main.js +32 -4
  19. package/src/interfaces/desktop/renderer.js +26 -5
  20. package/src/interfaces/web/dist/assets/index-B0nTYflm.js +651 -0
  21. package/src/interfaces/web/dist/assets/index-B0nTYflm.js.map +1 -0
  22. package/src/interfaces/web/dist/assets/index-C22PmKCD.css +1 -0
  23. package/src/interfaces/web/dist/index.html +2 -2
  24. package/src/interfaces/web/package-lock.json +3 -3
  25. package/src/interfaces/web/src/components/ShortcutInput.tsx +156 -0
  26. package/src/interfaces/web/src/components/voice/VoiceSttCard.tsx +101 -5
  27. package/src/interfaces/web/src/i18n/en.ts +28 -2
  28. package/src/interfaces/web/src/i18n/es.ts +28 -2
  29. package/src/interfaces/web/src/lib/api/desktop.ts +28 -0
  30. package/src/interfaces/web/src/lib/api/voice.ts +26 -2
  31. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +55 -3
  32. package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +98 -36
  33. package/src/interfaces/web/dist/assets/index-BReF4_xV.js +0 -646
  34. package/src/interfaces/web/dist/assets/index-BReF4_xV.js.map +0 -1
  35. package/src/interfaces/web/dist/assets/index-wrEbTJbc.css +0 -1
@@ -1,11 +1,16 @@
1
- import { Field } from "../ui";
1
+ import { Field, Input } from "../ui";
2
2
  import { UiSelect } from "../UiSelect";
3
3
  import { WHISPER_MODELS, type TranscriptionConfig } from "../../lib/api/voice";
4
+ import { isSecretMarker, secretSuffix } from "../../lib/secrets";
4
5
  import { t } from "../../i18n";
5
6
 
6
7
  // STT (speech-to-text) configuration. Persisted under config.transcription.
7
- // The actual capture happens in the deck overlay / Telegram / CLI; here the
8
- // owner just picks the backend + (for local whisper) the model + language.
8
+ // The actual capture happens in the desktop window / Telegram / CLI; here the
9
+ // owner picks the engine and configures it:
10
+ // local — embedded faster-whisper (offline; model + language)
11
+ // openai — OpenAI cloud Whisper (api_key + model)
12
+ // custom — any OpenAI-compatible server: mlx-audio on this Mac's Metal GPU,
13
+ // a Radeon/NVIDIA box on the LAN, or a remote endpoint (base_url).
9
14
 
10
15
  interface Props {
11
16
  config: TranscriptionConfig;
@@ -17,6 +22,7 @@ const providerOptions = () => [
17
22
  { value: "auto", label: t("voice_ui.stt_provider_auto") },
18
23
  { value: "local", label: t("voice_ui.stt_provider_local") },
19
24
  { value: "openai", label: t("voice_ui.stt_provider_openai") },
25
+ { value: "custom", label: t("voice_ui.stt_provider_custom") },
20
26
  ];
21
27
 
22
28
  const langOptions = () => [
@@ -32,9 +38,30 @@ const langOptions = () => [
32
38
  export function VoiceSttCard({ config, onPatch, busy }: Props) {
33
39
  const provider = config.provider || "auto";
34
40
  const local = config.local || {};
41
+ const openai = config.openai || {};
42
+ const custom = config.custom || {};
35
43
  const model = local.model || "small";
36
44
  const language = local.language || "auto";
37
- const usesLocal = provider !== "openai";
45
+ // "auto" tries local first, so its tuning shares the local block.
46
+ const showLocal = provider === "auto" || provider === "local";
47
+
48
+ // Text fields patch on blur (not every keystroke), and only when changed.
49
+ const patchText = (key: string, prev: string | undefined, value: string) => {
50
+ const next = value.trim();
51
+ if (next === (prev || "").trim()) return;
52
+ onPatch({ [key]: next });
53
+ };
54
+ // Secrets: a blank field keeps the stored key; the daemon ignores redacted
55
+ // "*** set ***" markers, so we never echo one back as a real value.
56
+ const patchKey = (key: string, value: string) => {
57
+ const next = value.trim();
58
+ if (!next || isSecretMarker(next)) return;
59
+ onPatch({ [key]: next });
60
+ };
61
+ const keyPlaceholder = (marker: unknown) =>
62
+ isSecretMarker(marker)
63
+ ? t("voice_ui.api_key_set", { suffix: secretSuffix(marker) ?? "" })
64
+ : t("voice_ui.api_key_label");
38
65
 
39
66
  return (
40
67
  <div className="space-y-3">
@@ -48,7 +75,7 @@ export function VoiceSttCard({ config, onPatch, busy }: Props) {
48
75
  />
49
76
  </Field>
50
77
 
51
- {usesLocal && (
78
+ {showLocal && (
52
79
  <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
53
80
  <Field label={t("voice_ui.stt_model_label")} hint={t("voice_ui.stt_model_hint")}>
54
81
  <UiSelect
@@ -68,6 +95,75 @@ export function VoiceSttCard({ config, onPatch, busy }: Props) {
68
95
  </Field>
69
96
  </div>
70
97
  )}
98
+
99
+ {provider === "openai" && (
100
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
101
+ <Field
102
+ label={t("voice_ui.api_key_label")}
103
+ hint={isSecretMarker(openai.api_key)
104
+ ? t("voice_ui.api_key_keep_hint")
105
+ : t("voice_ui.api_key_reuse_hint", { engine: "engines.openai.api_key", env: "OPENAI_API_KEY" })}
106
+ >
107
+ <Input
108
+ type="password"
109
+ autoComplete="new-password"
110
+ defaultValue=""
111
+ placeholder={keyPlaceholder(openai.api_key)}
112
+ onBlur={(e) => patchKey("transcription.openai.api_key", e.target.value)}
113
+ disabled={busy}
114
+ />
115
+ </Field>
116
+ <Field label={t("voice_ui.stt_openai_model_label")} hint={t("voice_ui.stt_openai_model_hint")}>
117
+ <Input
118
+ defaultValue={openai.model || ""}
119
+ placeholder="whisper-1"
120
+ onBlur={(e) => patchText("transcription.openai.model", openai.model, e.target.value)}
121
+ disabled={busy}
122
+ />
123
+ </Field>
124
+ </div>
125
+ )}
126
+
127
+ {provider === "custom" && (
128
+ <div className="space-y-3">
129
+ <Field label={t("voice_ui.stt_custom_baseurl_label")} hint={t("voice_ui.stt_custom_baseurl_hint")}>
130
+ <Input
131
+ defaultValue={custom.base_url || ""}
132
+ placeholder="http://localhost:8000/v1"
133
+ onBlur={(e) => patchText("transcription.custom.base_url", custom.base_url, e.target.value)}
134
+ disabled={busy}
135
+ />
136
+ </Field>
137
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
138
+ <Field label={t("voice_ui.stt_custom_model_label")} hint={t("voice_ui.stt_custom_model_hint")}>
139
+ <Input
140
+ defaultValue={custom.model || ""}
141
+ placeholder="mlx-community/whisper-large-v3-turbo"
142
+ onBlur={(e) => patchText("transcription.custom.model", custom.model, e.target.value)}
143
+ disabled={busy}
144
+ />
145
+ </Field>
146
+ <Field label={t("voice_ui.stt_language_label")} hint={t("voice_ui.stt_language_hint")}>
147
+ <UiSelect
148
+ value={custom.language || "auto"}
149
+ onChange={(v) => onPatch({ "transcription.custom.language": v })}
150
+ options={langOptions()}
151
+ disabled={busy}
152
+ />
153
+ </Field>
154
+ </div>
155
+ <Field label={t("voice_ui.api_key_label")} hint={t("voice_ui.stt_custom_key_hint")}>
156
+ <Input
157
+ type="password"
158
+ autoComplete="new-password"
159
+ defaultValue=""
160
+ placeholder={keyPlaceholder(custom.api_key)}
161
+ onBlur={(e) => patchKey("transcription.custom.api_key", e.target.value)}
162
+ disabled={busy}
163
+ />
164
+ </Field>
165
+ </div>
166
+ )}
71
167
  </div>
72
168
  );
73
169
  }
@@ -940,6 +940,9 @@ export const en = {
940
940
  reload_manifest: "Reload manifest",
941
941
  widget_native: "Native APX widget",
942
942
  widget_external: "External widget",
943
+ preview_badge: "Preview",
944
+ preview_title: "Deck — Coming soon",
945
+ preview_body: "The Deck module is still in development and not released yet. We'll re-enable it once Deck ships in a stable release. For now everything here is read-only and no changes will be saved.",
943
946
  },
944
947
 
945
948
  memory_panel: {
@@ -1150,9 +1153,17 @@ export const en = {
1150
1153
  stt_model_hint: "Bigger = more accurate and slower.",
1151
1154
  stt_language_label: "Language",
1152
1155
  stt_language_hint: "For Spanish, setting \"Spanish\" improves accuracy.",
1153
- stt_provider_auto: "Automatic (local, then OpenAI)",
1156
+ stt_provider_auto: "Automatic (local, then remote)",
1154
1157
  stt_provider_local: "Local — faster-whisper (offline)",
1155
1158
  stt_provider_openai: "OpenAI — Whisper-1 (cloud)",
1159
+ stt_provider_custom: "Custom — OpenAI-compatible server",
1160
+ stt_openai_model_label: "OpenAI model",
1161
+ stt_openai_model_hint: "Defaults to whisper-1.",
1162
+ stt_custom_baseurl_label: "Base URL (OpenAI-compatible)",
1163
+ stt_custom_baseurl_hint: "e.g. http://localhost:8000/v1 (mlx-audio on Metal) or http://192.168.1.50:9000/v1 (Radeon/NVIDIA on the LAN).",
1164
+ stt_custom_model_label: "Model",
1165
+ stt_custom_model_hint: "e.g. mlx-community/whisper-large-v3-turbo or large-v3.",
1166
+ stt_custom_key_hint: "Optional — most local servers need no key.",
1156
1167
  lang_auto: "Auto-detect",
1157
1168
  lang_es: "Spanish",
1158
1169
  lang_en: "English",
@@ -1302,18 +1313,33 @@ export const en = {
1302
1313
  desktop_pos_left: "Left",
1303
1314
  desktop_pos_center: "Center",
1304
1315
  desktop_pos_right: "Right",
1316
+ desktop_theme_system: "System",
1305
1317
  desktop_theme_light: "Light",
1306
1318
  desktop_theme_dark: "Dark",
1307
1319
  desktop_status_desc: "The window launches from the terminal or via autostart.",
1308
1320
  desktop_running: "Running",
1309
1321
  desktop_stopped: "Stopped",
1310
1322
  desktop_refresh: "refresh",
1323
+ desktop_start: "Start",
1324
+ desktop_stop: "Stop",
1325
+ desktop_restart: "Restart",
1326
+ desktop_restart_hint: "Reload the open window so config changes (theme, position) apply now.",
1327
+ desktop_restart_done: "Restarting the window — applying the latest config.",
1328
+ desktop_restart_none: "No desktop window is connected.",
1329
+ desktop_start_done: "Desktop window launched.",
1330
+ desktop_start_already: "Desktop window is already running.",
1331
+ desktop_stop_done: "Desktop window stopped.",
1332
+ desktop_stop_none: "No desktop window was running.",
1311
1333
  desktop_from_terminal: "From terminal:",
1312
1334
  desktop_autostart_desc: "Launches the window at user login. Equivalent to `apx desktop install` (no sudo required).",
1313
1335
  desktop_platform: "platform: {platform}",
1314
1336
  desktop_shortcut_desc: "Global hotkey that shows/hides the window and starts listening.",
1315
1337
  desktop_accelerator: "Accelerator",
1316
- desktop_accelerator_hint:"Electron format, e.g. \"CommandOrControl+G\" or \"CommandOrControl+Shift+Space\". Restart the window to apply.",
1338
+ desktop_accelerator_hint:"Click the field and press your key combo. Restart the window to apply.",
1339
+ desktop_shortcut_record: "Click to set a shortcut",
1340
+ desktop_shortcut_recording: "Press your combo…",
1341
+ desktop_shortcut_change: "click to change",
1342
+ desktop_shortcut_esc: "Esc to cancel",
1317
1343
  desktop_shortcut_saved: "Shortcut saved. Restart the window (apx desktop stop && start) to apply it.",
1318
1344
  desktop_autostart_on: "Autostart enabled for the next login.",
1319
1345
  desktop_autostart_off: "Autostart disabled.",
@@ -938,6 +938,9 @@ export const es = {
938
938
  reload_manifest: "Recargar manifest",
939
939
  widget_native: "Widget nativo APX",
940
940
  widget_external: "Widget externo",
941
+ preview_badge: "Vista previa",
942
+ preview_title: "Deck — Próximamente",
943
+ preview_body: "El módulo Deck todavía está en desarrollo y no fue lanzado aún. Lo volveremos a activar cuando Deck salga en una versión estable. Por ahora todo acá es de solo lectura y no se guardará ningún cambio.",
941
944
  },
942
945
 
943
946
  memory_panel: {
@@ -1148,9 +1151,17 @@ export const es = {
1148
1151
  stt_model_hint: "Más grande = más preciso y más lento.",
1149
1152
  stt_language_label: "Idioma",
1150
1153
  stt_language_hint: "Para español, elegir \"Español\" mejora la precisión.",
1151
- stt_provider_auto: "Automático (local, después OpenAI)",
1154
+ stt_provider_auto: "Automático (local, después remoto)",
1152
1155
  stt_provider_local: "Local — faster-whisper (offline)",
1153
1156
  stt_provider_openai: "OpenAI — Whisper-1 (cloud)",
1157
+ stt_provider_custom: "Custom — server OpenAI-compatible",
1158
+ stt_openai_model_label: "Modelo OpenAI",
1159
+ stt_openai_model_hint: "Por defecto whisper-1.",
1160
+ stt_custom_baseurl_label: "URL base (OpenAI-compatible)",
1161
+ stt_custom_baseurl_hint: "Ej: http://localhost:8000/v1 (mlx-audio en Metal) o http://192.168.1.50:9000/v1 (Radeon/NVIDIA en la red).",
1162
+ stt_custom_model_label: "Modelo",
1163
+ stt_custom_model_hint: "Ej: mlx-community/whisper-large-v3-turbo o large-v3.",
1164
+ stt_custom_key_hint: "Opcional — la mayoría de los servers locales no requieren key.",
1154
1165
  lang_auto: "Detección automática",
1155
1166
  lang_es: "Español",
1156
1167
  lang_en: "Inglés",
@@ -1300,18 +1311,33 @@ export const es = {
1300
1311
  desktop_pos_left: "Izquierda",
1301
1312
  desktop_pos_center: "Centro",
1302
1313
  desktop_pos_right: "Derecha",
1314
+ desktop_theme_system: "Sistema",
1303
1315
  desktop_theme_light: "Claro",
1304
1316
  desktop_theme_dark: "Oscuro",
1305
1317
  desktop_status_desc: "La ventana se abre desde la terminal o por arranque automático.",
1306
1318
  desktop_running: "Corriendo",
1307
1319
  desktop_stopped: "Detenida",
1308
1320
  desktop_refresh: "refrescar",
1321
+ desktop_start: "Iniciar",
1322
+ desktop_stop: "Detener",
1323
+ desktop_restart: "Reiniciar",
1324
+ desktop_restart_hint: "Recarga la ventana abierta para aplicar ya los cambios de config (tema, posición).",
1325
+ desktop_restart_done: "Reiniciando la ventana — aplicando la última config.",
1326
+ desktop_restart_none: "No hay ninguna ventana de desktop conectada.",
1327
+ desktop_start_done: "Ventana de desktop iniciada.",
1328
+ desktop_start_already: "La ventana de desktop ya estaba corriendo.",
1329
+ desktop_stop_done: "Ventana de desktop detenida.",
1330
+ desktop_stop_none: "No había ninguna ventana de desktop corriendo.",
1309
1331
  desktop_from_terminal: "Desde la terminal:",
1310
1332
  desktop_autostart_desc: "Abre la ventana al iniciar sesión. Equivale a `apx desktop install` (no requiere sudo).",
1311
1333
  desktop_platform: "plataforma: {platform}",
1312
1334
  desktop_shortcut_desc: "Atajo global que muestra/oculta la ventana y empieza a escuchar.",
1313
1335
  desktop_accelerator: "Acelerador",
1314
- desktop_accelerator_hint:"Formato Electron, ej. \"CommandOrControl+G\" o \"CommandOrControl+Shift+Space\". Reiniciá la ventana para aplicar.",
1336
+ desktop_accelerator_hint:"Hacé clic en el campo y apretá tu combinación de teclas. Reiniciá la ventana para aplicar.",
1337
+ desktop_shortcut_record: "Hacé clic para definir un atajo",
1338
+ desktop_shortcut_recording: "Apretá tu combinación…",
1339
+ desktop_shortcut_change: "clic para cambiar",
1340
+ desktop_shortcut_esc: "Esc para cancelar",
1315
1341
  desktop_shortcut_saved: "Atajo guardado. Reiniciá la ventana (apx desktop stop && start) para aplicarlo.",
1316
1342
  desktop_autostart_on: "Arranque automático habilitado para el próximo inicio de sesión.",
1317
1343
  desktop_autostart_off: "Arranque automático deshabilitado.",
@@ -16,6 +16,25 @@ export interface DesktopStatus {
16
16
  running: boolean;
17
17
  }
18
18
 
19
+ export interface DesktopRestartResult {
20
+ ok: boolean;
21
+ /** How many connected windows were told to reload (0 = none connected). */
22
+ reloaded: number;
23
+ }
24
+
25
+ export interface DesktopStartResult {
26
+ ok: boolean;
27
+ pid?: number;
28
+ /** True when a window was already running (start was a no-op). */
29
+ already?: boolean;
30
+ }
31
+
32
+ export interface DesktopStopResult {
33
+ ok: boolean;
34
+ /** False when nothing was running. */
35
+ stopped?: boolean;
36
+ }
37
+
19
38
  export interface AutostartStatus {
20
39
  ok: boolean;
21
40
  enabled: boolean;
@@ -26,6 +45,15 @@ export const Desktop = {
26
45
  /** GET /desktop/status — connected window count + running flag (live probe). */
27
46
  status: () => http.get<DesktopStatus>("/desktop/status"),
28
47
 
48
+ /** POST /desktop/start — launch the floating window (detached Electron). */
49
+ start: () => http.post<DesktopStartResult>("/desktop/start", {}),
50
+
51
+ /** POST /desktop/stop — terminate the running window. */
52
+ stop: () => http.post<DesktopStopResult>("/desktop/stop", {}),
53
+
54
+ /** POST /desktop/restart — tell every live window to reload + re-read config. */
55
+ restart: () => http.post<DesktopRestartResult>("/desktop/restart", {}),
56
+
29
57
  /** GET /desktop/autostart — current login-item state for this platform. */
30
58
  autostartGet: () => http.get<AutostartStatus>("/desktop/autostart"),
31
59
 
@@ -99,9 +99,33 @@ export interface TranscriptionLocalConfig {
99
99
  beam_size?: number;
100
100
  idle_minutes?: number;
101
101
  }
102
+ export interface TranscriptionOpenAIConfig {
103
+ base_url?: string; // defaults to https://api.openai.com/v1
104
+ api_key?: string; // may carry a redacted "*** set ***" marker
105
+ model?: string; // defaults to whisper-1
106
+ }
107
+ export interface TranscriptionCustomConfig {
108
+ base_url?: string; // OpenAI-compatible server, e.g. http://localhost:8000/v1
109
+ api_key?: string; // optional; may carry a redacted marker
110
+ model?: string; // e.g. mlx-community/whisper-large-v3-turbo
111
+ language?: string; // ISO code or "auto"
112
+ }
102
113
  export interface TranscriptionConfig {
103
- provider?: string; // "auto" | "local" | "openai"
114
+ provider?: string; // "auto" | "local" | "openai" | "custom"
104
115
  local?: TranscriptionLocalConfig;
116
+ openai?: TranscriptionOpenAIConfig;
117
+ custom?: TranscriptionCustomConfig;
118
+ }
119
+
120
+ /** One STT engine entry as reported by GET /transcribe/providers. */
121
+ export interface SttProviderEntry {
122
+ id: string; // "local" | "openai" | "custom"
123
+ available: boolean;
124
+ configured: boolean;
125
+ }
126
+ export interface SttProvidersResponse {
127
+ configured_provider: string;
128
+ engines: SttProviderEntry[];
105
129
  }
106
130
 
107
131
  // Known engine voice presets used to fill selects without a daemon round-trip.
@@ -109,7 +133,7 @@ export const OPENAI_TTS_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shi
109
133
  export const GEMINI_TTS_VOICES = ["Kore", "Puck", "Charon", "Fenrir", "Aoede"];
110
134
  export const ELEVENLABS_MODELS = ["eleven_multilingual_v2", "eleven_turbo_v2_5", "eleven_flash_v2_5"];
111
135
  export const OPENAI_TTS_MODELS = ["tts-1", "tts-1-hd"];
112
- export const WHISPER_MODELS = ["tiny", "base", "small", "medium", "large-v2", "large-v3"];
136
+ export const WHISPER_MODELS = ["tiny", "base", "small", "medium", "large-v2", "large-v3", "large-v3-turbo"];
113
137
 
114
138
  // Friendly labels + ordering for the provider list. The daemon is the source
115
139
  // of truth for availability; this only adds display names + a stable order.
@@ -1,5 +1,5 @@
1
1
  import useSWR from "swr";
2
- import { RefreshCw } from "lucide-react";
2
+ import { RefreshCw, Construction } from "lucide-react";
3
3
  import { Deck } from "../../lib/api/deck";
4
4
  import { Section } from "../../components/Section";
5
5
  import { Button, Empty, Loading } from "../../components/ui";
@@ -21,7 +21,17 @@ export function DeckScreen() {
21
21
  { refreshInterval: 30_000 }
22
22
  );
23
23
 
24
+ // ── Deck is NOT released yet ───────────────────────────────────────────
25
+ // The whole module is shown behind a non-dismissable "coming soon" modal
26
+ // (see the overlay at the bottom of this component) and the UI underneath is
27
+ // made inert. As defense-in-depth, the real widget mutation is disabled here
28
+ // too: if someone forces the modal out of the DOM, toggling a widget does
29
+ // NOTHING — no request ever hits the daemon, so nothing breaks.
30
+ // 👉 Re-enable the body below (remove this guard) when Deck ships in a stable
31
+ // release. The original implementation is kept intact, just unreachable.
24
32
  const handleToggle = async (widgetId: string, enabled: boolean) => {
33
+ return; // no-op while Deck is pre-release — see note above.
34
+ // eslint-disable-next-line no-unreachable
25
35
  try {
26
36
  await Deck.setWidget(widgetId, { enabled });
27
37
  // Optimistically update local data so the switch flips immediately.
@@ -58,7 +68,11 @@ export function DeckScreen() {
58
68
  // Re-validate after a short delay to get the server's persisted state.
59
69
  setTimeout(() => mutate(), 800);
60
70
  } catch (e: unknown) {
61
- const msg = e instanceof Error ? e.message : t("modules_ui.deck_save_error");
71
+ // `e` reads as `unknown` here because the early `return` above makes this
72
+ // catch unreachable while Deck is pre-release — TS skips control-flow
73
+ // narrowing in dead code. Cast so the kept-for-later body still
74
+ // typechecks; behaviour is unchanged (this block never runs today).
75
+ const msg = e instanceof Error ? (e as Error).message : t("modules_ui.deck_save_error");
62
76
  toast.error(msg);
63
77
  }
64
78
  };
@@ -77,7 +91,18 @@ export function DeckScreen() {
77
91
  const enabledCount = externalWidgets.filter((w) => w.user_enabled === true).length;
78
92
 
79
93
  return (
80
- <div className="mx-auto max-w-4xl space-y-6 p-6" data-testid="screen-deck">
94
+ <div className="relative min-h-full" data-testid="screen-deck">
95
+ {/* ── Underlying Deck UI ──────────────────────────────────────────────
96
+ Shown for context only while Deck is pre-release. It is made fully
97
+ inert (no focus, no pointer events) and blurred behind the overlay
98
+ below. All real actions are also neutralized in code (see
99
+ handleToggle), so nothing runs even if this layer is re-enabled by
100
+ hand. Remove the `inert`/blur wrapper when Deck ships. */}
101
+ <div
102
+ className="mx-auto max-w-4xl space-y-6 p-6 pointer-events-none select-none blur-[2px] opacity-60"
103
+ aria-hidden
104
+ inert
105
+ >
81
106
  {/* Daemon info card */}
82
107
  {data && <DaemonCard manifest={data} />}
83
108
 
@@ -160,5 +185,32 @@ export function DeckScreen() {
160
185
  </Section>
161
186
  )}
162
187
  </div>
188
+
189
+ {/* ── "Coming soon" overlay ───────────────────────────────────────────
190
+ Non-dismissable: there is no close button and clicking the backdrop
191
+ does nothing. It floats over the inert Deck UI above. Remove this
192
+ whole block (and the inert wrapper / handleToggle guard) when Deck is
193
+ released. */}
194
+ <div
195
+ className="absolute inset-0 z-10 flex items-center justify-center p-6 backdrop-blur-[1px]"
196
+ role="dialog"
197
+ aria-modal="true"
198
+ aria-labelledby="deck-coming-soon-title"
199
+ data-testid="deck-coming-soon"
200
+ >
201
+ <div className="w-full max-w-md rounded-2xl border border-border bg-card/95 p-8 text-center shadow-2xl">
202
+ <div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-muted">
203
+ <Construction className="size-6 text-muted-fg" />
204
+ </div>
205
+ <span className="inline-block rounded-full bg-muted px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-fg">
206
+ {t("deck_screen.preview_badge")}
207
+ </span>
208
+ <h2 id="deck-coming-soon-title" className="mt-3 text-lg font-semibold">
209
+ {t("deck_screen.preview_title")}
210
+ </h2>
211
+ <p className="mt-2 text-sm text-muted-fg">{t("deck_screen.preview_body")}</p>
212
+ </div>
213
+ </div>
214
+ </div>
163
215
  );
164
216
  }
@@ -2,8 +2,9 @@ import { useEffect, useMemo, useState } from "react";
2
2
  import { Link } from "react-router-dom";
3
3
  import useSWR from "swr";
4
4
  import { Section, Kbd, StatusDot } from "../../components/Section";
5
- import { Button, Field, Input, Switch, Loading, Empty } from "../../components/ui";
5
+ import { Button, Field, Switch, Loading, Empty } from "../../components/ui";
6
6
  import { UiSelect } from "../../components/UiSelect";
7
+ import { ShortcutInput } from "../../components/ShortcutInput";
7
8
  import { useToast } from "../../components/Toast";
8
9
  import { useGlobalConfig } from "../../hooks/useGlobalConfig";
9
10
  import { Desktop, fetchDesktopMessages, type GlobalMessage } from "../../lib/api/desktop";
@@ -16,8 +17,9 @@ const positionOpts = () => [
16
17
  { value: "right", label: t("modules_ui.desktop_pos_right") },
17
18
  ];
18
19
  const themeOpts = () => [
19
- { value: "light", label: t("modules_ui.desktop_theme_light") },
20
- { value: "dark", label: t("modules_ui.desktop_theme_dark") },
20
+ { value: "system", label: t("modules_ui.desktop_theme_system") },
21
+ { value: "light", label: t("modules_ui.desktop_theme_light") },
22
+ { value: "dark", label: t("modules_ui.desktop_theme_dark") },
21
23
  ];
22
24
 
23
25
  // Desktop module — manage the floating voice window (the Electron app launched
@@ -32,14 +34,16 @@ export function DesktopScreen() {
32
34
  const cfgView = config as unknown as {
33
35
  desktop?: {
34
36
  shortcut?: string; enabled?: boolean;
35
- theme?: "light" | "dark";
37
+ theme?: "light" | "dark" | "system";
36
38
  position?: "left" | "center" | "right";
37
39
  };
38
40
  overlay?: { shortcut?: string }; // legacy fallback
39
41
  };
40
42
  const savedShortcut = cfgView.desktop?.shortcut || cfgView.overlay?.shortcut || DEFAULT_SHORTCUT;
41
43
  const enabled = cfgView.desktop?.enabled !== false;
42
- const theme = cfgView.desktop?.theme || "light";
44
+ // Default to "system" so the window follows the OS appearance until the
45
+ // user explicitly pins light/dark.
46
+ const theme = cfgView.desktop?.theme || "system";
43
47
  const position = cfgView.desktop?.position || "right";
44
48
 
45
49
  const { data: status, isLoading: stLoading, mutate: mutateStatus } = useSWR(
@@ -63,8 +67,35 @@ export function DesktopScreen() {
63
67
  const [shortcut, setShortcut] = useState(savedShortcut);
64
68
  const [busy, setBusy] = useState(false);
65
69
  const [autostartBusy, setAutostartBusy] = useState(false);
70
+ // Which lifecycle action (start/stop/restart) is in flight — drives the
71
+ // per-button spinner and disables its siblings while one runs.
72
+ const [lifeAction, setLifeAction] = useState<"start" | "stop" | "restart" | null>(null);
66
73
  useEffect(() => setShortcut(savedShortcut), [savedShortcut]);
67
74
 
75
+ // Start/Stop launch or kill the Electron window (daemon spawns/SIGTERMs it);
76
+ // Restart tells a live window to reload + re-read config (theme, position,
77
+ // shortcut) — the "apply now" the static status-poll never did. All three
78
+ // re-poll status shortly after so the dot + buttons settle.
79
+ const runLifecycle = async (action: "start" | "stop" | "restart", fn: () => Promise<void>) => {
80
+ setLifeAction(action);
81
+ try { await fn(); }
82
+ catch (e) { toast.error((e as Error).message); }
83
+ finally { setLifeAction(null); setTimeout(() => mutateStatus(), 1200); }
84
+ };
85
+ const startDesktop = () => runLifecycle("start", async () => {
86
+ const r = await Desktop.start();
87
+ toast.success(r.already ? t("modules_ui.desktop_start_already") : t("modules_ui.desktop_start_done"));
88
+ });
89
+ const stopDesktop = () => runLifecycle("stop", async () => {
90
+ const r = await Desktop.stop();
91
+ toast.success(r.stopped ? t("modules_ui.desktop_stop_done") : t("modules_ui.desktop_stop_none"));
92
+ });
93
+ const restartDesktop = () => runLifecycle("restart", async () => {
94
+ const r = await Desktop.restart();
95
+ if (r.reloaded > 0) toast.success(t("modules_ui.desktop_restart_done"));
96
+ else toast.info(t("modules_ui.desktop_restart_none"));
97
+ });
98
+
68
99
  const saveShortcut = async () => {
69
100
  const next = shortcut.trim();
70
101
  if (!next || next === savedShortcut) return;
@@ -101,23 +132,58 @@ export function DesktopScreen() {
101
132
  <div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
102
133
  {/* ── LEFT: configuration + status ─────────────────────────────── */}
103
134
  <div className="space-y-6">
104
- <Section title={t("desktop_screen.status_title")} description={t("modules_ui.desktop_status_desc")}>
135
+ <Section
136
+ title={t("desktop_screen.status_title")}
137
+ description={t("modules_ui.desktop_status_desc")}
138
+ action={
139
+ <div className="flex items-center gap-2">
140
+ <Button
141
+ variant="primary"
142
+ size="sm"
143
+ onClick={startDesktop}
144
+ loading={lifeAction === "start"}
145
+ disabled={running || (lifeAction !== null && lifeAction !== "start")}
146
+ >
147
+ {t("modules_ui.desktop_start")}
148
+ </Button>
149
+ <Button
150
+ variant="secondary"
151
+ size="sm"
152
+ onClick={stopDesktop}
153
+ loading={lifeAction === "stop"}
154
+ disabled={!running || (lifeAction !== null && lifeAction !== "stop")}
155
+ >
156
+ {t("modules_ui.desktop_stop")}
157
+ </Button>
158
+ <Button
159
+ variant="secondary"
160
+ size="sm"
161
+ onClick={restartDesktop}
162
+ loading={lifeAction === "restart"}
163
+ disabled={!running || (lifeAction !== null && lifeAction !== "restart")}
164
+ title={t("modules_ui.desktop_restart_hint")}
165
+ >
166
+ {t("modules_ui.desktop_restart")}
167
+ </Button>
168
+ </div>
169
+ }
170
+ >
105
171
  {stLoading ? <Loading /> : (
106
- <div className="flex items-center gap-2 text-sm">
172
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
107
173
  <StatusDot ok={running} />
108
174
  <span className="font-medium">{running ? t("modules_ui.desktop_running") : t("modules_ui.desktop_stopped")}</span>
109
175
  <button
110
176
  type="button"
111
177
  onClick={() => mutateStatus()}
112
- className="ml-2 text-xs text-muted-fg underline-offset-2 hover:underline"
178
+ className="text-xs text-muted-fg underline-offset-2 hover:underline"
113
179
  >
114
180
  {t("modules_ui.desktop_refresh")}
115
181
  </button>
182
+ <span className="text-xs text-muted-fg">
183
+ ({t("modules_ui.desktop_from_terminal")} <Kbd>apx desktop start</Kbd> · <Kbd>apx desktop --debug</Kbd>)
184
+ </span>
116
185
  </div>
117
186
  )}
118
- <p className="mt-3 text-xs text-muted-fg">
119
- {t("modules_ui.desktop_from_terminal")} <Kbd>apx desktop start</Kbd> · <Kbd>apx desktop --debug</Kbd>
120
- </p>
121
187
  </Section>
122
188
 
123
189
  <Section
@@ -142,31 +208,27 @@ export function DesktopScreen() {
142
208
  description={t("modules_ui.desktop_shortcut_desc")}
143
209
  >
144
210
  {cfgLoading ? <Loading /> : (
145
- <div className="flex items-end gap-3">
146
- <div className="flex-1">
147
- <Field
148
- label={t("modules_ui.desktop_accelerator")}
149
- hint={t("modules_ui.desktop_accelerator_hint")}
150
- >
151
- <Input
152
- value={shortcut}
153
- onChange={(e) => setShortcut(e.target.value)}
154
- onKeyDown={(e) => { if (e.key === "Enter") saveShortcut(); }}
155
- placeholder={DEFAULT_SHORTCUT}
156
- className="max-w-md font-mono"
157
- disabled={busy}
158
- />
159
- </Field>
160
- </div>
161
- <Button
162
- variant="primary"
163
- onClick={saveShortcut}
164
- loading={busy}
165
- disabled={!shortcut.trim() || shortcut.trim() === savedShortcut}
166
- >
167
- {t("common.save")}
168
- </Button>
169
- </div>
211
+ <Field
212
+ label={t("modules_ui.desktop_accelerator")}
213
+ hint={t("modules_ui.desktop_accelerator_hint")}
214
+ >
215
+ <ShortcutInput
216
+ value={shortcut}
217
+ onChange={setShortcut}
218
+ disabled={busy}
219
+ trailing={
220
+ <Button
221
+ variant="primary"
222
+ size="sm"
223
+ onClick={saveShortcut}
224
+ loading={busy}
225
+ disabled={!shortcut.trim() || shortcut.trim() === savedShortcut}
226
+ >
227
+ {t("common.save")}
228
+ </Button>
229
+ }
230
+ />
231
+ </Field>
170
232
  )}
171
233
  </Section>
172
234