opsiom-cli 1.5.2__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## 1.5.2
3
+ ## 1.6.0
4
+
5
+ ### Ajouts
6
+ - **Fichiers en contexte** : `/fichier <chemin>` joint un fichier texte à la prochaine question (retiré après envoi). `/fichier` liste, `/fichier effacer` retire. Option `-f/--file` (répétable) en question unique. Taille estimée en tokens affichée.
7
+ - **Recettes réutilisables** : `opsiom recette` liste les recettes intégrées (résumé, correction, reformulation, traduction, explication, idées) ; `opsiom recette <nom> "texte"`, `-f fichier` ou un pipe. Recettes personnelles dans `~/.config/opsiom/recettes/<nom>.md`, qui remplacent une recette intégrée de même nom.
8
+ - **Rendu Markdown en direct** (titres, gras, listes, tableaux, blocs de code colorés) avec l’extra `pretty` (rich). Désactivable avec `OPSIOM_PLAIN=1`.
9
+ - **Complétion** des commandes, des identifiants de modèles (`/model`) et des chemins (`/fichier`), avec historique (extra `pretty`, prompt_toolkit). Sans l’extra, la saisie reste celle de readline.
10
+ - **Jauge de quota** dans l’en-tête et dans `/quota` (orange dès 70 %, rouge dès 90 %).
11
+ - Aide `/help` regroupée par thème.
12
+
13
+ ### Changements
14
+ - `-m/--model` accepte un numéro, un nom partiel ou un identifiant, comme `/model`, et refuse les modèles hors forfait.
15
+ - Installation : `pip install "opsiom-cli[pretty]"` pour le rendu enrichi. Le CLI fonctionne sans cet extra (texte brut, readline).
16
+
17
+ ## 1.5.0
4
18
 
5
19
  ### Ajouts
6
20
  - Sous-commandes `opsiom doctor` (diagnostic), `opsiom paths` (emplacements des fichiers) et `opsiom configure`.
@@ -16,6 +30,8 @@
16
30
  - `/save` : le nom de fichier est confiné au dossier des transcriptions (plus de `../` ni de chemin absolu).
17
31
  - Fichiers `config.json` et `usage.json` écrits de façon atomique, avec droits 600.
18
32
  - Une connexion Octix refusée n’est plus prise pour une réussite lorsque le Space redirige vers `/login`.
33
+ - Connexion Octix : le formulaire `/login` est chargé puis soumis comme dans un navigateur (cookie de session et champs cachés).
34
+ - Les caractères de contrôle insérés par erreur (par exemple par un Ctrl+V sous Windows) sont retirés du mot de passe.
19
35
  - Une réponse interrompue avant l’évènement de fin est signalée comme erreur.
20
36
  - Après une reconnexion Octix, le message est renvoyé automatiquement.
21
37
  - Les réponses conservent leurs retours à la ligne et leur indentation (listes, blocs de code).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opsiom-cli
3
- Version: 1.5.2
3
+ Version: 1.6.0
4
4
  Summary: Interface en ligne de commande pour discuter avec Opsiom, l'assistant IA francophone.
5
5
  Keywords: chatbot,cli,francophone,ia,opsiom
6
6
  Classifier: Environment :: Console
@@ -13,7 +13,12 @@ Classifier: Topic :: Communications :: Chat
13
13
  Requires-Python: >=3.9
14
14
  Requires-Dist: requests>=2.31
15
15
  Provides-Extra: dev
16
+ Requires-Dist: prompt-toolkit>=3; extra == 'dev'
16
17
  Requires-Dist: pytest>=7; extra == 'dev'
18
+ Requires-Dist: rich>=13; extra == 'dev'
19
+ Provides-Extra: pretty
20
+ Requires-Dist: prompt-toolkit>=3; extra == 'pretty'
21
+ Requires-Dist: rich>=13; extra == 'pretty'
17
22
  Description-Content-Type: text/markdown
18
23
 
19
24
  # opsiom-cli
@@ -62,7 +67,9 @@ opsiom
62
67
  | `opsiom` | Session interactive. |
63
68
  | `opsiom -p "Résume ce texte"` | Pose une question, affiche la réponse et quitte. |
