Skip to content

Commit 1e97646

Browse files
committed
[D1] Refactor Ghost Text preview to non-destructive (fix streaming overwrite/caret)
1 parent f6467b8 commit 1e97646

File tree

6 files changed

+234
-5
lines changed

6 files changed

+234
-5
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
2+
D1,Refactor Ghost Text preview to non-destructive (fix streaming overwrite/caret),"Replace default document-mutating Ghost Text preview with a non-destructive overlay (DeepIntegratedGhostText). Add editor paint overlay rendering and make accept/cancel stable. Ensure streaming chunk rendering never modifies document text or moves caret; clear preview on doc switch/save. Add pytest-qt coverage for overwrite/caret chaos regressions.","Streaming/AI preview does not change editor.toPlainText() until user accepts; caret stays stable during streaming; Tab accept inserts completion once at correct position; Esc cancels and stops updates; stale chunks ignored; tests pass.",".tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_streaming_ghost_no_overwrite.py",none,DONE,DONE,TODO,"src/gui/editor/text_editor.py | src/gui/editor/deep_integrated_ghost_text.py | src/gui/editor/optimal_ghost_text.py | src/gui/editor/smart_completion_manager.py | tests/test_streaming_ghost_no_overwrite.py",none,"user_report: streaming overwrites existing text + caret chaos | test:.tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_streaming_ghost_no_overwrite.py PASS | done_at:2026-02-10"
3+
D2,Scratch autosave/restore + flush on close,"Persist unbound scratch editor content to a recovery file on autosave and on app close; restore it on startup when no project is restored. Also flush pending autosave for project-backed editors on app close so quick exits don’t lose content. Add tests + manual restart checklist.", "Typing in scratch tab persists after restart; closing app quickly still saves latest edits (scratch + project docs); tests pass (or limited validation recorded with risk).",".tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_scratch_autosave_restore.py",none,TODO,TODO,TODO,"src/gui/editor/editor_panel.py | src/gui/editor/text_editor.py | src/main.py | src/core/config.py | tests/test_scratch_autosave_restore.py",none,"manual_checklist: 1) Start app, type in scratch tab, close immediately. 2) Reopen; expect content restored. 3) Open project, edit scene, close immediately; reopen project; expect content present."
4+
D3,Bottom status bar separator/overlap cleanup,"Reduce stacked separator lines and clipping in EnhancedStatusBar. Use a single separator strategy (either borders or VLines) with consistent heights/margins and remove duplicate/overlapping lines. Update status bar layout test accordingly.","Bottom bar shows a single clean row; no stacked/thick separator lines; labels are not clipped; test passes.",".tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_status_bar_layout.py",none,TODO,TODO,TODO,"src/gui/status/status_bar.py | tests/test_status_bar_layout.py",none,"user_report: bottom bar lines stacked/overlapping"
5+
D4,"Max tokens UI supports >=1,000,000","Ensure AI配置中心的最大tokens输入允许很大的值(>=1,000,000)且不会出现输入首位数字就被限制/截断的问题;保存/加载配置保持一致。添加pytest-qt回归测试。","Max tokens can be set to 1,000,000+ and persists after reopening AI配置中心; regression test passes.",".tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_ai_config_max_tokens_range.py",none,TODO,TODO,TODO,"src/gui/ai/unified_ai_config_dialog.py | tests/test_ai_config_max_tokens_range.py",none,"user_report: max_tokens max=3999 and typing '4' clamps; verify and prevent regression"
6+
D5,Verify provider streaming specs + docs/tests,"Use official docs (OpenAI/Anthropic/Gemini) to verify streaming endpoints/headers/params and SSE parsing requirements; adjust provider strategies and AIClient stream parsing if needed; add a short doc summary and tests for OpenAI+Claude streaming fixtures.","Provider streaming endpoints/params match official docs as of 2026-02-10; OpenAI/Claude streaming extraction tests pass; doc added.",".tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_provider_streaming_openai_claude.py",manual,TODO,TODO,TODO,"src/core/ai_client.py | src/core/ai_providers/openai.py | src/core/ai_providers/claude.py | src/core/ai_providers/gemini.py | docs/ai/streaming_providers.md | tests/test_provider_streaming_openai_claude.py",none,"requires web research for up-to-date streaming specs; keep tests offline via fixtures"
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
mode: plan
3+
task: UI+Autosave+Streaming fixes v2
4+
created_at: "2026-02-10T23:07:08+08:00"
5+
complexity: complex
6+
---
7+
8+
# Plan: UI + Autosave + Streaming fixes v2
9+
10+
## Goal
11+
- Fix streaming completion so it never overwrites existing document text and caret stays stable; output renders in-order like typing.
12+
- Ensure blank/scratch editing is auto-saved and restored reliably (including on app close) so content isn鈥檛 lost.
13+
- Clean up bottom status bar visual clutter (no stacked separators/overlaps) and keep key info readable.
14+
- Confirm max_tokens UI supports large values (>= 1,000,000) and config persists correctly.
15+
- Verify provider streaming request/response formats for OpenAI/Anthropic/Gemini against latest docs and keep code + tests aligned.
16+
17+
## Scope
18+
- In:
19+
- Ghost text/inline completion rendering (`src/gui/editor/*ghost*`, `src/gui/editor/smart_completion_manager.py`)
20+
- Streaming request/parse plumbing (`src/core/ai_client.py`, `src/core/ai_providers/*`, `src/core/ai_qt_client.py`, `src/gui/ai/enhanced_ai_manager.py`)
21+
- Autosave/session restore for scratch + project docs (`src/gui/editor/editor_panel.py`, `src/gui/editor/text_editor.py`, `src/main.py`, `src/core/config*.py`)
22+
- Status bar UI (`src/gui/status/status_bar.py` + related tests)
23+
- AI config center UI token/stream toggles (`src/gui/ai/unified_ai_config_dialog.py`)
24+
- Out:
25+
- Changing project DB schema
26+
- Adding new providers beyond existing list (only ensure existing auto-adaptation is correct)
27+
28+
## Assumptions / Dependencies
29+
- Use venv python: `.tmp\\ane0305-venv-311\\Scripts\\python.exe`
30+
- PyQt6 + pytest-qt available for tests.
31+
- Provider streaming docs may change; rely on official docs as of 2026-02-10.
32+
33+
## Phases
34+
1. Create plan + Issue CSV contract; validate CSV.
35+
2. Streaming render safety: move ghost preview to non-destructive overlay; add tests guarding no overwrite/cursor chaos.
36+
3. Autosave reliability: flush on close; persist/restore scratch doc; add tests + manual checklist.
37+
4. Status bar cleanup: remove redundant separators and fix sizing; update layout test.
38+
5. Provider streaming verification: web research + adjust provider strategies/SSE parsing as needed; add/refresh fixtures.
39+
6. Regression: run `python -m compileall -q src` and targeted pytest suite; mark Regression DONE via meta commit.
40+
41+
## Tests & Verification
42+
- Streaming safety -> `.tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_streaming_ghost_no_overwrite.py`
43+
- Scratch autosave -> `.tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_scratch_autosave_restore.py`
44+
- Status bar -> `.tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_status_bar_layout.py`
45+
- Config UI -> `.tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_ai_config_max_tokens_range.py`
46+
- Provider streaming -> `.tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_provider_streaming_openai_claude.py`
47+
- Regression -> `.tmp\\ane0305-venv-311\\Scripts\\python.exe -m compileall -q src` + `.tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q`
48+
49+
## Issue CSV
50+
- Path: issues/2026-02-10_23-01-37-ui-autosave-streaming-v2.csv
51+
- Must share the same timestamp/slug as this plan.
52+
53+
## Tools / MCP
54+
- `manual` for UI verification.
55+
- `none` for code work; web search (non-MCP) for provider docs.
56+
57+
## Acceptance Checklist
58+
- [ ] Streaming completion never modifies document text until user accepts.
59+
- [ ] Streaming output appears in-order like typing; cancel stops updates; stale chunks ignored.
60+
- [ ] Closing the app does not lose recently typed content (project docs and scratch).
61+
- [ ] New/empty project starts with empty content (no ghost/saved garbage).
62+
- [ ] Bottom status bar has clean separators, no stacked lines or clipping.
63+
- [ ] Max tokens UI supports >= 1,000,000 and persists.
64+
- [ ] All issues Dev/Review1/Regression are DONE with tests evidence in Notes.
65+
66+
## Risks / Blockers
67+
- PyQt paint overlay differences across platforms/themes; needs manual QA.
68+
- Streaming SSE formats may evolve; fixtures might need updates.
69+
70+
## Rollback / Recovery
71+
- Keep old Optimal ghost system code; allow toggling default back if deep overlay causes regressions.
72+
- For autosave: keep scratch autosave file separate and non-destructive.
73+
74+
## Checkpoints
75+
- Commit + push per issue.
76+
- Meta commit for Regression_Status after batch passes.
77+
78+
## References
79+
- src/gui/editor/text_editor.py
80+
- src/gui/editor/optimal_ghost_text.py
81+
- src/gui/editor/deep_integrated_ghost_text.py
82+
- src/gui/editor/smart_completion_manager.py
83+
- src/core/ai_client.py
84+
- src/gui/status/status_bar.py

