diff --git a/api.py b/api.py index ea4836d..95d794c 100644 --- a/api.py +++ b/api.py @@ -209,8 +209,8 @@ def cleanup(text, api_key, model, system_prompt, reasoning="", return content -def chat(messages, api_key, model, system_prompt, base_url=OPENROUTER_URL, - timeout=180): +def chat(messages, api_key, model, system_prompt, reasoning="", + base_url=OPENROUTER_URL, timeout=180): """A conversation, rather than one transcript rewritten. The messages are the whole history and come back unchanged; the caller keeps @@ -223,6 +223,8 @@ def chat(messages, api_key, model, system_prompt, base_url=OPENROUTER_URL, "model": model, "messages": [{"role": "system", "content": system_prompt}] + list(messages), } + if reasoning: + payload["reasoning"] = {"effort": reasoning, "exclude": True} try: data = _request( f"{base_url.rstrip('/')}/chat/completions", diff --git a/assistant.py b/assistant.py index 11c99d2..fe808f2 100644 --- a/assistant.py +++ b/assistant.py @@ -68,6 +68,18 @@ CODEX_ITEMS = { } +# How hard to think, in each provider's own vocabulary. The setting is one +# scale, offered once, because "think harder" is one thing to want; what differs +# is only which rungs a provider has. A level it does not have lands on the +# nearest one it does rather than being dropped. +CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low", + "medium": "medium", "high": "high", "xhigh": "xhigh", + "max": "max"} +CODEX_EFFORT = {"none": "minimal", "minimal": "minimal", "low": "low", + "medium": "medium", "high": "high", "xhigh": "high", + "max": "high"} + + class AssistantError(Exception): pass @@ -207,6 +219,9 @@ def _ask_claude(prompt, conf, session, on_stage, should_stop): "--permission-mode", conf["assistant_permission_mode"], "--append-system-prompt", conf.assistant_prompt(), ] + effort = CLAUDE_EFFORT.get(conf["assistant_reasoning"], "") + if effort: + cmd += ["--effort", effort] if session: cmd += ["--resume", session] @@ -269,6 +284,9 @@ def _ask_codex(prompt, conf, session, on_stage, should_stop): ] if conf["assistant_codex_model"].strip(): settings += ["-m", conf["assistant_codex_model"].strip()] + effort = CODEX_EFFORT.get(conf["assistant_reasoning"], "") + if effort: + settings += ["-c", f'model_reasoning_effort="{effort}"'] cmd = (["codex", "exec", "resume", session] if session else ["codex", "exec"]) cmd += settings + [body] @@ -323,7 +341,8 @@ def _ask_openrouter(prompt, conf, on_stage): try: answer = api.chat( messages, conf.openrouter_key(), conf["assistant_openrouter_model"], - conf.assistant_prompt(), base_url=conf["openrouter_base_url"], + conf.assistant_prompt(), reasoning=conf["assistant_reasoning"], + base_url=conf["openrouter_base_url"], timeout=conf["assistant_timeout"], ) except api.ApiError as exc: diff --git a/config.py b/config.py index 8a1fba0..1255767 100644 --- a/config.py +++ b/config.py @@ -340,6 +340,7 @@ DEFAULTS = { "assistant_codex_model": "", # empty -> whatever Codex is set to "assistant_codex_sandbox": "workspace-write", "assistant_openrouter_model": "google/gemini-3.5-flash", + "assistant_reasoning": "", # empty -> the model's own default "assistant_dir": "", # empty -> the home directory "assistant_prompt": "", # empty -> language-specific default "assistant_cleanup": False, # the model reads through filler words fine diff --git a/dikte.py b/dikte.py index 1f78c7d..08203ce 100755 --- a/dikte.py +++ b/dikte.py @@ -79,7 +79,8 @@ class Dikte: self.overlay = Overlay(self.conf["overlay_corner"]) # The agent's indicator sits on top of the dictation one when both are # up, and drops into the corner when it is alone there. - self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay) + self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay, + dismissable=True) self.recorder = audio.Recorder() self.pipeline = Pipeline(self.conf) self.ask_pipeline = Pipeline(self.conf) diff --git a/i18n.py b/i18n.py index c5247fc..925d81a 100644 --- a/i18n.py +++ b/i18n.py @@ -360,6 +360,11 @@ TR = { "hesabınla.", "How it runs": "Nasıl çalışıyor", "Runs on": "Şunun üstünde çalışır", + "More thinking is slower, and you are standing in front of the screen while " + "it happens. Worth it for a job that has to be worked out rather than " + "looked up.": + "Daha çok düşünmek daha yavaştır ve bu sırada ekranın başında bekliyor " + "olursun. Bakılıp bulunacak değil, çözülmesi gereken işler için değer.", "Claude Code": "Claude Code", "Codex": "Codex", "Codex's own default": "Codex'in kendi varsayılanı", diff --git a/overlay.py b/overlay.py index 6b25118..a35d14e 100644 --- a/overlay.py +++ b/overlay.py @@ -36,10 +36,15 @@ class Overlay(QWidget): of covering it, which is what lets a dictation and a command to the agent be under way at the same time and still both be visible.""" - def __init__(self, corner="bottom-left", below=None): + def __init__(self, corner="bottom-left", below=None, dismissable=False): super().__init__(None) self.corner = corner self.below = below + # A job that can run for ten minutes should not have to be watched for + # ten minutes. Clicking such an indicator puts the progress away; the + # work carries on and its result still shows up. + self.dismissable = dismissable + self.muted = False self._stacked = False self.state = "idle" self.message = "" @@ -58,7 +63,13 @@ class Overlay(QWidget): ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + # One that can be clicked away has to receive the click, which means it + # also swallows one aimed at whatever is underneath it. The rest stay + # transparent to the mouse, as an indicator should be. + if dismissable: + self.setCursor(Qt.CursorShape.PointingHandCursor) + else: + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.resize(MIN_WIDTH, HEIGHT) @@ -79,6 +90,7 @@ class Overlay(QWidget): self.message = "" self.seconds = 0.0 self.levels = [0.0] * BARS + self.muted = False # a new run starts visible, whatever the last one did self._hide_timer.stop() self._appear() @@ -93,26 +105,31 @@ class Overlay(QWidget): self._appear() def show_busy(self, message): + # Muted: the stages keep arriving and are simply not drawn. The state is + # left alone as well, so nothing repaints the box back onto the screen. + if self.muted: + self.message = message + return self.state = "busy" self.message = message self._hide_timer.stop() self._appear() def show_done(self, message="", msec=2000): - self.state = "done" - self.message = message - self._appear() - self._hide_timer.start(msec) + self._finish("done", message, msec) def show_warning(self, message, msec=9000): """Finished, but something the user should know about went wrong.""" - self.state = "warning" - self.message = message - self._appear() - self._hide_timer.start(msec) + self._finish("warning", message, msec) def show_error(self, message, msec=6000): - self.state = "error" + self._finish("error", message, msec) + + def _finish(self, state, message, msec): + """An outcome always shows, even one that was told to be quiet: waving + the progress away asks not to be watched, not to be kept in the dark.""" + self.muted = False + self.state = state self.message = message self._appear() self._hide_timer.start(msec) @@ -121,6 +138,14 @@ class Overlay(QWidget): self._hide_timer.stop() self._conceal() + def mousePressEvent(self, event): + # Only a job in progress can be waved away. A recording is short and + # ending it by accident would cost the words; an outcome goes on its own. + if self.dismissable and self.state == "busy": + self.muted = True + self.dismiss() + event.accept() + @property def showing(self): """Mapped and actually painting something. The window stays mapped while @@ -172,7 +197,9 @@ class Overlay(QWidget): width = MIN_WIDTH + (24 if self.state == "meeting" else 0) else: metrics = QFontMetrics(self._label_font()) - width = max(MIN_WIDTH, min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + 76)) + extra = 76 + (18 if self._can_dismiss else 0) + width = max(MIN_WIDTH, + min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + extra)) self.resize(width, HEIGHT) def _reposition(self): @@ -232,6 +259,8 @@ class Overlay(QWidget): self._draw_time(painter) else: self._draw_message(painter) + if self._can_dismiss: + self._draw_dismiss(painter) def _draw_indicator(self, painter, accent): cx, cy = 26.0, self.height() / 2 @@ -340,10 +369,27 @@ class Overlay(QWidget): text, ) + @property + def _can_dismiss(self): + return self.dismissable and self.state == "busy" + + def _draw_dismiss(self, painter): + """A faint cross on the right: without it there is nothing to say the + box can be clicked away, and a feature nobody can see is not one.""" + cx, cy = self.width() - 18.0, self.height() / 2 + pen = QPen(QColor(MUTED), 1.6) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawLine(QPointF(cx - 4, cy - 4), QPointF(cx + 4, cy + 4)) + painter.drawLine(QPointF(cx + 4, cy - 4), QPointF(cx - 4, cy + 4)) + def _draw_message(self, painter): painter.setFont(self._label_font()) painter.setPen({"error": ERR, "warning": WARN}.get(self.state, TEXT)) - box = QRectF(46, 0, self.width() - 60, self.height()) + # Leave the cross its corner rather than running the text under it. + box = QRectF(46, 0, self.width() - 60 - (18 if self._can_dismiss else 0), + self.height()) metrics = QFontMetrics(self._label_font()) text = metrics.elidedText(self.message, Qt.TextElideMode.ElideRight, int(box.width())) painter.drawText( diff --git a/settings_ui.py b/settings_ui.py index 3b9a7d1..9d7123f 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -395,6 +395,18 @@ class SettingsWindow(QDialog): dir_note.setWordWrap(True) how_form.addRow(dir_note) + # One scale for all three: how hard to think is one thing to want, and + # each provider is handed the nearest rung it actually has. + self.assistant_reasoning = QComboBox() + for label, value in REASONING_LEVELS: + self.assistant_reasoning.addItem(t(label), value) + self.assistant_reasoning.setToolTip(t( + "More thinking is slower, and you are standing in front of the " + "screen while it happens. Worth it for a job that has to be worked " + "out rather than looked up." + )) + how_form.addRow(t("Thinking"), self.assistant_reasoning) + self.assistant_timeout = QSpinBox() self.assistant_timeout.setRange(15, 3600) self.assistant_timeout.setSuffix(t(" s")) @@ -903,6 +915,7 @@ class SettingsWindow(QDialog): self._select_data(self.assistant_codex_sandbox, conf["assistant_codex_sandbox"]) self.assistant_openrouter_model.setCurrentText(conf["assistant_openrouter_model"]) self._assistant_provider_changed() # selecting index 0 fires no signal + self._select_data(self.assistant_reasoning, conf["assistant_reasoning"]) self.assistant_dir.setText(conf["assistant_dir"]) self.assistant_timeout.setValue(int(conf["assistant_timeout"])) self.assistant_session_minutes.setValue(int(conf["assistant_session_minutes"])) @@ -997,6 +1010,7 @@ class SettingsWindow(QDialog): self.assistant_openrouter_model.currentText().strip() or cfg.DEFAULTS["assistant_openrouter_model"] ) + conf["assistant_reasoning"] = self.assistant_reasoning.currentData() or "" conf["assistant_dir"] = self.assistant_dir.text().strip() conf["assistant_timeout"] = self.assistant_timeout.value() conf["assistant_session_minutes"] = self.assistant_session_minutes.value()