64
69
  | `cat notes.txt \| opsiom` | Envoie le contenu de l’entrée standard comme question. |
65
- | `opsiom -m <modèle> …` | Choisit le modèle (identifiant ou nom partiel). |
70
+ | `opsiom -f <fichier> -p "…"` | Joint un fichier texte à la question (répétable avec `-f`). |
71
+ | `opsiom recette` | Liste les recettes. `opsiom recette <nom> "texte"` applique une recette. |
72
+ | `opsiom -m <modèle> …` | Choisit le modèle (numéro, identifiant ou nom partiel). |
66
73
  | `opsiom configure` | Reconfigure le serveur et la clé d’accès. |
67
74
  | `opsiom doctor` | Vérifie l’URL, le serveur, les modèles et le quota. |
68
75
  | `opsiom paths` | Affiche les emplacements des fichiers locaux. |
@@ -81,15 +88,50 @@ Dans une session, tapez `"""` seul sur une ligne, écrivez votre texte, puis ter
81
88
  | `/save [nom]` | Exporte la conversation en Markdown dans `~/opsiom-conversations/`. |
82
89
  | `/new` | Repart d’une conversation vide. |
83
90
  | `/clear` | Efface l’écran. |
91
+ | `/fichier [chemin\|effacer]` | Joint un fichier texte à la prochaine question. Sans argument, liste les fichiers en attente. |
84
92
  | `/model [id ou numéro]` | Change de modèle. Sans argument, affiche un menu. |
85
93
  | `/models` | Liste les modèles disponibles. |
86
94
  | `/status` | Rappelle la connexion, le compte et le quota. |
87
- | `/quota` | Affiche le quota restant aujourd’hui. |
95
+ | `/quota` | Affiche la jauge et le quota restant aujourd’hui. |
88
96
  | `/doctor` | Lance le diagnostic depuis la session. |
89
97
  | `/help` | Affiche l’aide. |
90
98
  | `/quit` | Quitte (aussi `/exit`, ou Ctrl+D). |
91
99
 
92
- Raccourcis : **Entrée** envoie, **Ctrl+C** annule la saisie ou la réponse en cours, **Ctrl+D** quitte. L’historique des saisies se parcourt avec les flèches.
100
+ Raccourcis : **Entrée** envoie, **Ctrl+C** annule la saisie ou la réponse en cours, **Ctrl+D** quitte. L’historique des saisies se parcourt avec les flèches. **Tab** complète les commandes, les modèles après `/model` et les chemins après `/fichier`.
101
+
102
+ ### Joindre des fichiers
103
+
104
+ Un fichier texte (UTF-8, 200 Ko au maximum) peut être envoyé avec votre question :
105
+
106
+ ```
107
+ /fichier rapport.md
108
+ Résume les points faibles de ce rapport.
109
+ ```
110
+
111
+ Le fichier est mis en attente, envoyé avec la prochaine question, puis retiré. Une estimation du nombre de tokens est affichée (≈ caractères / 4). Les fichiers binaires, trop volumineux ou non UTF-8 sont refusés avec un message explicite.
112
+
113
+ En question unique : `opsiom -f rapport.md -p "Résume-le"`. Sans `-p`, la question par défaut est « Résume ce document. ».
114
+
115
+ ### Recettes
116
+
117
+ Les recettes sont des consignes réutilisables :
118
+
119
+ | Recette | Effet |
120
+ |---|---|
121
+ | `resume` | Résumé avec les points clés en liste. |
122
+ | `corriger` | Correction de l’orthographe, de la grammaire et de la ponctuation. |
123
+ | `reformuler` | Réécriture claire et professionnelle. |
124
+ | `traduire-en-anglais` | Traduction en anglais, ton conservé. |
125
+ | `expliquer` | Explication simple. |
126
+ | `idees` | Dix idées en lien avec le texte. |
127
+
128
+ ```bash
129
+ opsiom recette resume -f article.md
130
+ cat notes.txt | opsiom recette corriger
131
+ opsiom recette traduire-en-anglais "Bonjour à tous"
132
+ ```
133
+
134
+ Pour ajouter les vôtres, créez un fichier `<nom>.md` dans le dossier affiché par `opsiom paths` (« recettes personnelles »). Un fichier de même nom remplace la recette intégrée.
93
135
 