src/gui/editor/deep_integrated_ghost_text.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -515,9 +515,17 @@ def accept_ghost_text(self) -> bool:
515515
self.clear_ghost_text()
516516

517517
# 插入实际文本
518-
cursor = QTextCursor(self.document)
518+
try:
519+
cursor = self.text_editor.textCursor()
520+
except Exception:
521+
cursor = QTextCursor(self.document)
522+
519523
cursor.setPosition(position)
520524
cursor.insertText(ghost_text)
525+
try:
526+
self.text_editor.setTextCursor(cursor)
527+
except Exception:
528+
pass
521529

522530
# 发射信号
523531
self.ghost_text_accepted.emit(ghost_text)
@@ -833,4 +841,4 @@ def enhanced_key_press_event(event: QKeyEvent):
833841
logger.error(f"ghost_text_manager missing show_completion method! Type: {type(ghost_text_manager)}")
834842
raise AttributeError("DeepIntegratedGhostText instance missing show_completion method")
835843

836-
return ghost_text_manager
844+
return ghost_text_manager

src/gui/editor/smart_completion_manager.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ def _bind_ghost_signals(self) -> None:
234234

235235
def _on_ghost_completion_accepted(self, accepted_text: str) -> None:
236236
try:
237+
self.stop_streaming_ai_completion()
237238
if self._ghost_state_manager:
238239
self._ghost_state_manager.accept_with_text(accepted_text)
239240
self._completion_state_machine.set_state(CompletionState.APPLIED)
@@ -242,6 +243,7 @@ def _on_ghost_completion_accepted(self, accepted_text: str) -> None:
242243

