from __future__ import annotations import html import json import os import re import shutil import subprocess import sys from queue import Empty, Queue from threading import Thread from datetime import datetime, timedelta from dataclasses import dataclass from pathlib import Path import PySide6 from PySide6.QtCore import QThread, Qt, QTimer, Signal from PySide6.QtGui import ( QCloseEvent, QFont, QFontDatabase, QGuiApplication, QIcon, QKeySequence, QShortcut, QTextCursor, ) from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, QDialog, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, ) from desktop_app_core import * from desktop_app_workers import * from desktop_app_ui import * class WindowConversationMixin: def append_attachments(self, paths: list[str]) -> tuple[int, list[str]]: invalid_paths: list[str] = [] added = 0 existing = {item.path for item in self.pending_attachments} for raw_path in paths: path = str(Path(raw_path).expanduser()) if not path or path in existing: continue if not Path(path).is_file(): invalid_paths.append(Path(path).name or path) continue kind = detect_attachment_kind(path) self.pending_attachments.append(AttachmentInfo(path=path, kind=kind)) existing.add(path) added += 1 if added: self.refresh_attachment_widgets() return added, invalid_paths def refresh_attachment_widgets(self) -> None: self.clear_layout_widgets(self.attachment_list_layout) if not self.pending_attachments: self.attachment_hint.setText("æªæ·»å ") self.attachment_hint.show() return self.attachment_hint.hide() for index, item in enumerate(self.pending_attachments): kind = {"image": "å¾", "text": "æ"}.get(item.kind, "ä»¶") button = QPushButton(f"{kind} {truncate_text(attachment_label(item.path), 24)} Ã") button.setObjectName("scopeButton") button.clicked.connect(lambda _checked=False, i=index: self.remove_attachment(i)) self.attachment_list_layout.addWidget(button, 0) self.attachment_list_layout.addStretch(1) def remove_attachment(self, index: int) -> None: if index < 0 or index >= len(self.pending_attachments): return self.pending_attachments.pop(index) self.refresh_attachment_widgets() def pick_attachments(self) -> None: files, _ = QFileDialog.getOpenFileNames( self, "éæ©éä»¶", str(self.config.work_dir), "æææä»¶ (*);;" "å¾ç (*.png *.jpg *.jpeg *.webp *.gif *.bmp);;" "ææ¬æä»¶ (*.txt *.log *.md *.markdown *.json *.jsonl *.yaml *.yml *.toml *.csv *.xml)", ) if not files: return added, invalid_paths = self.append_attachments(files) if added: self.set_status(f"已添å {added} 个éä»¶", "idle") if invalid_paths: QMessageBox.information( self, "codex-ui", "以ä¸è·¯å¾ä¸å卿䏿¯æä»¶ï¼\n" + "\n".join(invalid_paths), ) def add_pasted_attachments(self, paths: list[str]) -> None: added, invalid_paths = self.append_attachments(paths) if added: self.set_status(f"å·²ä»åªè´´æ¿æ·»å {added} 个éä»¶", "idle") if invalid_paths: QMessageBox.information( self, "codex-ui", "以ä¸ç²è´´è·¯å¾ä¸å卿䏿¯æä»¶ï¼\n" + "\n".join(invalid_paths), ) def add_clipboard_image_attachment(self, image) -> None: target = clipboard_attachment_dir() / f"clipboard-{current_local_iso().replace(':', '').replace('+', '_')}.png" if not image.save(str(target), "PNG"): QMessageBox.critical(self, "codex-ui", "åªè´´æ¿å¾çä¿åå¤±è´¥ï¼æ æ³ä½ä¸ºéä»¶æ·»å ã") return added, invalid_paths = self.append_attachments([str(target)]) if added: self.set_status("å·²ä»åªè´´æ¿æ·»å å¾çéä»¶", "idle") return target.unlink(missing_ok=True) if invalid_paths: QMessageBox.information( self, "codex-ui", "å½ååªè´´æ¿å¾çæ æ³æ·»å ã", ) def current_request_key(self) -> str: return self.active_session_id or "__new__" def resolve_request_key(self, request_key: str) -> str: return self.worker_key_aliases.get(request_key, request_key) def has_running_worker(self) -> bool: return any(worker.isRunning() for worker in self.workers.values()) def is_current_session_busy(self) -> bool: return self.current_request_key() in self.workers def update_header_status_chip(self) -> None: self.header_status.setText("running" if self.is_current_session_busy() else "idle") def update_request_controls(self) -> None: busy = self.is_current_session_busy() self.update_header_status_chip() self.send_button.setEnabled(not busy) self.stop_button.setVisible(busy) self.stop_button.setEnabled(busy) self.add_attachment_button.setEnabled(not busy) self.prompt_menu_button.setEnabled(not busy) self.input_box.setEnabled(not busy) self.new_button.setEnabled(True) if busy: self.request_state_label.setObjectName("requestStateRunning") self.request_state_label.setStyleSheet("") self.request_state_label.setText("å¤çä¸...") self.request_error_label.hide() self.retry_button.hide() return if self.request_state_label.text() in {"å¤çä¸...", "æ£å¨åæ¢..."}: self.set_request_ready_feedback() def begin_request_feedback(self, prompt: str, attachments: list[AttachmentInfo] | None = None) -> None: self.last_prompt = prompt self.last_attachments = [ AttachmentInfo(path=item.path, kind=item.kind) for item in (attachments or []) ] self.last_error = "" self.streaming_bubble = None self.streaming_text = "" self.request_state_label.setObjectName("requestStateRunning") self.request_state_label.setStyleSheet("") self.request_state_label.setText("å¤çä¸...") self.request_error_label.hide() self.request_error_label.setText("") self.retry_button.hide() self.update_request_controls() def finish_request_feedback(self) -> None: self.update_request_controls() if self.input_box.isEnabled(): self.input_box.setFocus() def set_request_ready_feedback(self) -> None: self.request_state_label.setObjectName("requestStateIdle") self.request_state_label.setStyleSheet("") self.request_state_label.setText("") self.request_error_label.hide() self.retry_button.hide() self.stop_button.hide() def set_request_failed_feedback(self, error: str) -> None: compact_error = " ".join((error or "æªç¥é误").split()).strip() self.last_error = compact_error self.request_state_label.setObjectName("requestStateFailed") self.request_state_label.setStyleSheet("") self.request_state_label.setText("åé失败") self.request_error_label.setText(truncate_text(compact_error, 220)) self.request_error_label.show() if self.last_prompt: self.retry_button.show() self.input_box.setEnabled(True) self.send_button.setEnabled(True) self.add_attachment_button.setEnabled(True) self.prompt_menu_button.setEnabled(True) def retry_last_prompt(self) -> None: if (not self.last_prompt and not self.last_attachments) or self.is_current_session_busy(): return self.input_box.setPlainText(self.last_prompt) self.pending_attachments = [AttachmentInfo(path=item.path, kind=item.kind) for item in self.last_attachments] self.refresh_attachment_widgets() self.send_prompt() def stop_current_request(self) -> None: request_key = self.current_request_key() worker = self.workers.get(request_key) if worker is None or not worker.isRunning(): return self.request_state_label.setObjectName("requestStateRunning") self.request_state_label.setStyleSheet("") self.request_state_label.setText("æ£å¨åæ¢...") self.stop_button.setEnabled(False) self.set_status("æ£å¨åæ¢å½å请æ±...", "running") self.restore_request_account() worker.stop() def on_session_selected(self, row: int) -> None: if row < 0: return item = self.session_list.item(row) self.activate_session_item(item) def on_session_item_clicked(self, item: QListWidgetItem) -> None: self.activate_session_item(item) def activate_session_item(self, item: QListWidgetItem | None) -> None: if not item: return session_id = item.data(Qt.UserRole) if not session_id: return if str(session_id).startswith("__group__:"): group = str(session_id).removeprefix("__group__:") scroll_bar = self.session_list.verticalScrollBar() scroll_position = scroll_bar.value() if group in self.collapsed_session_groups: self.collapsed_session_groups.remove(group) else: self.collapsed_session_groups.add(group) self.refresh_session_list() scroll_bar.setValue(scroll_position) QTimer.singleShot(0, lambda: scroll_bar.setValue(scroll_position)) return if str(session_id).startswith("__project__:"): project_id = str(session_id).removeprefix("__project__:") scroll_bar = self.session_list.verticalScrollBar() scroll_position = scroll_bar.value() self.selected_project_context_id = project_id for project in self.projects: project.selected = project.project_id == project_id if project_id in self.collapsed_project_ids: self.collapsed_project_ids.remove(project_id) else: self.collapsed_project_ids.add(project_id) expanded = project_id not in self.collapsed_project_ids try: set_codex_project_sidebar_state( self.config.codex_home, project_id, expanded, ) except (OSError, ValueError, json.JSONDecodeError): pass self.refresh_session_list() scroll_bar.setValue(scroll_position) QTimer.singleShot(0, lambda: scroll_bar.setValue(scroll_position)) return if session_id == "__new__": self.new_session() return if session_id == self.active_session_id: return self.active_session_id = session_id summary = next( (session for session in self.sessions if session.session_id == session_id), None, ) project = self.session_project(summary) if summary else None self.selected_project_context_id = project.project_id if project else "" for item_project in self.projects: item_project.selected = bool(project and item_project.project_id == project.project_id) self.update_session_list_selection() self.update_work_dir_label() self.load_active_session(scroll_to_top=False) def current_session_messages(self) -> list[ChatMessage] | None: if not self.active_session_id: return [] cached = self.session_message_cache.get(self.active_session_id) if cached: cached_path = Path(cached.session_path) if cached_path.is_file(): try: mtime_ns = cached_path.stat().st_mtime_ns except OSError: mtime_ns = -1 if mtime_ns == cached.mtime_ns: return cached.messages return None def start_session_load(self, session_id: str, scroll_to_top: bool = False) -> None: if self.session_load_worker is not None and self.session_load_worker.isRunning(): self.session_load_worker.requestInterruption() self.session_load_target_id = session_id self.pending_scroll_to_top = scroll_to_top path, mtime_ns = conversation_file_info(self.config.codex_home, session_id) if not path: self.session_message_cache.pop(session_id, None) self.render_session_messages([], scroll_to_top) return self.active_session_loading = True self.message_count_label.setText("å è½½ä¸...") self.load_more_messages_button.hide() worker = ConversationLoadWorker(session_id, path, mtime_ns) self.session_load_worker = worker worker.finished_ok.connect(self.on_session_messages_loaded) worker.failed.connect(self.on_session_messages_failed) worker.finished.connect(self.on_session_load_worker_finished) worker.start() def on_session_messages_loaded( self, session_id: str, session_path: str, mtime_ns: int, messages: list[ChatMessage], ) -> None: self.session_message_cache[session_id] = ConversationCacheEntry( session_path=session_path, mtime_ns=mtime_ns, messages=messages, ) if session_id != self.active_session_id or session_id != self.session_load_target_id: return self.render_session_messages(messages, self.pending_scroll_to_top) def on_session_messages_failed(self, session_id: str, error: str) -> None: if session_id != self.active_session_id or session_id != self.session_load_target_id: return self.active_session_loading = False self.pending_visible_messages = [] self.message_count_label.setText("å 载失败") self.load_more_messages_button.hide() self.set_status("ä¼è¯å 载失败", "idle") self.request_error_label.setText(truncate_text(error, 220)) self.request_error_label.show() def on_session_load_worker_finished(self) -> None: worker = self.sender() if worker is self.session_load_worker: self.session_load_worker = None if worker is not None: worker.deleteLater() def render_session_messages(self, messages: list[ChatMessage], scroll_to_top: bool = False) -> None: self.active_session_loading = False self.active_session_messages = messages total = len(messages) self.active_message_start_index = max(0, total - self.initial_message_render_limit) self.pending_visible_messages = messages[self.active_message_start_index :] self.pending_scroll_to_top = scroll_to_top self.update_message_load_controls() self.render_next_message_batch() def render_next_message_batch(self) -> None: if not self.pending_visible_messages: streaming_text = self.streaming_texts.get(self.current_request_key(), "") if streaming_text and self.current_request_key() not in self.streaming_bubbles: bubble = self.add_message( ChatMessage(role="assistant", text=streaming_text, timestamp=current_local_time()), auto_scroll=False, ) self.streaming_bubbles[self.current_request_key()] = bubble if self.active_session_messages: self.reset_chat_scroll(to_top=self.pending_scroll_to_top) self.update_request_controls() return batch = self.pending_visible_messages[: self.message_render_batch_size] self.pending_visible_messages = self.pending_visible_messages[self.message_render_batch_size :] for msg in batch: self.add_message(msg, auto_scroll=False) if self.pending_visible_messages: self.message_render_timer.start(0) return self.render_next_message_batch() def update_message_load_controls(self) -> None: total = len(self.active_session_messages) visible = total - self.active_message_start_index if total else 0 if total <= 0: self.message_count_label.setText("ææ æ¶æ¯") self.load_more_messages_button.hide() return if self.active_message_start_index > 0: hidden = self.active_message_start_index self.message_count_label.setText(f"å·²æ¾ç¤ºæè¿ {visible} / {total} æ¡") self.load_more_messages_button.setText(f"å è½½æ´æ©æ¶æ¯ ({hidden})") self.load_more_messages_button.show() return self.message_count_label.setText(f"å ± {total} æ¡æ¶æ¯") self.load_more_messages_button.hide() def clear_messages(self) -> None: self.streaming_bubbles.clear() self.active_session_messages = [] self.active_message_start_index = 0 self.pending_visible_messages = [] self.message_render_timer.stop() self.streaming_bubble = None self.streaming_text = "" while self.chat_layout.count() > 0: item = self.chat_layout.takeAt(0) widget = item.widget() if widget: widget.deleteLater() self.update_message_load_controls() def add_message( self, message: ChatMessage, auto_scroll: bool = True, insert_index: int | None = None, ) -> MessageBubble: bubble = MessageBubble(message) if insert_index is None: self.chat_layout.addWidget(bubble) else: self.chat_layout.insertWidget(insert_index, bubble) if auto_scroll: self.chat_scroll.verticalScrollBar().setValue(self.chat_scroll.verticalScrollBar().maximum()) return bubble def load_older_messages(self) -> None: if self.active_message_start_index <= 0 or not self.active_session_messages: return scrollbar = self.chat_scroll.verticalScrollBar() old_value = scrollbar.value() old_maximum = scrollbar.maximum() new_start = max(0, self.active_message_start_index - self.message_render_chunk_size) older_messages = self.active_session_messages[new_start : self.active_message_start_index] insert_index = 0 for message in older_messages: self.add_message(message, auto_scroll=False, insert_index=insert_index) insert_index += 1 self.active_message_start_index = new_start self.update_message_load_controls() def restore_position() -> None: current = self.chat_scroll.verticalScrollBar() current.setValue(old_value + max(0, current.maximum() - old_maximum)) QTimer.singleShot(0, restore_position) QTimer.singleShot(30, restore_position) def append_assistant_delta(self, request_key: str, delta: str) -> None: request_key = self.resolve_request_key(request_key) chunk = delta or "" if not chunk: return current_text = self.streaming_texts.get(request_key, "") current_text += chunk self.streaming_texts[request_key] = current_text if request_key != self.current_request_key(): return bubble = self.streaming_bubbles.get(request_key) if bubble is None: bubble = self.add_message( ChatMessage(role="assistant", text="", timestamp=current_local_time()), auto_scroll=False, ) self.streaming_bubbles[request_key] = bubble self.streaming_bubble = bubble try: bubble.update_text(current_text) except RuntimeError: self.streaming_bubbles.pop(request_key, None) if request_key != self.current_request_key(): return bubble = self.add_message( ChatMessage(role="assistant", text=current_text, timestamp=current_local_time()), auto_scroll=False, ) self.streaming_bubbles[request_key] = bubble self.streaming_bubble = bubble self.chat_scroll.verticalScrollBar().setValue(self.chat_scroll.verticalScrollBar().maximum()) def finalize_assistant_message(self, request_key: str, text: str) -> None: request_key = self.resolve_request_key(request_key) final_text = text.strip() if not final_text: return self.streaming_texts[request_key] = final_text bubble = self.streaming_bubbles.get(request_key) if bubble is not None: try: bubble.update_text(final_text) except RuntimeError: self.streaming_bubbles.pop(request_key, None) bubble = None if bubble is not None: self.streaming_bubbles.pop(request_key, None) if self.streaming_bubble is bubble: self.streaming_bubble = None return if request_key != self.current_request_key(): return self.add_message(ChatMessage(role="assistant", text=final_text, timestamp=current_local_time())) def load_active_session(self, scroll_to_top: bool = False) -> None: self.clear_messages() self.update_model_selectors() if not self.is_current_session_busy(): self.set_request_ready_feedback() self.clear_session_unread(self.active_session_id) if not self.active_session_id: if self.sessions: self.header_title.setText("æ°ä¼è¯") self.header_meta.setText("å°æªå¼å§") else: self.header_title.setText("ææ ä¼è¯") self.header_meta.setText("å½åæ¬å°è¿æ²¡æä¼è¯") self.resume_command.setText("") self.set_status("") self.update_pin_button() self.update_session_action_buttons() self.message_count_label.setText("ææ æ¶æ¯") self.update_request_controls() return session = next((s for s in self.sessions if s.session_id == self.active_session_id), None) if not session: session = load_session_summary(self.config, self.active_session_id, self.session_aliases) self.header_title.setText(session.thread_name if session else self.active_session_id[:8]) self.header_meta.setText(f"{session.updated_at if session else '-'} · {self.active_session_id[:8]}") self.resume_command.setText(f"codex resume {self.active_session_id}") self.update_work_dir_label() self.update_pin_button() self.update_session_action_buttons() messages = self.current_session_messages() if messages is not None: self.render_session_messages(messages, scroll_to_top) return self.start_session_load(self.active_session_id, scroll_to_top) def reset_chat_scroll(self, to_top: bool) -> None: target = 0 if to_top else self.chat_scroll.verticalScrollBar().maximum() def apply_position() -> None: scrollbar = self.chat_scroll.verticalScrollBar() scrollbar.setValue(0 if to_top else scrollbar.maximum()) scrollbar = self.chat_scroll.verticalScrollBar() scrollbar.setValue(target) QTimer.singleShot(0, apply_position) QTimer.singleShot(30, apply_position) def copy_resume_command(self) -> None: command = self.resume_command.text().strip() if not command: return QGuiApplication.clipboard().setText(command) self.set_status("å·²å¤å¶ resume å½ä»¤", "idle") def insert_prompt_template(self, template: str) -> None: current = self.input_box.toPlainText() if not current.strip(): self.input_box.setPlainText(template) else: self.input_box.setPlainText(f"{current.rstrip()}\n\n{template}") self.input_box.setFocus() self.input_box.moveCursor(QTextCursor.End) def normalize_model_command_value(self, raw: str) -> str: value = (raw or "").strip() if value.lower() in {"default", "reset", "none"} or value in {"é»è®¤", "æ¸ ç©º"}: return "" return value def model_settings_label(self, model: str, reasoning_effort: str) -> str: return ( f"模å: {model_display_name(model)}" f" · æ¨ç: {reasoning_effort_display_name(reasoning_effort)}" ) def current_model_settings(self) -> tuple[str, str]: if not self.active_session_id: model = self.new_session_model return ( "" if is_retired_model(model) else model, self.new_session_reasoning_effort, ) override = self.session_model_overrides.get(self.active_session_id) if override is not None: model = str(override.get("model") or "").strip() return ( "" if is_retired_model(model) else model, normalize_reasoning_effort(override.get("reasoning_effort")), ) model, effort = load_session_model_settings(self.config.codex_home, self.active_session_id) if model: return "" if is_retired_model(model) else model, effort fallback = self.config.model return ( "" if is_retired_model(fallback) else fallback, self.config.model_reasoning_effort, ) def recorded_session_model(self) -> str: if not self.active_session_id: return self.new_session_model override = self.session_model_overrides.get(self.active_session_id) if override is not None: return str(override.get("model") or "").strip() model, _effort = load_session_model_settings( self.config.codex_home, self.active_session_id, ) return model def update_model_selectors(self) -> None: if not hasattr(self, "model_combo"): return model, effort = self.current_model_settings() recorded_model = self.recorded_session_model() self.model_combo.blockSignals(True) self.model_combo.clear() for value in model_choices(model, self.config.codex_home): if value: label = value elif is_retired_model(recorded_model): label = f"CLI é»è®¤ï¼{recorded_model} å·²ä¸çº¿ï¼" else: label = "CLI é»è®¤" self.model_combo.addItem(label, value) model_index = self.model_combo.findData(model) self.model_combo.setCurrentIndex(model_index if model_index >= 0 else 0) self.model_combo.setToolTip(f"å½åä¼è¯æ¨¡åï¼{model_display_name(model)}") self.model_combo.blockSignals(False) self.reasoning_combo.blockSignals(True) effort_index = self.reasoning_combo.findData(normalize_reasoning_effort(effort)) self.reasoning_combo.setCurrentIndex(effort_index if effort_index >= 0 else 0) self.reasoning_combo.setToolTip( f"å½åæ¨ç强度ï¼{reasoning_effort_display_name(effort)}" ) self.reasoning_combo.blockSignals(False) def on_model_selector_changed(self) -> None: if not hasattr(self, "model_combo"): return self.apply_model_settings( str(self.model_combo.currentData() or ""), str(self.reasoning_combo.currentData() or ""), ) def apply_model_settings( self, model: str | None = None, reasoning_effort: str | None = None, ) -> None: current_model, current_reasoning_effort = self.current_model_settings() if model is not None and is_retired_model(model): self.update_model_selectors() self.set_status(f"{model} å·²ä¸çº¿ï¼è¯·éæ©å ¶ä»æ¨¡å", "error") return next_model = current_model if model is None else self.normalize_model_command_value(model) next_reasoning_effort = ( current_reasoning_effort if reasoning_effort is None else normalize_reasoning_effort(reasoning_effort) ) if next_model == current_model and next_reasoning_effort == current_reasoning_effort: self.update_model_selectors() self.set_status(f"å½å{self.model_settings_label(next_model, next_reasoning_effort)}", "idle") return if self.active_session_id: self.session_model_overrides[self.active_session_id] = { "model": next_model, "reasoning_effort": next_reasoning_effort, } save_session_model_overrides(self.session_model_overrides) else: self.new_session_model = next_model self.new_session_reasoning_effort = next_reasoning_effort self.config.model = next_model self.config.model_reasoning_effort = next_reasoning_effort save_config(self.config) self.update_model_selectors() self.set_status(f"已忢 {self.model_settings_label(next_model, next_reasoning_effort)}", "idle") def open_model_selection_dialog(self) -> None: dialog = ModelSelectionDialog(self) if dialog.exec() != QDialog.Accepted: self.set_status("å·²åæ¶æ¨¡åéæ©", "idle") return self.apply_model_settings(dialog.selected_model, dialog.selected_reasoning_effort) def parse_model_command_args(self, raw: str) -> tuple[str | None, str | None]: tokens = (raw or "").split() if not tokens: return None, None if len(tokens) == 1 and normalize_reasoning_effort(tokens[0]): return None, tokens[0] if len(tokens) == 1 and tokens[0] in {"é»è®¤", "æ¸ ç©º"}: return "", None model = tokens[0] reasoning_effort = tokens[1] if len(tokens) > 1 else None return model, reasoning_effort def handle_composer_command(self, prompt: str) -> bool: stripped = (prompt or "").strip() if not stripped.startswith("/"): return False parts = stripped.split(None, 1) command = parts[0] if parts else "" rest = parts[1] if len(parts) > 1 else "" if command.strip().lower() != "/model": return False self.input_box.clear() if rest.strip(): model, reasoning_effort = self.parse_model_command_args(rest) self.apply_model_settings(model, reasoning_effort) else: self.open_model_selection_dialog() if self.input_box.isEnabled(): self.input_box.setFocus() return True def handle_local_workflow_command(self, prompt: str, attachments: list[AttachmentInfo]) -> bool: if attachments: return False stripped = (prompt or "").strip() if stripped != "æäº¤ä»£ç ": return False success, message = self.run_commit_workflow() self.input_box.clear() if self.input_box.isEnabled(): self.input_box.setFocus() if success: self.set_status(message, "idle") else: self.set_status("æäº¤å¤±è´¥", "idle") QMessageBox.critical(self, "Codex for Linux", message) return True def new_session(self) -> None: self.active_session_id = None project = self.project_by_id.get(self.selected_project_context_id) project_root = Path(project.root_paths[0]).expanduser() if project and project.root_paths else None self.new_session_work_dir = project_root or self.config.work_dir self.new_session_work_dir_overridden = self.new_session_work_dir != self.config.work_dir self.new_session_project_id = project.project_id if project_root and project else "" self.new_session_model = self.config.model self.new_session_reasoning_effort = self.config.model_reasoning_effort self.refresh_session_list() self.update_work_dir_label() self.update_model_selectors() self.load_active_session(scroll_to_top=True) self.set_status("已忢尿°ä¼è¯", "idle") def send_prompt(self) -> None: request_key = self.current_request_key() if request_key in self.workers: return prompt = self.input_box.toPlainText().strip() attachments = [AttachmentInfo(path=item.path, kind=item.kind) for item in self.pending_attachments] if not prompt and not attachments: return if prompt and self.handle_composer_command(prompt): return if prompt and self.handle_local_workflow_command(prompt, attachments): return missing_paths = [item.path for item in attachments if not Path(item.path).exists()] if missing_paths: QMessageBox.critical( self, "codex-ui", "以ä¸éä»¶ä¸åå¨ï¼æ æ³åéï¼\n" + "\n".join(missing_paths), ) return try: final_prompt = build_prompt_with_attachments(prompt, attachments) except OSError as exc: QMessageBox.critical(self, "codex-ui", str(exc)) return if not final_prompt: QMessageBox.critical(self, "codex-ui", "éä»¶å å®¹ä¸ºç©ºï¼æ æ³åéã") return visible_prompt = prompt or "" attachment_summary = render_attachment_summary(attachments) if attachment_summary: visible_prompt = f"{visible_prompt}\n\n{attachment_summary}".strip() self.add_message(ChatMessage(role="user", text=visible_prompt, timestamp=current_local_time())) self.mark_session_updated(self.active_session_id) self.remember_request_account() self.input_box.clear() self.pending_attachments = [] self.refresh_attachment_widgets() self.set_status("", "running") image_paths = [item.path for item in attachments if item.kind == "image"] model, reasoning_effort = self.current_model_settings() worker = CodexWorker( self.config, self.active_session_id, final_prompt, image_paths=image_paths, work_dir=self.current_effective_work_dir(), model=model, reasoning_effort=reasoning_effort, ) self.workers[request_key] = worker self.worker = worker self.begin_request_feedback(prompt, attachments) worker.session_started.connect(lambda session_id, key=request_key: self.on_session_started(key, session_id)) worker.assistant_delta.connect(lambda text, key=request_key: self.on_assistant_delta(key, text)) worker.assistant_message.connect(lambda text, key=request_key: self.on_assistant_message(key, text)) worker.usage_updated.connect(lambda usage, key=request_key: self.on_usage_updated(key, usage)) worker.failed.connect(lambda error, key=request_key: self.on_failed(key, error)) worker.finished_ok.connect(lambda key=request_key: self.on_finished_ok(key)) worker.finished.connect(self.on_worker_thread_finished) worker.start() self.refresh_session_list() def on_session_started(self, request_key: str, session_id: str) -> None: if session_id: invalidate_session_candidate_cache(self.config.codex_home) self.worker_key_aliases[request_key] = session_id worker = self.workers.pop(request_key, None) if worker is not None: self.workers[session_id] = worker if request_key in self.streaming_texts: self.streaming_texts[session_id] = self.streaming_texts.pop(request_key) if request_key in self.streaming_bubbles: self.streaming_bubbles[session_id] = self.streaming_bubbles.pop(request_key) if request_key == "__new__" and self.new_session_work_dir_overridden: self.session_work_dir_overrides[session_id] = str(self.new_session_work_dir) save_session_work_dir_overrides(self.session_work_dir_overrides) self.new_session_work_dir_overridden = False if request_key == "__new__": if self.new_session_project_id: try: assign_session_to_codex_project( self.config.codex_home, session_id, self.new_session_project_id, ) self.refresh_project_catalog() except (OSError, ValueError, json.JSONDecodeError): pass self.new_session_project_id = "" self.session_model_overrides[session_id] = { "model": worker.model if worker is not None else self.new_session_model, "reasoning_effort": ( worker.reasoning_effort if worker is not None else self.new_session_reasoning_effort ), } save_session_model_overrides(self.session_model_overrides) self.active_session_id = session_id self.bind_session_to_active_account(session_id) self.mark_session_updated(session_id) self.update_work_dir_label() self.update_model_selectors() self.update_request_controls() self.refresh_session_list() def on_assistant_message(self, request_key: str, text: str) -> None: request_key = self.resolve_request_key(request_key) self.finalize_assistant_message(request_key, text) self.mark_session_updated(request_key if request_key != "__new__" else self.active_session_id) if request_key != self.current_request_key(): self.mark_session_unread(request_key) def on_assistant_delta(self, request_key: str, text: str) -> None: resolved_key = self.resolve_request_key(request_key) self.append_assistant_delta(request_key, text) if resolved_key != self.current_request_key(): self.mark_session_unread(resolved_key) def on_usage_updated(self, request_key: str, usage: dict) -> None: request_key = self.resolve_request_key(request_key) if request_key != self.current_request_key(): return self.usage_label.setText( "in " f"{humanize_count(usage.get('input_tokens', 0))}" " · cache " f"{humanize_count(usage.get('cached_input_tokens', 0))}" " · out " f"{humanize_count(usage.get('output_tokens', 0))}" ) def on_failed(self, request_key: str, error: str) -> None: request_key = self.resolve_request_key(request_key) self.streaming_bubbles.pop(request_key, None) if request_key == self.current_request_key(): self.streaming_bubble = None self.restore_request_account() if request_key == self.current_request_key(): self.finish_request_feedback() self.set_request_failed_feedback(error) self.set_status("åé失败", "idle") def on_finished_ok(self, request_key: str) -> None: request_key = self.resolve_request_key(request_key) invalidate_session_candidate_cache(self.config.codex_home) self.streaming_bubbles.pop(request_key, None) self.streaming_texts.pop(request_key, None) self.restore_request_account() if request_key == self.current_request_key(): self.finish_request_feedback() self.set_request_ready_feedback() self.load_active_session(scroll_to_top=False) self.bind_session_to_active_account(request_key if request_key != "__new__" else self.active_session_id) if request_key != self.current_request_key(): self.mark_session_unread(request_key) self.set_status("", "idle") def on_worker_thread_finished(self) -> None: worker = self.sender() was_stopping = self.request_state_label.text() == "æ£å¨åæ¢..." for key, running_worker in list(self.workers.items()): if running_worker is worker: self.workers.pop(key, None) for alias, resolved in list(self.worker_key_aliases.items()): if resolved == key: self.worker_key_aliases.pop(alias, None) break if worker is self.worker: self.worker = None worker.deleteLater() self.update_request_controls() self.refresh_session_list() if was_stopping: self.set_status("已忢å½å请æ±", "idle") def closeEvent(self, event: QCloseEvent) -> None: has_running_tasks = ( self.has_running_worker() or (self.session_load_worker is not None and self.session_load_worker.isRunning()) or (self.account_worker is not None and self.account_worker.isRunning()) ) if has_running_tasks: answer = QMessageBox.question( self, "Codex for Linux", "å½å仿任塿£å¨è¿è¡ï¼å ³éçªå£åä¼ä¸æè¿äºä»»å¡ãæ¯å¦ç»§ç»å ³éï¼", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: event.ignore() return self.message_render_timer.stop() if self.session_load_worker is not None and self.session_load_worker.isRunning(): self.session_load_worker.requestInterruption() self.session_load_worker.wait(3000) for worker in list(self.workers.values()): if worker.isRunning(): worker.stop() worker.wait(3000) if self.account_worker is not None and self.account_worker.isRunning(): self.account_worker.stop() self.account_worker.wait(3000) if self.update_check_worker is not None and self.update_check_worker.isRunning(): self.update_check_worker.wait(3000) event.accept()