94
136
  ## Configuration
95
137
 
@@ -111,7 +153,18 @@ Fichiers locaux (`opsiom paths` les affiche) :
111
153
  - **historique de saisie** : pour la flèche du haut.
112
154
  - **transcriptions** : dossier des exports `/save`.
113
155
 
114
- ### Affichage
156
+ ### Affichage et extras
157
+
158
+ Le CLI fonctionne seul. Deux extras enrichissent l’expérience :
159
+
160
+ ```bash
161
+ pip install "opsiom-cli[pretty]"
162
+ ```
163
+
164
+ - **rich** : rendu Markdown en direct dans le terminal.
165
+ - **prompt_toolkit** : complétion des commandes, des modèles et des chemins, historique.
166
+
167
+ Sans ces paquets, les réponses s’affichent en texte brut et la saisie reste celle du terminal. `OPSIOM_PLAIN=1` désactive le rendu Markdown.
115
168
 
116
169
  - Les couleurs sont désactivées si `NO_COLOR` est défini, si `TERM=dumb`, ou si la sortie est redirigée vers un fichier ou un pipe.
117
170
  - Sur un terminal sans UTF-8, les symboles basculent sur de l’ASCII.
@@ -133,6 +186,9 @@ Le quota se réinitialise chaque jour. Utilisez `/quota` pour voir où vous en
133
186
  **« modèle inconnu » ou « hors forfait »**
134
187
  Tapez `/models` pour la liste. Un modèle marqué « hors forfait » n’est pas inclus dans votre offre.
135
188
 
189
+ **« fichier introuvable », « binaire », « non UTF-8 » ou « trop volumineux »**
190
+ Le fichier joint doit être un texte UTF-8 de 200 Ko au maximum. Convertissez-le ou découpez-le en plusieurs fichiers.
191
+
136
192
  **Les accents ou les couleurs s’affichent mal**
137
193
  Utilisez un terminal qui gère l’UTF-8 et les couleurs 256. Sous Windows, préférez Windows Terminal.
138
194
 
@@ -160,11 +216,14 @@ Structure :
160
216
 
161
217
  ```
162
218
  src/opsiom_cli/
163
- ├── cli.py options, configuration, question unique, sous-commandes
219
+ ├── cli.py options, configuration, question unique, recettes, sous-commandes
164
220
  ├── chat.py session interactive, commandes, diagnostic, erreurs
165
221
  ├── clients.py clients serveur API et Space Hugging Face, flux SSE
222
+ ├── files.py fichiers joints : lecture, contrôles, mise en forme
223
+ ├── recipes.py recettes intégrées et personnelles
224
+ ├── prompt.py complétion et historique (prompt_toolkit, optionnel)
166
225
  ├── storage.py chemins, configuration, quota local, transcriptions
167
- └── ui.py couleurs, symboles, indicateur d’attente, streaming
226
+ └── ui.py couleurs, symboles, jauge, indicateur d’attente, rendu Markdown (rich, optionnel)
168
227
  tests/ tests unitaires et tests de bout en bout (faux serveur)
169
228
  ```
170
229
 
@@ -44,7 +44,9 @@ opsiom
44
44
  | `opsiom` | Session interactive. |
45
45
  | `opsiom -p "Résume ce texte"` | Pose une question, affiche la réponse et quitte. |
46
46
  | `cat notes.txt \| opsiom` | Envoie le contenu de l’entrée standard comme question. |
47
- | `opsiom -m <modèle> …` | Choisit le modèle (identifiant ou nom partiel). |
47
+ | `opsiom -f <fichier> -p "…"` | Joint un fichier texte à la question (répétable avec `-f`). |
48
+ | `opsiom recette` | Liste les recettes. `opsiom recette <nom> "texte"` applique une recette. |
49
+ | `opsiom -m <modèle> …` | Choisit le modèle (numéro, identifiant ou nom partiel). |
48
50
  | `opsiom configure` | Reconfigure le serveur et la clé d’accès. |