243244
def _on_ghost_completion_rejected(self) -> None:
244245
try:
246+
self.stop_streaming_ai_completion()
245247
if self._ghost_state_manager:
246248
self._ghost_state_manager.force_idle()
247249
self._completion_state_machine.set_state(CompletionState.CANCELLED)
@@ -722,6 +724,17 @@ def update_streaming_ai_completion(self, chunk_text: str, context: dict) -> None
722724
self._redetect_ghost_text_system()
723725
if not self._ghost_completion:
724726
return
727+
# If the user has moved the caret since the request was started, stop rendering
728+
# to avoid "jumping" previews and confusing cursor/overwrite behavior.
729+
try:
730+
anchor_pos = context.get("cursor_position") if isinstance(context, dict) else None
731+
if isinstance(anchor_pos, int) and anchor_pos >= 0:
732+
current_pos = self._text_editor.textCursor().position()
733+
if current_pos != anchor_pos:
734+
self.stop_streaming_ai_completion()
735+
return
736+
except Exception:
737+
pass
725738
try:
726739
self._streaming_typed_controller.on_chunk(chunk_text, context or {})
727740
except Exception as exc: # noqa: BLE001

src/gui/editor/text_editor.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444

4545
logger = logging.getLogger(__name__)
4646

47-
DEFAULT_GHOST_TEXT_SYSTEM = "optimal" # "optimal" | "deep"
47+
DEFAULT_GHOST_TEXT_SYSTEM = "deep" # "optimal" | "deep" (deep is non-destructive; safer for streaming/autosave)
4848
ALLOW_GHOST_TEXT_FALLBACK_TO_DEEP = True
4949

5050

@@ -245,10 +245,33 @@ def __init__(self, config: Config, shared: Shared, parent=None):
245245
logger.info("Intelligent text editor initialized")
246246

247247
def paintEvent(self, event: QPaintEvent):
248-
"""简化的paintEvent - OptimalGhostText无需特殊渲染"""
249-
# OptimalGhostText直接在文档中插入格式化文本,无需额外渲染
248+
"""Paint editor text, then (optionally) paint Ghost Text overlay.
249+
250+
DeepIntegratedGhostText is non-destructive and relies on an overlay render pass.
251+
OptimalGhostText inserts formatted text into the document and does not require overlay rendering.
252+
"""
250253
super().paintEvent(event)
251254

