kortalk 0.4.0__py3-none-any.whl

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.
kortalk/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """kortalk — отправка выделенного текста в ИИ (Claude, OpenAI, локальные модели).
2
+
3
+ GUI на PySide6: popup у курсора, окно в два столбца, иконка в трее,
4
+ графические настройки. Вдохновлено Crow Translate.
5
+ """
6
+
7
+ __version__ = "0.4.0"
kortalk/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
kortalk/app.py ADDED
@@ -0,0 +1,436 @@
1
+ """Точка входа kortalk: CLI, single-instance, трей, глобальные хоткеи.
2
+
3
+ Архитектура: `kortalk` без аргументов поднимает резидентное приложение
4
+ (трей + QLocalServer + глобальные хоткеи). Все действия — из трея и по
5
+ хоткеям, назначаемым в настройках. CLI-флаги (--popup и т.п.) оставлены
6
+ для скриптов: они доставляются работающему экземпляру через локальный
7
+ сокет и завершаются мгновенно.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import getpass
14
+ import json
15
+ import logging
16
+ import logging.handlers
17
+ import os
18
+ import shutil
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from PySide6.QtCore import QByteArray
23
+ from PySide6.QtGui import QAction, QClipboard, QGuiApplication
24
+ from PySide6.QtNetwork import QLocalServer, QLocalSocket
25
+ from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
26
+
27
+ from . import __version__, i18n, theme
28
+ from .config import Config
29
+ from .hotkeys import GlobalHotkeys
30
+ from .i18n import tr
31
+ from .providers import shutdown_workers
32
+ from .settings_dialog import SettingsDialog
33
+ from .windows import MainWindow, PopupWindow
34
+
35
+ SOCKET_NAME = f"kortalk-{getpass.getuser()}"
36
+ LOG_DIR = Path(os.environ.get("XDG_STATE_HOME",
37
+ str(Path.home() / ".local" / "state"))) / "kortalk"
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+
42
+ def setup_logging(debug: bool) -> None:
43
+ """Файл ~/.local/state/kortalk/kortalk.log всегда; stderr — при --debug."""
44
+ root = logging.getLogger("kortalk")
45
+ root.setLevel(logging.DEBUG if debug else logging.INFO)
46
+ fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
47
+ try:
48
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
49
+ file_handler = logging.handlers.RotatingFileHandler(
50
+ LOG_DIR / "kortalk.log", maxBytes=1_000_000, backupCount=2,
51
+ encoding="utf-8",
52
+ )
53
+ file_handler.setFormatter(fmt)
54
+ root.addHandler(file_handler)
55
+ except OSError:
56
+ pass # нет прав/места — работаем без файла
57
+ if debug:
58
+ stream_handler = logging.StreamHandler()
59
+ stream_handler.setFormatter(fmt)
60
+ root.addHandler(stream_handler)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # CLI
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def build_arg_parser() -> argparse.ArgumentParser:
68
+ parser = argparse.ArgumentParser(
69
+ prog="kortalk",
70
+ description="AI popup for selected text. With no arguments starts the tray "
71
+ "daemon; actions via the tray and global hotkeys "
72
+ "(configured in the app).",
73
+ )
74
+ parser.add_argument("prompt", nargs="?", default=None,
75
+ help="Prompt for a one-off popup (implies --popup).")
76
+ parser.add_argument("--popup", action="store_true",
77
+ help="Popup near the cursor with a response to the selected text.")
78
+ parser.add_argument("--window", "--split", action="store_true", dest="window",
79
+ help="Two-column window: text on the left, response on the right.")
80
+ parser.add_argument("--settings", action="store_true", help="Open settings.")
81
+ parser.add_argument("--provider", default=None,
82
+ help="Provider ID for this request (see Settings).")
83
+ parser.add_argument("--quit", action="store_true", dest="quit_",
84
+ help="Quit the running instance.")
85
+ parser.add_argument("--check", action="store_true", help="Environment diagnostics.")
86
+ parser.add_argument("--debug", action="store_true", help="Verbose log and full tracebacks.")
87
+ parser.add_argument("--version", action="version", version=f"kortalk {__version__}")
88
+ return parser
89
+
90
+
91
+ def args_to_command(args) -> dict:
92
+ if args.quit_:
93
+ action = "quit"
94
+ elif args.settings:
95
+ action = "settings"
96
+ elif args.window:
97
+ action = "window"
98
+ elif args.popup or args.prompt:
99
+ action = "popup"
100
+ else:
101
+ action = "daemon"
102
+ return {"action": action, "prompt": args.prompt, "provider": args.provider}
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # --check
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def run_selftest(config: Config) -> int:
110
+ ok = True
111
+
112
+ def report(label: str, passed: bool, remedy: str = "") -> None:
113
+ nonlocal ok
114
+ print(f"{'✅' if passed else '❌'} {label}")
115
+ if not passed:
116
+ ok = False
117
+ if remedy:
118
+ print(f" → {remedy}")
119
+
120
+ report(f"PySide6 (Qt {__import__('PySide6').QtCore.qVersion()})", True)
121
+
122
+ clipboard = QGuiApplication.clipboard()
123
+ report("PRIMARY selection (QClipboard)",
124
+ clipboard.supportsSelection(),
125
+ "the environment does not support PRIMARY selection")
126
+
127
+ report("System tray", QSystemTrayIcon.isSystemTrayAvailable(),
128
+ "tray unavailable — use the CLI flags (--popup, --window)")
129
+
130
+ platform = QGuiApplication.platformName()
131
+ print(f"ℹ️ Platform: {platform} → hotkey backend: "
132
+ + ("XGrabKey (X11)" if platform == "xcb" else "XDG portal GlobalShortcuts"))
133
+
134
+ for p in config.providers():
135
+ if p.type == "claude-cli":
136
+ report(f"provider “{p.name}”: claude CLI in PATH",
137
+ shutil.which("claude") is not None,
138
+ "install Claude Code CLI: https://docs.claude.com")
139
+ elif p.type == "anthropic":
140
+ report(f"provider “{p.name}”: API key set", bool(p.api_key),
141
+ "add the key: Settings → Providers")
142
+ elif p.type == "openai":
143
+ key_ok = bool(p.api_key) or not p.needs_api_key()
144
+ report(f"provider “{p.name}”: base URL and key",
145
+ bool(p.base_url) and key_ok,
146
+ "fill in the fields: Settings → Providers")
147
+
148
+ active = config.active_provider()
149
+ print(f"\nActive provider: {active.name} ({active.type}"
150
+ + (f", {active.model}" if active.model else "") + ")")
151
+ print(f"Hotkeys: popup={config.hotkey('popup') or '—'}, "
152
+ f"window={config.hotkey('window') or '—'}")
153
+ print(f"Settings file: {config.file_path()}")
154
+ print("All good." if ok else "Issues found, see the recommendations above.")
155
+ return 0 if ok else 1
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Single instance
160
+ # ---------------------------------------------------------------------------
161
+
162
+ def try_send_to_running(command: dict) -> bool:
163
+ """True, если работающий экземпляр найден и команда доставлена."""
164
+ socket = QLocalSocket()
165
+ socket.connectToServer(SOCKET_NAME)
166
+ if not socket.waitForConnected(300):
167
+ return False
168
+ socket.write(QByteArray(json.dumps(command).encode("utf-8")))
169
+ socket.flush()
170
+ socket.waitForBytesWritten(1000)
171
+ socket.disconnectFromServer()
172
+ return True
173
+
174
+
175
+ class KortalkApp:
176
+ """Резидентное приложение: трей + сервер команд + хоткеи + окна."""
177
+
178
+ def __init__(self, app: QApplication, config: Config):
179
+ self.app = app
180
+ self.config = config
181
+ self.popup: PopupWindow | None = None
182
+ self.main_window: MainWindow | None = None
183
+ self.settings_dialog: SettingsDialog | None = None
184
+
185
+ theme.apply_theme(app, str(config.get("theme")))
186
+ theme.apply_font(app, str(config.get("font_family")), int(config.get("font_size")))
187
+ app.setQuitOnLastWindowClosed(False)
188
+
189
+ self.server = QLocalServer()
190
+ QLocalServer.removeServer(SOCKET_NAME) # подчистить сокет после падения
191
+ self.server.listen(SOCKET_NAME)
192
+ self.server.newConnection.connect(self._on_connection)
193
+
194
+ # ВАЖНО: меню и трей храним в атрибутах — setContextMenu не берёт
195
+ # владение, и локальный QMenu был бы собран GC (краш по ПКМ).
196
+ self.tray = QSystemTrayIcon(theme.make_tray_icon())
197
+ self.menu = QMenu()
198
+ self.prompt_menu = QMenu(tr("Popup with prompt"))
199
+ self._rebuild_menu()
200
+ self.tray.setContextMenu(self.menu)
201
+ self.tray.activated.connect(self._tray_activated)
202
+ if QSystemTrayIcon.isSystemTrayAvailable():
203
+ self.tray.show()
204
+
205
+ self.hotkeys = GlobalHotkeys()
206
+ self.hotkeys.activated.connect(self._hotkey_activated)
207
+ self._apply_hotkeys()
208
+
209
+ # -- трей ----------------------------------------------------------------
210
+
211
+ def _rebuild_menu(self) -> None:
212
+ self.menu.clear()
213
+ self.menu.addAction(tr("Popup with selection"), lambda: self.handle({"action": "popup"}))
214
+
215
+ self.prompt_menu.clear()
216
+ active_name = self.config.active_prompt().name
217
+ for p in self.config.prompts():
218
+ title = f"● {p.name}" if p.name == active_name else p.name
219
+ action = QAction(title, self.prompt_menu)
220
+ action.triggered.connect(
221
+ lambda _checked=False, name=p.name: self.handle(
222
+ {"action": "popup", "prompt_name": name})
223
+ )
224
+ self.prompt_menu.addAction(action)
225
+ self.prompt_menu.setTitle(tr("Popup with prompt"))
226
+ self.menu.addMenu(self.prompt_menu)
227
+
228
+ self.menu.addAction(tr("Open window"), lambda: self.handle({"action": "window"}))
229
+ self.menu.addSeparator()
230
+ self.menu.addAction(tr("Settings"), self.open_settings)
231
+ self.menu.addSeparator()
232
+ self.menu.addAction(tr("Quit"), self.quit)
233
+ self._update_tooltip()
234
+
235
+ def _update_tooltip(self) -> None:
236
+ provider = self.config.active_provider()
237
+ lines = [f"kortalk — {provider.name}"]
238
+ if self.config.hotkey("popup"):
239
+ lines.append(f"Popup: {self.config.hotkey('popup')}")
240
+ if self.hotkeys_note():
241
+ lines.append(self.hotkeys_note())
242
+ self.tray.setToolTip("\n".join(lines))
243
+
244
+ def hotkeys_note(self) -> str:
245
+ if getattr(self, "hotkeys", None) and self.hotkeys.backend == "none" and self.hotkeys.error:
246
+ return tr("Hotkeys unavailable: {error}").format(error=self.hotkeys.error)
247
+ return ""
248
+
249
+ def _apply_hotkeys(self) -> None:
250
+ self.hotkeys.apply({
251
+ "popup": self.config.hotkey("popup"),
252
+ "window": self.config.hotkey("window"),
253
+ })
254
+ self._update_tooltip()
255
+
256
+ def _hotkey_activated(self, action: str) -> None:
257
+ self.handle({"action": action})
258
+
259
+ def _tray_activated(self, reason) -> None:
260
+ if reason == QSystemTrayIcon.ActivationReason.Trigger: # ЛКМ
261
+ self.handle({"action": "popup"})
262
+
263
+ # -- команды -----------------------------------------------------------
264
+
265
+ def handle(self, command: dict) -> None:
266
+ action = command.get("action", "daemon")
267
+ log.debug("command: %s", command)
268
+ if action == "quit":
269
+ self.quit()
270
+ elif action == "settings":
271
+ self.open_settings()
272
+ elif action == "window":
273
+ self.open_window(command)
274
+ elif action == "popup":
275
+ self.open_popup(command)
276
+ # прочее ("daemon", "noop") — просто живём дальше
277
+
278
+ def _selection_text(self) -> str:
279
+ clipboard = QGuiApplication.clipboard()
280
+ text = ""
281
+ if clipboard.supportsSelection():
282
+ text = clipboard.text(QClipboard.Mode.Selection)
283
+ return text.strip()
284
+
285
+ def _resolve(self, command: dict):
286
+ provider = None
287
+ if command.get("provider"):
288
+ provider = self.config.provider(command["provider"])
289
+ if provider is None:
290
+ provider = self.config.active_provider()
291
+
292
+ if command.get("prompt"):
293
+ prompt = command["prompt"]
294
+ elif command.get("prompt_name"):
295
+ named = self.config.prompt_by_name(command["prompt_name"])
296
+ prompt = named.text if named else self.config.active_prompt().text
297
+ else:
298
+ prompt = self.config.active_prompt().text
299
+
300
+ selection = self._selection_text()
301
+ full = f"{prompt}\n\n{selection}" if selection else prompt
302
+ return provider, full, selection
303
+
304
+ def open_popup(self, command: dict) -> None:
305
+ provider, full_prompt, _selection = self._resolve(command)
306
+ if self.popup is not None:
307
+ self.popup.close()
308
+ label = provider.name + (f" · {provider.model}" if provider.model else "")
309
+ popup = PopupWindow(self.config, label)
310
+ popup.open_in_window.connect(self._popup_to_window)
311
+ # отложенный destroyed старого popup не должен обнулять ссылку на новый
312
+ popup.destroyed.connect(lambda *_a, p=popup: self._popup_destroyed(p))
313
+ self.popup = popup
314
+ popup.show_near_cursor()
315
+ popup.ask(provider, full_prompt)
316
+
317
+ def _popup_destroyed(self, popup: PopupWindow) -> None:
318
+ if self.popup is popup:
319
+ self.popup = None
320
+
321
+ def open_window(self, command: dict) -> None:
322
+ _provider, full_prompt, selection = self._resolve(command)
323
+ window = self._ensure_main_window()
324
+ if selection or command.get("prompt"):
325
+ window.set_input(full_prompt)
326
+ window.show()
327
+ window.raise_()
328
+ window.activateWindow()
329
+
330
+ def _popup_to_window(self, answer: str) -> None:
331
+ window = self._ensure_main_window()
332
+ window.set_output(answer)
333
+ window.show()
334
+ window.raise_()
335
+ window.activateWindow()
336
+
337
+ def _ensure_main_window(self) -> MainWindow:
338
+ if self.main_window is None:
339
+ self.main_window = MainWindow(self.config)
340
+ self.main_window.settings_requested.connect(self.open_settings)
341
+ return self.main_window
342
+
343
+ def open_settings(self) -> None:
344
+ if self.settings_dialog is not None:
345
+ self.settings_dialog.raise_()
346
+ self.settings_dialog.activateWindow()
347
+ return
348
+ self.settings_dialog = SettingsDialog(self.config)
349
+ self.settings_dialog.saved.connect(self._settings_saved)
350
+ self.settings_dialog.finished.connect(
351
+ lambda _r: setattr(self, "settings_dialog", None)
352
+ )
353
+ self.settings_dialog.show()
354
+
355
+ def _settings_saved(self) -> None:
356
+ i18n.set_language(str(self.config.get("language")))
357
+ theme.apply_theme(self.app, str(self.config.get("theme")))
358
+ theme.apply_font(self.app, str(self.config.get("font_family")),
359
+ int(self.config.get("font_size")))
360
+ self.tray.setIcon(theme.make_tray_icon())
361
+ self._rebuild_menu()
362
+ self._apply_hotkeys()
363
+ if self.main_window is not None:
364
+ self.main_window.reload_providers()
365
+
366
+ def quit(self) -> None:
367
+ self.hotkeys.stop()
368
+ shutdown_workers()
369
+ self.server.close()
370
+ QLocalServer.removeServer(SOCKET_NAME)
371
+ self.app.quit()
372
+
373
+ # -- IPC -----------------------------------------------------------------
374
+
375
+ def _on_connection(self) -> None:
376
+ socket = self.server.nextPendingConnection()
377
+ if socket is None:
378
+ return
379
+ socket.readyRead.connect(lambda: self._read_command(socket))
380
+
381
+ def _read_command(self, socket) -> None:
382
+ data = bytes(socket.readAll()).decode("utf-8", errors="replace")
383
+ socket.disconnectFromServer()
384
+ try:
385
+ command = json.loads(data)
386
+ except json.JSONDecodeError:
387
+ command = {"action": "noop"}
388
+ self.handle(command)
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # main
393
+ # ---------------------------------------------------------------------------
394
+
395
+ def main(argv: list[str] | None = None) -> int:
396
+ parser = build_arg_parser()
397
+ args = parser.parse_args(argv)
398
+ command = args_to_command(args)
399
+ setup_logging(args.debug)
400
+
401
+ if not args.check:
402
+ if try_send_to_running(command):
403
+ if command["action"] == "daemon":
404
+ print("kortalk is already running.", file=sys.stderr)
405
+ return 0
406
+ if args.quit_:
407
+ print("kortalk is not running.", file=sys.stderr)
408
+ return 1
409
+
410
+ try:
411
+ app = QApplication(sys.argv[:1])
412
+ app.setApplicationName("kortalk")
413
+ config = Config()
414
+ i18n.set_language(str(config.get("language")))
415
+
416
+ if args.check:
417
+ return run_selftest(config)
418
+
419
+ kortalk = KortalkApp(app, config)
420
+ log.info("kortalk %s started: platform=%s, hotkeys=%s",
421
+ __version__, QGuiApplication.platformName(), kortalk.hotkeys.backend)
422
+ if command["action"] != "daemon":
423
+ kortalk.handle(command)
424
+ code = app.exec()
425
+ log.info("kortalk exited (code %d)", code)
426
+ return code
427
+ except Exception as exc: # noqa: BLE001
428
+ log.exception("unhandled error")
429
+ if args.debug:
430
+ raise
431
+ print(f"Error: {exc}", file=sys.stderr)
432
+ return 1
433
+
434
+
435
+ if __name__ == "__main__":
436
+ sys.exit(main())