49
51
  | `opsiom doctor` | Vérifie l’URL, le serveur, les modèles et le quota. |
50
52
  | `opsiom paths` | Affiche les emplacements des fichiers locaux. |
@@ -63,15 +65,50 @@ Dans une session, tapez `"""` seul sur une ligne, écrivez votre texte, puis ter
63
65
  | `/save [nom]` | Exporte la conversation en Markdown dans `~/opsiom-conversations/`. |
64
66
  | `/new` | Repart d’une conversation vide. |
65
67
  | `/clear` | Efface l’écran. |
68
+ | `/fichier [chemin\|effacer]` | Joint un fichier texte à la prochaine question. Sans argument, liste les fichiers en attente. |
66
69
  | `/model [id ou numéro]` | Change de modèle. Sans argument, affiche un menu. |
67
70
  | `/models` | Liste les modèles disponibles. |
68
71
  | `/status` | Rappelle la connexion, le compte et le quota. |
69
- | `/quota` | Affiche le quota restant aujourd’hui. |
72
+ | `/quota` | Affiche la jauge et le quota restant aujourd’hui. |
70
73
  | `/doctor` | Lance le diagnostic depuis la session. |
71
74
  | `/help` | Affiche l’aide. |
72
75
  | `/quit` | Quitte (aussi `/exit`, ou Ctrl+D). |
73
76
 
74
- Raccourcis : **Entrée** envoie, **Ctrl+C** annule la saisie ou la réponse en cours, **Ctrl+D** quitte. L’historique des saisies se parcourt avec les flèches.
77
+ Raccourcis : **Entrée** envoie, **Ctrl+C** annule la saisie ou la réponse en cours, **Ctrl+D** quitte. L’historique des saisies se parcourt avec les flèches. **Tab** complète les commandes, les modèles après `/model` et les chemins après `/fichier`.
78
+
79
+ ### Joindre des fichiers
80
+
81
+ Un fichier texte (UTF-8, 200 Ko au maximum) peut être envoyé avec votre question :
82
+
83
+ ```
84
+ /fichier rapport.md
85
+ Résume les points faibles de ce rapport.
86
+ ```
87
+
88
+ Le fichier est mis en attente, envoyé avec la prochaine question, puis retiré. Une estimation du nombre de tokens est affichée (≈ caractères / 4). Les fichiers binaires, trop volumineux ou non UTF-8 sont refusés avec un message explicite.
89
+
90
+ En question unique : `opsiom -f rapport.md -p "Résume-le"`. Sans `-p`, la question par défaut est « Résume ce document. ».
91
+
92
+ ### Recettes
93
+
94
+ Les recettes sont des consignes réutilisables :
95
+
96
+ | Recette | Effet |
97
+ |---|---|
98
+ | `resume` | Résumé avec les points clés en liste. |
99
+ | `corriger` | Correction de l’orthographe, de la grammaire et de la ponctuation. |
100
+ | `reformuler` | Réécriture claire et professionnelle. |
101
+ | `traduire-en-anglais` | Traduction en anglais, ton conservé. |
102
+ | `expliquer` | Explication simple. |
103
+ | `idees` | Dix idées en lien avec le texte. |
104
+
105
+ ```bash
106
+ opsiom recette resume -f article.md
107
+ cat notes.txt | opsiom recette corriger
108
+ opsiom recette traduire-en-anglais "Bonjour à tous"
109
+ ```
110
+
111
+ Pour ajouter les vôtres, créez un fichier `<nom>.md` dans le dossier affiché par `opsiom paths` (« recettes personnelles »). Un fichier de même nom remplace la recette intégrée.
75
112
 
76
113
  ## Configuration
77
114
 
@@ -93,7 +130,18 @@ Fichiers locaux (`opsiom paths` les affiche) :
93
130
  - **historique de saisie** : pour la flèche du haut.
94
131
  - **transcriptions** : dossier des exports `/save`.
95
132
 
