diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index e6ba982..b0186be 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -36,7 +36,9 @@ jobs:
run: node tests/frontend/validate-imports.mjs
- name: Frontend unit tests
- run: node tests/frontend/unit.test.mjs
+ run: |
+ node tests/frontend/unit.test.mjs
+ node tests/frontend/forge-completion.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
run: |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3de53a3..66a8c2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
-> [Unreleased](#unreleased). La dernière version livrée est **2.8.0**.
+> [Unreleased](#unreleased). La dernière version livrée est **2.8.1**.
---
@@ -14,6 +14,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [2.8.1] — 2026-09-17
+
+### Corrigé
+
+- **BUG-055 - Éditeur Forge : autocomplétion Tab naturelle et fiable** : la touche `Tab`
+ déclenchait simultanément trois actions indépendantes (indentation, complétion d'un mot du
+ document, acceptation de la prédiction IA), ce qui insérait un ou deux espaces avant le mot
+ complété — et le retour arrière effaçait alors l'ajout. La gestion de `Tab` est désormais
+ **unifiée** avec une priorité claire (liste de suggestions ouverte → prédiction IA →
+ complétion de mot du document → indentation), une seule action par appui. Les helpers de
+ complétion sont extraits en fonctions pures partagées avec CodeMirror
+ (`getWordFragment`, `findWordCompletions`, `normalizeGhost`, `chooseTabAction`) ; plusieurs
+ candidats affichent une liste positionnée au curseur ; la **complétion fantôme** n'affiche
+ plus un miroir transparent de tout le document (source du décalage visuel et des espaces
+ fantômes) mais uniquement la prédiction, positionnée exactement au curseur et nettoyée dès
+ que le curseur bouge ou que la vue défile ; une complétion de mot ne peut plus introduire
+ d'espace. Le prompt `/api/ai/inline-complete` est simplifié et borné à 128 tokens pour des
+ suggestions plus courtes et plus rapides. Fichiers : `frontend/editor-poc.html`,
+ `frontend/js/autocomplete.js`, `backend/ai.py`. Tests :
+ `tests/frontend/forge-completion.test.mjs` (nouveau, 28 tests).
+
+---
+
## [2.8.0] — 2026-09-17
### Ajouté
diff --git a/README.fr.md b/README.fr.md
index 2dff347..b049701 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
-[]()
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.docker.com/)
[](https://www.python.org/)
@@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
-Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.8.0).
+Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.8.1).
---
-*Projet : ObsiGate | Version : 2.8.0 | Dernière mise à jour : Juin 2026*
+*Projet : ObsiGate | Version : 2.8.1 | Dernière mise à jour : Juin 2026*
diff --git a/README.md b/README.md
index 972c0b3..93a7d4c 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
-[]()
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.docker.com/)
[](https://www.python.org/)
@@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
-See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.8.0).
+See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.8.1).
---
-*Project: ObsiGate | Version: 2.8.0 | Last updated: May 2026*
+*Project: ObsiGate | Version: 2.8.1 | Last updated: May 2026*
diff --git a/VERSION b/VERSION
index 834f262..dbe5900 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.8.0
+2.8.1
diff --git a/backend/ai.py b/backend/ai.py
index 036e5f9..b9e8da4 100644
--- a/backend/ai.py
+++ b/backend/ai.py
@@ -353,10 +353,13 @@ async def ai_generate_frontmatter(text: str, provider: ProviderName | None = Non
async def ai_inline_complete(text: str, provider: ProviderName | None = None) -> str:
- """Inline completion — suggest continuation."""
+ """Inline completion — suggest a short continuation of the text before the cursor."""
return await _call_deepseek_openrouter(
- f"Complete this text naturally. Return only the completion (just the new text, no repetition):\n\n{text}",
- SYSTEM_PROMPT, provider, temperature=0.3, max_tokens=512,
+ "Continue the text below in the same language. Reply with ONLY the "
+ "continuation: no repetition, no quotes, no explanation, at most one "
+ "short sentence. If the text ends with a partial word, finish that word.\n\n"
+ + text,
+ SYSTEM_PROMPT, provider, temperature=0.2, max_tokens=128,
)
diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock
index 54a6cae..ff226dc 100644
--- a/desktop/Cargo.lock
+++ b/desktop/Cargo.lock
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
-version = "2.8.0"
+version = "2.8.1"
dependencies = [
"chrono",
"env_logger",
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index 7d44cfd..364b319 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
-version = "2.8.0"
+version = "2.8.1"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json
index 536fcd9..61bac9f 100644
--- a/desktop/tauri.conf.json
+++ b/desktop/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
- "version": "2.8.0",
+ "version": "2.8.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md
index 3011214..6f1cd68 100644
--- a/docs/ISSUES_TODOLIST.md
+++ b/docs/ISSUES_TODOLIST.md
@@ -164,6 +164,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-052* | [🟡 IMPORTANT] Assistant IA : recherche web sans réponse finale (10 étapes + sources affichées, aucun texte dans la conversation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `tests/test_agent_loop.py` | Assistant (mode agent) : recherche web qui enchaîne 10 étapes puis n'affiche aucune réponse | `backend/agent/loop.py` : `_finalize_answer` — dernier appel LLM sans outil (instruction de synthèse) quand le budget d'itérations/quota est épuisé, repli déterministe `_fallback_summary` (liste des sources), résultats `deferred` pour les appels non atteints du lot en quota | Cause : `content=""` renvoyé sur `STOP_MAX_ITERATIONS`/`STOP_QUOTA_EXCEEDED` alors que le modèle appelait encore des outils. Tests : `tests/test_agent_loop.py` (+2) |
| *BUG-053* | [🟡 IMPORTANT] Assistant IA (mode agent) : le fichier demandé n'est pas créé — le modèle émet un bloc texte `obsigate-action` au lieu d'appeler l'outil `create_file` | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/bookslm.py`, `backend/bookslm_routes.py`, `tests/test_bookslm.py` | Mode agent, contexte Général (ou dossier vide) : « créer le fichier TestVault/sport/… avec le tableau des 84 matchs » → réponse avec un bloc ```obsigate-action``` tronqué, aucun fichier | `backend/bookslm.py` : protocole d'action scindé — `GENERAL_ACTION_TOOL_PROTOCOL` (outils natifs, interdiction des blocs `obsigate-action`) utilisé quand `agent=True`, protocole texte conservé pour le chat classique ; `backend/bookslm_routes.py` : `_resolve_system_prompt(..., agent=True)` depuis l'endpoint agent + règle « Mode agent » pour les prompts dossier/documents, `max_tokens` agent 4096 → 8192 (contenu de fichier complet) | Cause : le prompt Général enseignait encore le protocole texte alors que l'agent dispose du function calling. Tests : `tests/test_bookslm.py` (+3) |
| *BUG-054* | [🟡 IMPORTANT] Éditeur « Editer » : le bouton Sauvegarder reste bloqué sur le spinner de chargement (retour au crochet uniquement après un refresh complet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/utils.js` | Ouvrir un fichier → Editer → cliquer Sauvegarder (ou Ctrl+S) ; rouvrir l'éditeur : le bouton reste un spinner désactivé | Nouveau helper `resetSaveButton()` (crochet `✓` + `disabled=false` + styles en ligne nettoyés) appelé à l'ouverture (`openEditor`), à la fermeture (`closeEditor`) et en cas d'échec (`saveFile`). Tests : `tests/frontend/editor-inline.test.mjs` (+4) | Le nœud `#editor-save` est partagé entre sessions : l'état « spinner + désactivé » posé par une sauvegarde manuelle n'était jamais remis à zéro (succès → fermeture puis réouverture, Forge, ou échec réseau dans le `catch`). Seul un rechargement de `index.html` restaurait le crochet |
+| *BUG-055* | [🟡 IMPORTANT] Éditeur Forge : l'autocomplétion (Tab) ajoute des espaces parasites, l'effacement détruit le mot complété et la complétion fantôme est illisible | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/editor-poc.html`, `frontend/js/autocomplete.js`, `backend/ai.py`, `.gitea/workflows/ci.yml`, `tests/frontend/forge-completion.test.mjs` (nouveau) | Forge : taper un mot, puis Tab pour compléter ; un espace (voire deux) s'insère avant le mot complété, et le retour arrière efface l'ajout. La prédiction IA s'affichait décalée (texte miroir du document entier) | **Cause** : trois gestionnaires `keydown` Tab indépendants s'exécutaient tous — l'indentation (`insertAtCursor(' ')`) s'ajoutait à la complétion de mot et à l'acceptation du ghost. **Correctif** : gestion **unifiée** de Tab (`liste ouverte > ghost > mot du document > indentation`, une seule action), helpers purs partagés (`getWordFragment`, `findWordCompletions`, `normalizeGhost`, `chooseTabAction`) dans `autocomplete.js`, liste déroulante si plusieurs candidats, dropdown positionné au curseur, ghost **positionné au curseur** (fini le miroir du document, nettoyé au déplacement/scroll), complétion de mot sans espace garanti (`normalizeGhost` tronque au premier espace) et prompt `/api/ai/inline-complete` simplifié. Tests : `tests/frontend/forge-completion.test.mjs` (28). | Cause du bug : l'indentation Tab n'était pas conditionnée à l'absence de suggestion. Le ghost re-rendait tout le texte transparent + prédiction, d'où l'impression d'espaces et les erreurs d'effacement |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -220,6 +221,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-16 | BUG-053 | Correction | `backend/bookslm.py`, `backend/bookslm_routes.py`, `tests/test_bookslm.py`, `CHANGELOG.md` | **BUG-053** : en mode agent, le prompt Général (et dossier vide) enseignait le protocole texte `obsigate-action` ; le modèle décrivait donc l'action au lieu d'appeler l'outil natif `create_file` (bloc volumineux de surcroît tronqué avant fermeture → aucun fichier créé). Le prompt est scindé : `GENERAL_ACTION_TOOL_PROTOCOL` (appel direct des outils natifs, interdiction explicite des blocs `obsigate-action`) pour `build_general_system_prompt(agent=True)`, le protocole texte restant utilisé par le chat classique ; `_resolve_system_prompt` propage `agent` et ajoute une règle « Mode agent » aux prompts dossier/documents ; `max_tokens` de l'agent porté à 8192 pour un contenu de fichier complet. Vérifié : `tests/test_bookslm.py` 68/68 (+3 : prompt agent sans protocole texte, prompt classique inchangé, prompt système de l'endpoint agent), suite complète 1087 passed / 6 skipped (14 erreurs MCP préexistantes), ruff 0 (backend), mypy 0 (`backend/bookslm.py`, `backend/bookslm_routes.py`). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-054 | Correction | `frontend/js/utils.js`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-054** : le bouton `#editor-save` (nœud partagé entre toutes les sessions d'édition) restait bloqué sur le spinner de chargement et désactivé — une sauvegarde manuelle (clic ou Ctrl+S) remplaçait le crochet par le loader et ne le restaurait jamais : succès (l'éditeur se ferme, la réouverture réaffichait le spinner), sauvegarde Forge, ou échec réseau (le `catch` ne restaurait ni l'icône ni l'état). Nouveau helper `resetSaveButton()` (crochet `✓`, `disabled=false`, styles en ligne nettoyés) appelé à l'ouverture (`openEditor`), à la fermeture (`closeEditor`) et en cas d'échec (`saveFile`). Vérifié : `tests/frontend/editor-inline.test.mjs` 29/29 (+4), validate-imports 38 modules / 0 erreur. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | #101 | Feature | `frontend/editor-poc.html`, `frontend/js/sync.js`, `frontend/js/viewer.js`, `frontend/js/utils.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/editor-inline.test.mjs`, `docs/features/forge-assistant.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#101** : le bouton « AI Panel » de Forge ouvre désormais l'**Assistant IA** partagé (`postMessage forge-open-ai` → `bookslm.openForCurrentContext()`) au lieu du mini-chat isolé (supprimé) ; Forge lit `localStorage['obsigate_ai_picker']` (`aiPickerSelection()`) pour ses appels `/api/ai/*` et sa complétion fantôme (repli `ollama`), endpoints corrigés (`make-longer`/`make-shorter`, `target_lang`) ; bouton **plein écran** natif ajouté à Forge (iframe `allow="fullscreen"`) et à Editer (`#editor-fullscreen`, conteneur `#editor-container`, sortie à la fermeture, Échap laissé au navigateur) ; i18n `editor.fullscreen`/`editor.exit_fullscreen`. Vérifié : `tests/frontend/editor-inline.test.mjs` 40/40 (+10), unit 9/9, validate-imports 38 modules, 13 suites JSDOM vertes. | 🟢 livré (en attente vérif utilisateur) |
+| 2026-09-17 | BUG-055 | Correction | `frontend/editor-poc.html`, `frontend/js/autocomplete.js`, `backend/ai.py`, `.gitea/workflows/ci.yml`, `tests/frontend/forge-completion.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-055** : trois gestionnaires `keydown` Tab indépendants s'exécutaient à chaque appui — l'indentation (`insertAtCursor(' ')`) s'ajoutait à la complétion de mot (`insertAtCursor(suffixe)`) et à l'acceptation du ghost text, d'où l'espace parasite avant le mot complété puis un effacement destructeur. Gestion **unifiée** de Tab (`liste ouverte > ghost > mot du document > indentation`), helpers purs partagés (`getWordFragment`/`findWordCompletions`/`normalizeGhost`/`chooseTabAction`) extraits dans `autocomplete.js`, liste déroulante au curseur quand plusieurs mots correspondent, ghost **positionné au curseur** (plus de miroir du document entier, nettoyé au déplacement/scroll), complétion de mot sans espace garantie et prompt `/api/ai/inline-complete` simplifié (128 tokens). Vérifié : `tests/frontend/forge-completion.test.mjs` 28/28 (nouveau), `unit.test.mjs` 9/9, `editor-inline.test.mjs` 40/40, `ai.test.mjs` 88/88, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
---
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 60a7362..00a3d49 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
-> **Version :** 2.8.0 | **Dernière mise à jour :** 2026-09-17
+> **Version :** 2.8.1 | **Dernière mise à jour :** 2026-09-17
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
diff --git a/frontend/editor-poc.html b/frontend/editor-poc.html
index 98cb99c..bec209a 100644
--- a/frontend/editor-poc.html
+++ b/frontend/editor-poc.html
@@ -212,18 +212,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.ac-item-label { font-family: var(--mono); font-weight: 500; white-space: nowrap; }
.ac-item-detail { font-size: 10.5px; color: var(--text3); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; }
.ac-item-context { font-size: 9px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; padding: 2px 10px 4px; }
-/* Ghost text overlay */
+/* Ghost text overlay (AI inline prediction, positioned at the caret) */
.ghost-overlay {
- position: absolute; top: 0; left: 0; right: 0; bottom: 0;
+ position: absolute; top: 0; left: 0;
pointer-events: none; z-index: 1;
- padding: 24px 16px 24px 12px;
font-family: var(--mono); font-size: 13.5px; line-height: 1.75;
- white-space: pre-wrap; word-wrap: break-word;
- overflow: hidden; color: transparent;
-}
-.ghost-prediction {
- color: var(--text3); opacity: 0.5;
+ white-space: pre; color: var(--text3); opacity: 0.55;
}
+.ghost-prediction { color: inherit; }
@@ -390,7 +386,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
| / | Menu de commandes (en debut de ligne) |
| Alt+I | Insertion rapide (Quick Insert) |
| F1 | Ce panneau d'aide |
- | Tab | Indenter la ligne / element de liste |
+ | Tab | Valider la suggestion / completer le mot / indenter |
| Shift+Tab | Desindenter |
Formatage
@@ -480,6 +476,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var saveTimer = null;
var aiConfigured = null;
+ // Shared autocomplete helpers (pure functions from autocomplete.js). Loaded
+ // once at startup so the « Tab » handler can use them synchronously.
+ var ac = null;
+ import('/static/js/autocomplete.js').then(function(m) { ac = m; }).catch(function() {});
+
// Parse URL params
(function() {
var p = new URLSearchParams(window.location.search);
@@ -853,7 +854,27 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
})();
// ---- Caret position to pixel (for monospace textarea) ----
- function caretToPixel(pos) {
+ // Measured once so the caret math matches the actual font metrics.
+ var _charW = 0;
+ function getCharWidth() {
+ if (_charW) return _charW;
+ var style = getComputedStyle(ta);
+ var span = document.createElement('span');
+ span.style.fontFamily = style.fontFamily;
+ span.style.fontSize = style.fontSize;
+ span.style.fontWeight = style.fontWeight;
+ span.style.fontStyle = style.fontStyle;
+ span.style.position = 'absolute';
+ span.style.visibility = 'hidden';
+ span.style.whiteSpace = 'pre';
+ span.textContent = '0123456789';
+ document.body.appendChild(span);
+ _charW = (span.getBoundingClientRect().width / 10) || (parseFloat(style.fontSize) * 0.615);
+ span.remove();
+ return _charW;
+ }
+
+ function caretToPixel(pos, raw) {
if (pos == null) pos = getPos().s;
var v = val();
var before = v.substring(0, pos);
@@ -864,20 +885,40 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var fontSize = parseFloat(style.fontSize);
var lineH = parseFloat(style.lineHeight);
if (isNaN(lineH)) lineH = fontSize * 1.75;
- var charW = fontSize * 0.615;
+ var charW = getCharWidth();
var wrapRect = editorWrap.getBoundingClientRect();
var taRect = ta.getBoundingClientRect();
- // Position relative to editorWrap
- var x = taRect.left - wrapRect.left + 2 + colNum * charW;
+ // Position relative to editorWrap (top of the line below the caret).
+ var x = taRect.left - wrapRect.left + colNum * charW;
var y = taRect.top - wrapRect.top + (lineNum + 1) * lineH - ta.scrollTop;
- // Clamp
- if (x < 4) x = 4;
- if (x > wrapRect.width - 310) x = wrapRect.width - 310;
- if (y > wrapRect.height - 360) y = wrapRect.height - 360;
- if (y < 20) y = 20;
+ if (!raw) {
+ // Clamp for the popup menus (never the inline ghost, which is exact).
+ if (x < 4) x = 4;
+ if (x > wrapRect.width - 310) x = wrapRect.width - 310;
+ if (y > wrapRect.height - 360) y = wrapRect.height - 360;
+ if (y < 20) y = 20;
+ }
return { x: x, y: y };
}
+ // Position of the caret itself (same line), used by the inline ghost text.
+ function caretLinePixel(pos) {
+ if (pos == null) pos = getPos().s;
+ var v = val();
+ var before = v.substring(0, pos);
+ var lineNum = before.split('\n').length - 1;
+ var colNum = pos - v.lastIndexOf('\n', pos - 1) - 1;
+ if (colNum < 0) colNum = 0;
+ var style = getComputedStyle(ta);
+ var lineH = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.75;
+ var wrapRect = editorWrap.getBoundingClientRect();
+ var taRect = ta.getBoundingClientRect();
+ return {
+ x: taRect.left - wrapRect.left + colNum * getCharWidth(),
+ y: taRect.top - wrapRect.top + lineNum * lineH - ta.scrollTop
+ };
+ }
+
// ---- Slash menu ----
function initSlashItems() { slashItems = Array.from(slashMenu.querySelectorAll('.slash-item')); }
@@ -1275,11 +1316,6 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (e.ctrlKey && e.key === 'b') { e.preventDefault(); wrapSelection('**'); }
if (e.ctrlKey && e.key === 'i') { e.preventDefault(); wrapSelection('*'); }
if (e.ctrlKey && e.key === 'k') { e.preventDefault(); promptLink(); }
- // Tab: indent list items
- if (e.key === 'Tab') {
- e.preventDefault();
- if (e.shiftKey) { dedentLine(); } else { indentLine(); }
- }
// Quick Insert: Alt+I
if (e.altKey && e.key === 'i') { e.preventDefault(); showQI(); }
});
@@ -1502,10 +1538,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (lnGutter) {
document.getElementById('ln-gutter').scrollTop = editorWrap.scrollTop;
}
- // Sync ghost overlay scroll
- if (ghostOverlay) {
- ghostOverlay.scrollTop = editorWrap.scrollTop;
- }
+ clearGhost();
});
// Update line numbers on input
@@ -1524,32 +1557,61 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (e.key === 'Escape' && helpOverlay.classList.contains('on')) { helpOverlay.classList.remove('on'); }
});
- // ---- Autocomplete (Tab completion from existing words) ----
- var _acTimer = null;
+ // ---- Autocomplete : gestion unifiée de la touche Tab ----
+ // Priorité : liste ouverte > prédiction IA (ghost) > complétion de mot du
+ // document > indentation. Une seule action par appui (avant ce correctif,
+ // l'indentation s'exécutait *en plus* de la complétion et ajoutait des espaces).
ta.addEventListener('keydown', function(e) {
- if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !slashVisible && !qiMenu.classList.contains('on')) {
- var p = getPos();
- if (p.s !== p.e) return; // Don't autocomplete when selection exists (let indent handle it)
- var v = val();
- // Find the word fragment before cursor
- var start = p.s;
- while (start > 0 && /[\w\-\.\/]/.test(v.charAt(start - 1))) start--;
- var fragment = v.substring(start, p.s);
- if (fragment.length < 2) return; // Need at least 2 chars
- // Find matching words in the document
- var re = new RegExp('\\b' + fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\w\\-\\.\\/]+', 'gi');
- var matches = [];
- var m;
- while ((m = re.exec(v)) !== null) {
- if (matches.indexOf(m[0]) === -1) matches.push(m[0]);
- }
- if (matches.length === 1) {
- e.preventDefault();
- insertAtCursor(matches[0].substring(fragment.length), 0);
+ if (e.key !== 'Tab' || e.ctrlKey || e.altKey || e.metaKey) return;
+ if (slashVisible || qiMenu.classList.contains('on')) return; // ces menus gèrent Tab
+
+ // 1. Liste d'autocomplétion ouverte → valider l'élément surligné
+ if (acDropdown.classList.contains('active')) {
+ e.preventDefault();
+ if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]);
+ return;
+ }
+
+ // 2. Prédiction IA affichée → l'accepter
+ if (_ghostText) {
+ e.preventDefault();
+ acceptGhost();
+ return;
+ }
+
+ // 3. Complétion à partir des mots du document
+ var p = getPos();
+ if (!e.shiftKey && p.s === p.e && ac) {
+ var frag = ac.getWordFragment(val(), p.s);
+ if (frag.fragment.length >= 2) {
+ var candidates = ac.findWordCompletions(val(), p.s, frag.fragment, 8);
+ var action = ac.chooseTabAction({ candidates: candidates });
+ if (action === 'word') {
+ e.preventDefault();
+ insertAtCursor(candidates[0].slice(frag.fragment.length), 0);
+ return;
+ }
+ if (action === 'word-list') {
+ e.preventDefault();
+ showWordCompletions(candidates, frag);
+ return;
+ }
}
}
+
+ // 4. Défaut : indenter / désindenter
+ e.preventDefault();
+ if (e.shiftKey) { dedentLine(); } else { indentLine(); }
});
+ // Suggestion list for several document words sharing the typed prefix.
+ function showWordCompletions(words, frag) {
+ var items = words.map(function(w) {
+ return { label: w, detail: 'mot du document', insert: w.slice(frag.fragment.length), kind: 'word' };
+ });
+ showAutocomplete(items, 'Mots du document');
+ }
+
// ---- Title sync: first heading line <-> title bar (NO rename) ----
function syncTitleFromContent() {
var v = val();
@@ -1651,13 +1713,24 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
acItems = items;
acIdx = -1;
if (!items.length) { hideAutocomplete(); return; }
- // Position: centered below the textarea (simple, reliable)
- var rect = ta.getBoundingClientRect();
- acDropdown.style.left = (rect.left + rect.width / 2) + 'px';
- acDropdown.style.top = (rect.bottom + 6) + 'px';
- acDropdown.style.transform = 'translateX(-50%)';
- // Keep dropdown within viewport
- acDropdown.style.maxWidth = Math.min(380, rect.width - 32) + 'px';
+ // Position: just below the caret, clamped to the viewport (natural place
+ // for a completion list instead of a fixed centered dropdown).
+ var pt = caretToPixel(ta.selectionStart, true);
+ var wr = editorWrap.getBoundingClientRect();
+ var width = Math.min(380, Math.max(220, editorWrap.clientWidth - 32));
+ var estH = Math.min(items.length * 26 + 30, 260);
+ var left = wr.left + pt.x;
+ var top = wr.top + pt.y + 4;
+ if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
+ if (left < 8) left = 8;
+ if (top + estH > window.innerHeight - 8) {
+ top = Math.max(8, wr.top + pt.y - estH - 2);
+ }
+ acDropdown.style.width = width + 'px';
+ acDropdown.style.maxWidth = width + 'px';
+ acDropdown.style.left = left + 'px';
+ acDropdown.style.top = top + 'px';
+ acDropdown.style.transform = 'none';
var html = context ? '' + context + '
' : '';
items.forEach(function(item, i) {
@@ -1698,6 +1771,23 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
function applyAutocomplete(item) {
if (!item || !item.insert) return;
+
+ // Document word completions are inserted plainly at the caret — no
+ // context-aware replacement (which would wipe the line in frontmatter).
+ if (item.kind === 'word') {
+ var wp = getPos();
+ ta.value = val().slice(0, wp.s) + item.insert + val().slice(wp.e);
+ var wnp = wp.s + item.insert.length;
+ ta.setSelectionRange(wnp, wnp);
+ hideAutocomplete();
+ clearGhost();
+ ta.focus();
+ markDirty();
+ autoHeight();
+ updateLineNumbers();
+ return;
+ }
+
var start = ta.selectionStart;
var end = ta.selectionEnd;
var before = ta.value.slice(0, start);
@@ -1727,6 +1817,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
}
hideAutocomplete();
+ clearGhost();
ta.focus();
markDirty();
autoHeight();
@@ -1768,15 +1859,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
function clearGhost() {
_ghostText = '';
- ghostOverlay.innerHTML = '';
+ if (ghostPred) ghostPred.textContent = '';
}
function showGhost(prediction) {
if (!prediction) { clearGhost(); return; }
_ghostText = prediction;
- var before = ta.value.slice(0, ta.selectionStart);
- // Show: existing text (transparent) + prediction (visible faded)
- ghostOverlay.innerHTML = escHtml(before) + '' + escHtml(prediction) + '';
+ // Only the prediction is rendered, positioned exactly at the caret: no
+ // mirror of the whole document, so no misalignment and no phantom spaces.
+ ghostPred.textContent = prediction;
+ var pt = caretLinePixel(ta.selectionStart);
+ ghostOverlay.style.left = pt.x + 'px';
+ ghostOverlay.style.top = pt.y + 'px';
}
function acceptGhost() {
@@ -1784,9 +1878,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var start = ta.selectionStart;
var before = ta.value.slice(0, start);
var after = ta.value.slice(start);
- // Trim to avoid double spaces
var insert = _ghostText.replace(/^\s+/, '');
- if (!insert) return;
+ if (!insert) { clearGhost(); return; }
ta.value = before + insert + after;
ta.setSelectionRange(start + insert.length, start + insert.length);
clearGhost();
@@ -1797,32 +1890,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
}
function requestGhostCompletion() {
+ if (acDropdown.classList.contains('active')) return; // list open: don't compete
+ if (ta.selectionStart !== ta.selectionEnd) return; // no prediction over a selection
var fullText = ta.value.slice(0, ta.selectionStart);
- if (fullText.trim().length < 1) return;
+ if (!fullText.trim()) return;
var lastChar = fullText.slice(-1);
- var midWord = /[a-zA-Z0-9\u00C0-\u024F]$/.test(lastChar);
+ var midWord = /[\w\u00C0-\u024F]$/.test(lastChar);
+ var inputText = midWord ? (fullText.match(/([\w\u00C0-\u024F]+)$/) || [''])[0] : fullText;
- // Get user's preferred language
- var lang = 'fr';
- try { lang = localStorage.getItem('obsigate-lang') || 'fr'; } catch(e) {}
- var langNames = { fr: 'French', en: 'English', es: 'Spanish', de: 'German' };
- var langName = langNames[lang] || 'French';
-
- var prompt, inputText;
- if (midWord) {
- var wordMatch = fullText.match(/([\w\u00C0-\u024F]+)$/);
- var partialWord = wordMatch ? wordMatch[1] : fullText;
- inputText = partialWord;
- var context = fullText.slice(0, -partialWord.length).trim();
- prompt = 'Complete this ' + langName + ' word. The word is: "' + partialWord +
- '". Context: "' + (context || '(start of line)') +
- '". Return ONLY the remaining letters. Do NOT add spaces. Example: "famil" → "ial" for "familial".';
- } else {
- inputText = fullText;
- prompt = 'Continue this ' + langName + ' text naturally. Return ONLY the new text (do NOT repeat):\n' + fullText;
- }
-
- var ghostBody = { text: prompt };
+ // The backend turns this text into a short « continue this text » prompt.
+ var ghostBody = { text: fullText };
// #101 — follow the AI Assistant's configured provider/model; fall back to
// the local Ollama model when the assistant has no explicit selection.
var ghostPick = aiPickerSelection();
@@ -1836,19 +1913,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
})
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function(data) {
- var raw = (data.result || '').trim();
- if (!raw || raw.length < 1) return;
- var prediction = raw;
- if (midWord && prediction.toLowerCase().startsWith(inputText.toLowerCase())) {
- prediction = prediction.slice(inputText.length);
- } else if (!midWord && prediction.startsWith(inputText)) {
- prediction = prediction.slice(inputText.length);
- }
- prediction = prediction.trim();
+ var prediction = ac
+ ? ac.normalizeGhost(data.result || '', inputText, midWord)
+ : String(data.result || '').trim();
+ if (!prediction) return;
+ // Don't suggest text that is already present after the cursor.
var alreadyThere = ta.value.slice(ta.selectionStart).trimStart();
- if (prediction && prediction.length > 0 && !alreadyThere.startsWith(prediction.slice(0, Math.min(6, prediction.length)))) {
- showGhost(prediction);
- }
+ if (alreadyThere.startsWith(prediction.slice(0, Math.min(6, prediction.length)))) return;
+ // Ignore a stale response if the caret moved while we were waiting.
+ if (ta.selectionStart !== ta.selectionEnd) return;
+ showGhost(prediction);
})
.catch(function() { /* silent */ });
}
@@ -1860,15 +1934,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
_ghostTimer = setTimeout(requestGhostCompletion, 600);
});
+ // Clear the prediction as soon as the caret moves elsewhere.
+ document.addEventListener('selectionchange', function() {
+ if (document.activeElement === ta) clearGhost();
+ });
+
function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }
ta.addEventListener('keydown', function(e) {
- // Tab: accept ghost text prediction
- if (e.key === 'Tab' && _ghostText && !acDropdown.classList.contains('active')) {
- e.preventDefault();
- acceptGhost();
- return;
- }
// Escape: clear ghost text
if (e.key === 'Escape' && _ghostText && !acDropdown.classList.contains('active')) {
clearGhost();
@@ -1877,7 +1950,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (acDropdown.classList.contains('active')) {
if (e.key === 'ArrowDown') { e.preventDefault(); acIdx = Math.min(acIdx + 1, acItems.length - 1); highlightAcItem(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); acIdx = Math.max(acIdx - 1, 0); highlightAcItem(); }
- else if (e.key === 'Enter' || e.key === 'Tab') {
+ else if (e.key === 'Enter') {
e.preventDefault();
if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]);
}
diff --git a/frontend/js/autocomplete.js b/frontend/js/autocomplete.js
index 86492aa..2b2d070 100644
--- a/frontend/js/autocomplete.js
+++ b/frontend/js/autocomplete.js
@@ -347,4 +347,97 @@ function filterFiles(partial) {
});
}
-export { suggest, searchWikilinks, detectContext, MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT };
+// ── Word completion (Forge « Tab » completion) ────────────────────────
+// A word character matches the same set used by the editor: letters, digits,
+// underscore, dash, dot and slash (so paths like `docs/guide` complete too).
+var _WORD_CHAR = /[\w\-.\/]/;
+
+function isWordChar(ch) {
+ return !!ch && _WORD_CHAR.test(ch);
+}
+
+/** Return the word fragment immediately before the cursor. */
+function getWordFragment(text, cursorPos) {
+ var start = cursorPos;
+ while (start > 0 && isWordChar(text.charAt(start - 1))) start--;
+ return { start: start, fragment: text.slice(start, cursorPos) };
+}
+
+/**
+ * Collect unique words of `text` that start with `fragment` (case-insensitive).
+ * The occurrence currently being typed at the cursor is ignored. `limit` caps
+ * the number of candidates (0 = no limit).
+ */
+function findWordCompletions(text, cursorPos, fragment, limit) {
+ if (!fragment || fragment.length < 2) return [];
+ var lower = fragment.toLowerCase();
+ var re = /[\w\-.\/]+/g;
+ var seen = Object.create(null);
+ var out = [];
+ var m;
+ while ((m = re.exec(text)) !== null) {
+ var word = m[0];
+ if (word.length <= fragment.length) continue;
+ if (word.toLowerCase().indexOf(lower) !== 0) continue;
+ // Skip the word currently being typed (it starts exactly at the cursor).
+ if (m.index === cursorPos - fragment.length) continue;
+ var key = word.toLowerCase();
+ if (seen[key]) continue;
+ seen[key] = true;
+ out.push(word);
+ if (limit && out.length >= limit) break;
+ }
+ return out;
+}
+
+/**
+ * Clean up a raw AI prediction before displaying/inserting it.
+ *
+ * - strips the echoed input prefix (mid-word or full context),
+ * - for a mid-word completion, keeps only the first token so a word completion
+ * can never introduce a stray space,
+ * - trims any remaining leading whitespace.
+ */
+function normalizeGhost(raw, inputText, midWord) {
+ var p = raw == null ? '' : String(raw).trim();
+ if (!p) return '';
+ if (inputText) {
+ var inp = String(inputText);
+ if (midWord && p.toLowerCase().indexOf(inp.toLowerCase()) === 0) {
+ p = p.slice(inp.length);
+ } else if (!midWord && p.indexOf(inp) === 0) {
+ p = p.slice(inp.length);
+ }
+ }
+ if (midWord) p = p.split(/\s+/)[0];
+ return p.replace(/^\s+/, '');
+}
+
+/**
+ * Decide what the « Tab » key should do. Kept pure so the editor and the tests
+ * share the exact same priority rules.
+ *
+ * dropdown → an autocomplete list is open: accept the highlighted item
+ * ghost → an AI inline prediction is displayed: accept it
+ * dedent → Shift+Tab: remove indentation
+ * word → a single document word matches: insert its suffix
+ * word-list → several words match: open the suggestion list
+ * indent → default: indent the current line
+ */
+function chooseTabAction(state) {
+ state = state || {};
+ if (state.dropdownOpen) return 'dropdown';
+ if (state.ghost) return 'ghost';
+ if (state.shiftKey) return 'dedent';
+ if (state.hasSelection) return 'indent';
+ var candidates = state.candidates || [];
+ if (candidates.length === 1) return 'word';
+ if (candidates.length > 1) return 'word-list';
+ return 'indent';
+}
+
+export {
+ suggest, searchWikilinks, detectContext,
+ MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT,
+ isWordChar, getWordFragment, findWordCompletions, normalizeGhost, chooseTabAction,
+};
diff --git a/package.json b/package.json
index 4bed760..d45f7cb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsigate",
- "version": "2.8.0",
+ "version": "2.8.1",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
diff --git a/tests/frontend/forge-completion.test.mjs b/tests/frontend/forge-completion.test.mjs
new file mode 100644
index 0000000..404d3f0
--- /dev/null
+++ b/tests/frontend/forge-completion.test.mjs
@@ -0,0 +1,207 @@
+#!/usr/bin/env node
+/**
+ * ObsiGate — Forge autocomplete tests (BUG-055).
+ *
+ * The Forge editor (`frontend/editor-poc.html`) shares its pure completion
+ * helpers with CodeMirror through `frontend/js/autocomplete.js`. This suite
+ * pins the behaviour that fixes the « Tab adds a stray space » bug:
+ *
+ * - getWordFragment / findWordCompletions — document word completion
+ * - normalizeGhost — AI inline prediction cleanup
+ * - chooseTabAction — single action per Tab press
+ *
+ * Usage: node tests/frontend/forge-completion.test.mjs
+ */
+
+import { strict as assert } from "node:assert";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import path from "node:path";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const REPO_ROOT = path.resolve(__dirname, "..", "..");
+
+const mod = await import(
+ pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "autocomplete.js")).href
+);
+const {
+ isWordChar,
+ getWordFragment,
+ findWordCompletions,
+ normalizeGhost,
+ chooseTabAction,
+} = mod;
+
+let testCount = 0;
+let failCount = 0;
+
+function test(name, fn) {
+ testCount++;
+ try {
+ fn();
+ console.log(` ✓ ${name}`);
+ } catch (err) {
+ failCount++;
+ console.error(` ✗ ${name}\n ${err.message}`);
+ process.exitCode = 1;
+ }
+}
+
+// ── isWordChar ─────────────────────────────────────────────────────────────
+test("isWordChar accepts letters, digits, _ - . /", () => {
+ for (const ch of ["a", "Z", "0", "_", "-", ".", "/"]) {
+ assert.equal(isWordChar(ch), true, `expected ${JSON.stringify(ch)} to be a word char`);
+ }
+});
+
+test("isWordChar rejects spaces and punctuation", () => {
+ for (const ch of [" ", "\n", "!", "@", "#", "(", "\t"]) {
+ assert.equal(isWordChar(ch), false, `expected ${JSON.stringify(ch)} not to be a word char`);
+ }
+ assert.equal(isWordChar(""), false);
+ assert.equal(isWordChar(undefined), false);
+});
+
+// ── getWordFragment ────────────────────────────────────────────────────────
+test("getWordFragment returns the fragment before the cursor", () => {
+ const text = "hello wor";
+ assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "wor" });
+});
+
+test("getWordFragment stops at non-word characters", () => {
+ const text = "a (tabl";
+ assert.deepEqual(getWordFragment(text, text.length), { start: 3, fragment: "tabl" });
+});
+
+test("getWordFragment returns an empty fragment at a word boundary", () => {
+ const text = "hello ";
+ assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "" });
+});
+
+test("getWordFragment handles a mid-document cursor", () => {
+ const text = "one two three";
+ // cursor right after "tw"
+ assert.deepEqual(getWordFragment(text, 6), { start: 4, fragment: "tw" });
+});
+
+// ── findWordCompletions ────────────────────────────────────────────────────
+test("findWordCompletions returns prefix matches from the document", () => {
+ const text = "table tableau tab\n";
+ const cursor = text.length;
+ assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table", "tableau"]);
+});
+
+test("findWordCompletions ignores the occurrence being typed", () => {
+ const text = "table and tab";
+ const cursor = text.length; // typing the trailing "tab"
+ assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table"]);
+});
+
+test("findWordCompletions is case-insensitive and unique", () => {
+ const text = "Table TABLE table\n";
+ const cursor = text.length;
+ assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["Table"]);
+});
+
+test("findWordCompletions completes file-like tokens", () => {
+ const text = "docs/guide.md docs/guide\n";
+ const cursor = text.length;
+ assert.deepEqual(findWordCompletions(text, cursor, "docs/", 8), ["docs/guide.md", "docs/guide"]);
+});
+
+test("findWordCompletions honours the limit", () => {
+ const text = "abc abd abe abf abg\n";
+ const cursor = text.length;
+ assert.equal(findWordCompletions(text, cursor, "ab", 3).length, 3);
+});
+
+test("findWordCompletions needs at least two characters", () => {
+ const text = "apple apricot\n";
+ assert.deepEqual(findWordCompletions(text, text.length, "a", 8), []);
+ assert.deepEqual(findWordCompletions(text, text.length, "", 8), []);
+});
+
+// ── normalizeGhost ─────────────────────────────────────────────────────────
+test("normalizeGhost strips an echoed prefix (mid-word)", () => {
+ assert.equal(normalizeGhost("familial", "famil", true), "ial");
+});
+
+test("normalizeGhost keeps only the first token for a word completion", () => {
+ // A word completion must never introduce a space.
+ assert.equal(normalizeGhost("familial and more", "famil", true), "ial");
+});
+
+test("normalizeGhost tolerates a leading space from the model", () => {
+ assert.equal(normalizeGhost(" monde", "bonjour", false), "monde");
+});
+
+test("normalizeGhost strips a repeated full context", () => {
+ assert.equal(normalizeGhost("Bonjour le monde", "Bonjour", false), " le monde".trim());
+});
+
+test("normalizeGhost returns an empty string for empty input", () => {
+ assert.equal(normalizeGhost("", "abc", true), "");
+ assert.equal(normalizeGhost(null, "abc", true), "");
+ assert.equal(normalizeGhost(" ", "abc", false), "");
+});
+
+test("normalizeGhost keeps a phrase continuation intact", () => {
+ assert.equal(normalizeGhost("la suite de la phrase.", "Voici", false), "la suite de la phrase.");
+});
+
+// ── chooseTabAction ────────────────────────────────────────────────────────
+test("chooseTabAction prioritises the open list", () => {
+ assert.equal(chooseTabAction({ dropdownOpen: true, ghost: true }), "dropdown");
+});
+
+test("chooseTabAction accepts a ghost prediction before word completion", () => {
+ assert.equal(chooseTabAction({ ghost: true, candidates: ["table"] }), "ghost");
+});
+
+test("chooseTabAction inserts a single word match", () => {
+ assert.equal(chooseTabAction({ candidates: ["table"] }), "word");
+});
+
+test("chooseTabAction opens the list for several matches", () => {
+ assert.equal(chooseTabAction({ candidates: ["table", "tableau"] }), "word-list");
+});
+
+test("chooseTabAction indents when nothing matches", () => {
+ assert.equal(chooseTabAction({ candidates: [] }), "indent");
+ assert.equal(chooseTabAction({}), "indent");
+});
+
+test("chooseTabAction dedents on Shift+Tab", () => {
+ assert.equal(chooseTabAction({ shiftKey: true }), "dedent");
+ assert.equal(chooseTabAction({ shiftKey: true, candidates: ["table"] }), "dedent");
+});
+
+test("chooseTabAction indents when a selection exists", () => {
+ assert.equal(chooseTabAction({ hasSelection: true, candidates: ["table"] }), "indent");
+});
+
+// ── Static wiring of frontend/editor-poc.html ──────────────────────────────
+import { readFileSync } from "node:fs";
+const FORGE_HTML = readFileSync(
+ path.join(REPO_ROOT, "frontend", "editor-poc.html"),
+ "utf-8",
+);
+
+test("Forge uses the shared completion helpers", () => {
+ for (const call of ["ac.getWordFragment(", "ac.findWordCompletions(", "ac.chooseTabAction("]) {
+ assert.ok(FORGE_HTML.includes(call), `editor-poc.html must call ${call}`);
+ }
+});
+
+test("Forge has a single indentation call site (no double Tab handling)", () => {
+ const indentCalls = FORGE_HTML.match(/indentLine\(\);/g) || [];
+ assert.equal(indentCalls.length, 1, "indentLine() must only be called from the unified Tab handler");
+});
+
+test("Forge ghost text no longer mirrors the whole document", () => {
+ assert.ok(!FORGE_HTML.includes("ghostOverlay.innerHTML"), "the mirror overlay must be gone");
+});
+
+// ── Summary ────────────────────────────────────────────────────────────────
+console.log(`\n${testCount} passed, ${failCount} failed`);
+process.exit(failCount > 0 ? 1 : 0);