255+
ghost = getattr(self, "_ghost_completion", None)
256+
render_fn = getattr(ghost, "render_ghost_text", None)
257+
if not callable(render_fn):
258+
return
259+
260+
try:
261+
painter = QPainter(self.viewport())
262+
try:
263+
painter.setClipRect(event.rect())
264+
except Exception:
265+
pass
266+
render_fn(painter)
267+
except Exception:
268+
logger.debug("Ghost text overlay paint failed", exc_info=True)
269+
finally:
270+
try:
271+
painter.end()
272+
except Exception:
273+
return
274+
252275
# Legacy ghost text methods removed - replaced with OptimalGhostText
253276

254277
def set_ghost_text(self, text: str, cursor_position: int):
@@ -877,6 +900,11 @@ def _detect_concepts(self):
877900

878901
def set_document_content(self, content: str, document_id: str = None):
879902
"""设置文档内容"""
903+
# Ensure any in-flight Ghost Text preview does not bleed into the new document UI.
904+
try:
905+
self.clear_ghost_text()
906+
except Exception:
907+
pass
880908
self.setPlainText(content)
881909
self._current_document_id = document_id
882910
self._last_save_content = content
@@ -894,6 +922,12 @@ def is_modified(self) -> bool:
894922

895923
def save_document(self) -> bool:
896924
"""保存文档(优先持久化到项目数据库)。"""
925+
# Never persist preview-only Ghost Text into the document storage.
926+
try:
927+
self.clear_ghost_text()
928+
except Exception:
929+
pass
930+
897931
if not self._is_modified:
898932
return True
899933

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from __future__ import annotations
2+
3+
import os
4+
5+
import pytest
6+
7+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
8+
9+
pytest.importorskip("PyQt6")
10+
pytest.importorskip("pytestqt")
11+
12+
from PyQt6.QtCore import Qt
13+
14+
from core.config import Config
15+
from core.shared import Shared
16+
from gui.editor.text_editor import IntelligentTextEditor
17+
18+
19+
def test_streaming_ghost_preview_does_not_overwrite_document(qtbot) -> None:
20+
config = Config()
21+
shared = Shared(config)
22+
23+
editor = IntelligentTextEditor(config=config, shared=shared)
24+
qtbot.addWidget(editor)
25+
editor.show()
26+
editor.setFocus()
27+
28+
editor.set_document_content("Hello ", document_id="doc1")
29+
cursor = editor.textCursor()
30+
cursor.movePosition(cursor.MoveOperation.End)
31+
editor.setTextCursor(cursor)
32+
33+
anchor_pos = editor.textCursor().position()
34+
original_text = editor.toPlainText()
35+
36+
context = {"request_id": "r1", "task_key": "t1", "cursor_position": anchor_pos}
37+
editor._smart_completion.update_streaming_ai_completion("World", context)
38+
39+
ghost = getattr(editor, "_ghost_completion", None)
40+
qtbot.waitUntil(lambda: getattr(ghost, "_current_ghost_text", "") == "World", timeout=1500)
41+
42+
assert editor.toPlainText() == original_text
43+
assert editor.textCursor().position() == anchor_pos
44+
45+
# Accept preview with Tab.
46+
qtbot.keyClick(editor, Qt.Key.Key_Tab)
47+
qtbot.wait(50)
48+
49+
assert editor.toPlainText() == "Hello World"
50+
assert editor.textCursor().position() == len("Hello World")
51+
52+
53+
def test_streaming_preview_stops_when_caret_moves(qtbot) -> None:
54+
config = Config()
55+
shared = Shared(config)
56+
57+
editor = IntelligentTextEditor(config=config, shared=shared)
58+
qtbot.addWidget(editor)
59+
editor.show()
60+
editor.setFocus()
61+
62+
editor.set_document_content("ABC", document_id="doc1")
63+
cursor = editor.textCursor()
64+
cursor.movePosition(cursor.MoveOperation.End)
65+
editor.setTextCursor(cursor)
66+
67+
anchor_pos = editor.textCursor().position()
68+
context = {"request_id": "r1", "task_key": "t1", "cursor_position": anchor_pos}
69+
70+
editor._smart_completion.update_streaming_ai_completion("X", context)
71+
qtbot.wait(50)
72+
73+
# Move caret away from the anchor position.
74+
cursor = editor.textCursor()
75+
cursor.setPosition(0)
76+
editor.setTextCursor(cursor)
77+
78+
# Further chunks should stop streaming rendering instead of jumping to the new caret position.
79+
editor._smart_completion.update_streaming_ai_completion("Y", context)
80+
qtbot.wait(50)
81+
82+
ghost = getattr(editor, "_ghost_completion", None)
83+
assert not bool(getattr(ghost, "_current_ghost_text", ""))
84+
assert editor.toPlainText() == "ABC"

0 commit comments

Comments
 (0)