96
- ### Affichage
133
+ ### Affichage et extras
134
+
135
+ Le CLI fonctionne seul. Deux extras enrichissent l’expérience :
136
+
137
+ ```bash
138
+ pip install "opsiom-cli[pretty]"
139
+ ```
140
+
141
+ - **rich** : rendu Markdown en direct dans le terminal.
142
+ - **prompt_toolkit** : complétion des commandes, des modèles et des chemins, historique.
143
+
144
+ Sans ces paquets, les réponses s’affichent en texte brut et la saisie reste celle du terminal. `OPSIOM_PLAIN=1` désactive le rendu Markdown.
97
145
 
98
146
  - Les couleurs sont désactivées si `NO_COLOR` est défini, si `TERM=dumb`, ou si la sortie est redirigée vers un fichier ou un pipe.
99
147
  - Sur un terminal sans UTF-8, les symboles basculent sur de l’ASCII.
@@ -115,6 +163,9 @@ Le quota se réinitialise chaque jour. Utilisez `/quota` pour voir où vous en
115
163
  **« modèle inconnu » ou « hors forfait »**
116
164
  Tapez `/models` pour la liste. Un modèle marqué « hors forfait » n’est pas inclus dans votre offre.
117
165
 
166
+ **« fichier introuvable », « binaire », « non UTF-8 » ou « trop volumineux »**
167
+ Le fichier joint doit être un texte UTF-8 de 200 Ko au maximum. Convertissez-le ou découpez-le en plusieurs fichiers.
168
+
118
169
  **Les accents ou les couleurs s’affichent mal**
119
170
  Utilisez un terminal qui gère l’UTF-8 et les couleurs 256. Sous Windows, préférez Windows Terminal.
120
171
 
@@ -142,11 +193,14 @@ Structure :
142
193
 
143
194
  ```
144
195
  src/opsiom_cli/
145
- ├── cli.py options, configuration, question unique, sous-commandes
196
+ ├── cli.py options, configuration, question unique, recettes, sous-commandes
146
197
  ├── chat.py session interactive, commandes, diagnostic, erreurs
147
198
  ├── clients.py clients serveur API et Space Hugging Face, flux SSE
199
+ ├── files.py fichiers joints : lecture, contrôles, mise en forme
200
+ ├── recipes.py recettes intégrées et personnelles
201
+ ├── prompt.py complétion et historique (prompt_toolkit, optionnel)
148
202
  ├── storage.py chemins, configuration, quota local, transcriptions
149
- └── ui.py couleurs, symboles, indicateur d’attente, streaming
203
+ └── ui.py couleurs, symboles, jauge, indicateur d’attente, rendu Markdown (rich, optionnel)
150
204
  tests/ tests unitaires et tests de bout en bout (faux serveur)
151
205
  ```
152
206
 
@@ -0,0 +1,4 @@
1
+ Remove-Item -Recurse -Force .\dist
2
+ Remove-Item -Recurse -Force .\src\opsiom_cli.egg-info
3
+ python -m build
4
+ python -m twine upload dist/*
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opsiom-cli"
7
- version = "1.5.2"
7
+ version = "1.6.0"
8
8
  description = "Interface en ligne de commande pour discuter avec Opsiom, l'assistant IA francophone."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -26,7 +26,16 @@ dependencies = [
26
26
  ]
27
27
 
28
28
  [project.optional-dependencies]
29
- dev = ["pytest>=7"]
29
+ # Rendu Markdown dans le terminal et complétion des commandes.
30
+ pretty = [
31
+ "rich>=13",
32
+ "prompt_toolkit>=3",
33
+ ]
34
+ dev = [
35
+ "pytest>=7",
36
+ "rich>=13",
37
+ "prompt_toolkit>=3",
38
+ ]
30
39
 
31
40
  [project.scripts]
32
41
  opsiom = "opsiom_cli.cli:main"
@@ -13,6 +13,8 @@ import requests
13
13
 
14
14
  from . import __version__, storage
15
15
  from .clients import ServerError, error_detail
16
+ from .files import FileError, build_message, load_text_file
17
+ from .prompt import CommandCompleter, formatted, make_prompt_session
16
18
  from .storage import (
17
19
  get_quota,
18
20
  is_insecure,
@@ -37,12 +39,13 @@ from .ui import (
37
39
  print_logo,
38
40
  print_ok,
39
41
  print_warn,
42
+ quota_bar,
40
43
  sym,
41
44
  term_width,
42
45
  )
43
46
 
44
47
  try:
45
- import readline # historique des saisies et édition de ligne
48
+ import readline # historique et édition de ligne, sans prompt_toolkit
46
49
  except ImportError: # absent sur certains Windows
47
50
  readline = None
48
51
 
@@ -56,6 +59,7 @@ HELP_GROUPS = [
56
59
  ("Conversation", [
57
60
  ("/retry", "", "relance la dernière question"),
58
61
  ("/copy", "", "copie la dernière réponse dans le presse-papiers"),
62
+ ("/fichier", "[chemin|effacer]", "joint un fichier texte à la prochaine question"),
59
63
  ("/save", "[nom]", "exporte la conversation en Markdown"),
60
64
  ("/new", "", "repart d’une conversation vide"),
61
65
  ("/clear", "", "efface l’écran"),
@@ -75,6 +79,10 @@ HELP_GROUPS = [
75
79
  ALIASES = {"exit": "quit"}
76
80
 
77
81
 
82
+ def all_commands():
83
+ return [command for _title, items in HELP_GROUPS for command in items]
84
+
85
+
78
86
  # --- Erreurs -----------------------------------------------------------------
79
87
 
80
88
  def status_of(exc):
@@ -124,6 +132,17 @@ def record_usage(client, final):
124
132
  record_quota(client.identity, {"remaining": final["remaining_quota"]})
125
133
 
126
134
 
135
+ def quota_line(client):
136
+ """Jauge et détail du quota, ou « non communiqué » si le serveur ne l'a pas donné."""
137
+ state = get_quota(client.identity)
138
+ if not state["known"]:
139
+ return muted("non communiqué par le serveur")
140
+ if state["unlimited"]:
141
+ return "illimité"
142
+ detail = f"{state['used']}/{state['limit']} tokens aujourd’hui"
143
+ return f"{quota_bar(state['used'], state['limit'])} {muted(detail)}"
144
+
145
+
127
146
  # --- Authentification (Space Hugging Face) -----------------------------------
128
147
 
129
148
  def login_interactive(client, username=None, attempts=3):
@@ -170,7 +189,7 @@ def copy_to_clipboard(text):
170
189
  return False
171
190
 
172
191
 
173
- # --- Historique et saisie ----------------------------------------------------
192
+ # --- Historique et saisie (readline, si prompt_toolkit est absent) -----------
174
193
 
175
194
  def rl_safe(text):
176
195
  """Entoure les séquences ANSI de \\x01…\\x02 pour que readline calcule
@@ -334,16 +353,18 @@ def _truncate(text, width):
334
353
 
335
354
 
336
355
  class ChatSession:
337
- def __init__(self, client, model=None):
356
+ def __init__(self, client, model=None, attachments=None):
338
357
  self.client = client
339
358
  self.current_model = model
340
359
  self.models_payload = {"models": [], "default": None}
341
360
  self.transcript = []
361
+ self.pending = list(attachments or []) # fichiers joints à la prochaine question
342
362
  self.last_user = None
343
363
  self.last_answer = None
344
364
  self.connected = False
345
365
  self.models_loaded = []
346
366
  self.problem = None # (message, indication) si le serveur pose problème
367
+ self.prompt_session = None
347
368
 
348
369
  # -- démarrage -----------------------------------------------------------
349
370
 
@@ -377,7 +398,7 @@ class ChatSession:
377
398
  if self.problem:
378
399
  print_error(*self.problem)
379
400
  self.print_header()
380
- print_hint('Tapez votre message. /help liste les commandes ; """ ouvre une saisie multiligne.')
401
+ print_hint('Tapez votre message. Tab complète les commandes ; """ ouvre une saisie multiligne.')
381
402
  print()
382
403
 
383
404
  def print_header(self):
@@ -390,9 +411,7 @@ class ChatSession:
390
411
  print(f" {paint(sym('err'), 'error')} {muted('non connecté à')} {c.base_url}")
391
412
  print(f" {muted('accès'.ljust(8))}{c.auth_label}")
392
413
  if c.tracks_quota:
393
- state = get_quota(c.identity)
394
- text = quota_text(state) if state["known"] else "non communiqué"
395
- print(f" {muted('quota'.ljust(8))}{paint(text, 'error' if quota_exhausted(state) else None)}")
414
+ print(f" {muted('quota'.ljust(8))}{quota_line(c)}")
396
415
  if self.models_loaded:
397
416
  names = " · ".join(self.models_loaded)
398
417
  print(f" {muted('modèles'.ljust(8))}{muted(_truncate(names, term_width() - 14))}")
@@ -404,25 +423,36 @@ class ChatSession:
404
423
  label = self.current_model or "opsiom"
405
424
  if len(label) > 24:
406
425
  label = label[:23] + "…"
407
- return rl_safe(f"{muted(label)} {accent(sym('prompt'))} ")
426
+ return f"{muted(label)} {accent(sym('prompt'))} "
427
+
428
+ def _ask(self, text):
429
+ if self.prompt_session is not None:
430
+ return self.prompt_session.prompt(formatted(text)).strip()
431
+ return input(rl_safe(text)).strip()
432
+
433
+ def _model_choices(self):
434
+ return [(m["id"], m.get("label", m["id"])) for m in self.models_payload.get("models", [])]
408
435
 
409
436
  # -- boucle --------------------------------------------------------------
410
437
 
411
438
  def read_input(self):
412
- text = input(self.prompt()).strip()
439
+ text = self._ask(self.prompt())
413
440
  if text != '"""':
414
441
  return text
415
442
  print_hint('saisie multiligne : terminez par """ seul sur une ligne')
416
443
  rows = []
417
444
  while True:
418
- row = input(rl_safe(muted("… ")))
445
+ row = self._ask(muted("… "))
419
446
  if row.strip() == '"""':
420
447
  break
421
448
  rows.append(row)
422
449
  return "\n".join(rows).strip()
423
450
 
424
451
  def run(self):
425
- setup_history()
452
+ self.prompt_session = make_prompt_session(
453
+ CommandCompleter(all_commands(), self._model_choices), storage.HISTORY_PATH)
454
+ if self.prompt_session is None:
455
+ setup_history()
426
456
  try:
427
457
  while True:
428
458
  try:
@@ -441,7 +471,8 @@ class ChatSession:
441
471
  else:
442
472
  self.send(line)
443
473
  finally:
444
- save_history()
474
+ if self.prompt_session is None:
475
+ save_history()
445
476
  return 0
446
477
 
447
478
  def dispatch(self, line):
@@ -466,9 +497,9 @@ class ChatSession:
466
497
  print_info(bold(title))
467
498
  for name, args, desc in items:
468
499
  usage = f"{name} {args}".rstrip()
469
- print(f" {accent(usage.ljust(22))} {muted(desc)}")
500
+ print(f" {accent(usage.ljust(26))} {muted(desc)}")
470
501
  print()
471
- print_hint("Entrée envoie · \"\"\" saisie multiligne · Ctrl+C annule la saisie · Ctrl+D quitte")
502
+ print_hint("Entrée envoie · \"\"\" saisie multiligne · Tab complète · Ctrl+C annule · Ctrl+D quitte")
472
503
 
473
504
  def cmd_clear(self, _arg):
474
505
  sys.stdout.write("\033[H\033[2J")
@@ -482,23 +513,48 @@ class ChatSession:
482
513
  if not self.client.tracks_quota:
483
514
  print_hint("pas de quota : aucune clé API configurée.")
484
515
  return
485
- state = get_quota(self.client.identity)
486
- if not state["known"]:
487
- print_hint("quota non communiqué par le serveur pour l’instant.")
488
- elif state["unlimited"]:
489
- print_hint("quota illimité sur ce compte.")
490
- else:
491
- print_hint(f"{state['used']}/{state['limit']} tokens utilisés aujourd’hui — "
492
- f"{state['remaining']} restants.")
516
+ print(f" {quota_line(self.client)}")
493
517
 
494
518
  def cmd_new(self, _arg):
495
519
  self.transcript = []
520
+ self.pending = []
496
521
  self.last_user = self.last_answer = None
497
522
  if self.client.new_conversation():
498
523
  print_ok("nouvelle conversation (historique du compte réinitialisé côté serveur).")
499
524
  else:
500
525
  print_ok("nouvelle conversation. Ce serveur ne garde pas d’historique.")
501
526
 
527
+ def cmd_fichier(self, arg):
528
+ if not arg:
529
+ if not self.pending:
530
+ print_hint("aucun fichier joint. Usage : /fichier <chemin>")
531
+ return
532
+ for item in self.pending:
533
+ print_info(f"{item.name} ≈ {item.tokens} tokens")
534
+ print_hint("envoyé avec votre prochaine question · /fichier effacer pour retirer")
535
+ return
536
+ if arg.lower() in ("effacer", "clear"):
537
+ self.pending = []
538
+ print_ok("pièces jointes retirées.")
539
+ return
540
+ try:
541
+ item = load_text_file(arg)
542
+ except FileError as exc:
543
+ print_error(str(exc))
544
+ return
545
+ self.pending.append(item)
546
+ print_ok(f"joint : {item.name} (≈ {item.tokens} tokens). Il sera envoyé avec votre prochaine question.")
547
+
548
+ def cmd_copy(self, _arg):
549
+ if not self.last_answer:
550
+ print_hint("aucune réponse à copier.")
551
+ return
552
+ if copy_to_clipboard(self.last_answer):
553
+ print_ok("réponse copiée dans le presse-papiers.")
554
+ else:
555
+ print_warn("aucun outil de presse-papiers détecté.",
556
+ "installez xclip, wl-copy ou xsel (Linux), ou utilisez /save.")
557
+
502
558
  def cmd_save(self, arg):
503
559
  if not self.transcript:
504
560
  print_hint("rien à enregistrer pour l’instant.")
@@ -542,16 +598,6 @@ class ChatSession:
542
598
  return
543
599
  self.send(self.last_user)
544
600
 
545
- def cmd_copy(self, _arg):
546
- if not self.last_answer:
547
- print_hint("aucune réponse à copier.")
548
- return
549
- if copy_to_clipboard(self.last_answer):
550
- print_ok("réponse copiée dans le presse-papiers.")
551
- else:
552
- print_warn("aucun outil de presse-papiers détecté.",
553
- "installez xclip, wl-copy ou xsel (Linux), ou utilisez /save.")
554
-
555
601
  def cmd_doctor(self, _arg):
556
602
  run_doctor(self.client)
557
603
 
@@ -567,15 +613,19 @@ class ChatSession:
567
613
  "réessayez demain, ou changez de clé API avec : opsiom configure")
568
614
  return
569
615
 
570
- self.transcript.append({"role": "user", "text": message})
616
+ names = [item.name for item in self.pending]
617
+ payload = build_message(message, self.pending)
618
+ shown = message + (f" [fichier : {', '.join(names)}]" if names else "")
619
+ self.transcript.append({"role": "user", "text": shown})
571
620
  self.last_user = message
621
+
572
622
  spinner = Spinner("Réflexion", hint="Ctrl+C pour annuler")
573
623
  printer = None
574
624
  final = {}
575
625
  started = time.perf_counter()
576
626
  spinner.start()
577
627
  try:
578
- with closing(self.client.chat_stream(message, model=self.current_model)) as stream:
628
+ with closing(self.client.chat_stream(payload, model=self.current_model)) as stream:
579
629
  for piece in stream:
580
630
  if isinstance(piece, dict):
581
631
  final = piece
@@ -626,6 +676,7 @@ class ChatSession:
626
676
  answer = printer.finish()
627
677
  elapsed_ms = int((time.perf_counter() - started) * 1000)
628
678
  self.last_answer = answer
679
+ self.pending = [] # les fichiers joints ont été envoyés
629
680
  self.transcript.append({"role": "assistant", "text": answer,
630
681
  "model": final.get("model") or self.current_model or "?",
631
682
  "elapsed_ms": elapsed_ms})