omni-ai-cli 1.1.0 → 1.3.0

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.
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![npm](https://img.shields.io/npm/v/omni-ai-cli?color=C4552F)](https://www.npmjs.com/package/omni-ai-cli)
8
8
  [![downloads](https://img.shields.io/npm/dm/omni-ai-cli?color=1A1714)](https://www.npmjs.com/package/omni-ai-cli)
9
9
  [![provenance](https://img.shields.io/badge/npm-OIDC%20trusted%20publishing-C4552F)](https://docs.npmjs.com/trusted-publishers)
10
+ [![coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/samuelrms/omni-ai-cli/badges/coverage.json)](https://github.com/samuelrms/omni-ai-cli/actions/workflows/coverage.yml)
10
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-C4552F.svg)](LICENSE)
11
12
  ![Plataformas](https://img.shields.io/badge/macOS%20·%20Linux%20·%20Windows-1A1714)
12
13
  ![Idiomas](https://img.shields.io/badge/pt--BR%20·%20en%20·%20es%20·%20fr-C4552F)
@@ -388,6 +389,8 @@ ai --install # instala as ferramentas que faltam (perg
388
389
  ai --classify "prompt" # mostra como seria classificado
389
390
  ai --log # últimas chamadas
390
391
  ai doctor # diagnóstico de instalação, contas e chaves
392
+ ai --version # versão do Omni AI CLI
393
+ ai --completions zsh|bash # autocomplete do shell
391
394
  ```
392
395
 
393
396
  ---
@@ -449,7 +452,7 @@ python3 -m unittest discover -s tests -v
449
452
  bash -n scripts/install.sh
450
453
  ```
451
454
 
452
- **106 testes**: persistência e retomada de sessão, limite de contexto, permissão dos
455
+ **123 testes**: persistência e retomada de sessão, limite de contexto, permissão dos
453
456
  arquivos, sessão aberta em dois terminais, leitura de eventos, cancelamento da árvore de
454
457
  processos, paralelismo real, sandbox somente leitura, mascaramento de dados pessoais,
455
458
  fallback OpenRouter → Codex, validação de modelos, armazenamento das chaves e o ciclo de vida das contas. Todos os
@@ -543,7 +546,6 @@ guardar nem rotacionar.
543
546
  arquivos. Os agentes não editam o projeto ao mesmo tempo.
544
547
  - A classificação simples/complexa é por palavras-chave e tamanho, não por um modelo.
545
548
  - Trocar de modelo não renova a cota da conta.
546
- - O OpenRouter não tem streaming nesta versão: a resposta aparece inteira no final.
547
549
  - No Windows, o código usa os equivalentes nativos (grupo de processos e `taskkill`, `msvcrt`, junções de pasta), mas foi testado de verdade só no macOS.
548
550
 
549
551
  ---
package/docs/README.en.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![npm](https://img.shields.io/npm/v/omni-ai-cli?color=C4552F)](https://www.npmjs.com/package/omni-ai-cli)
8
8
  [![downloads](https://img.shields.io/npm/dm/omni-ai-cli?color=1A1714)](https://www.npmjs.com/package/omni-ai-cli)
9
9
  [![provenance](https://img.shields.io/badge/npm-OIDC%20trusted%20publishing-C4552F)](https://docs.npmjs.com/trusted-publishers)
10
+ [![coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/samuelrms/omni-ai-cli/badges/coverage.json)](https://github.com/samuelrms/omni-ai-cli/actions/workflows/coverage.yml)
10
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-C4552F.svg)](../LICENSE)
11
12
  ![Platforms](https://img.shields.io/badge/macOS%20·%20Linux%20·%20Windows-1A1714)
12
13
  ![Languages](https://img.shields.io/badge/pt--BR%20·%20en%20·%20es%20·%20fr-C4552F)
@@ -387,6 +388,8 @@ ai --install # installs the missing tools (asks first)
387
388
  ai --classify "prompt" # shows how it would be classified
388
389
  ai --log # latest calls
389
390
  ai doctor # diagnose install, accounts and keys
391
+ ai --version # Omni AI CLI version
392
+ ai --completions zsh|bash # shell autocomplete
390
393
  ```
391
394
 
392
395
  ---
@@ -448,7 +451,7 @@ python3 -m unittest discover -s tests -v
448
451
  bash -n scripts/install.sh
449
452
  ```
450
453
 
451
- **106 tests**: session persistence and resume, context limit, file permissions, session
454
+ **123 tests**: session persistence and resume, context limit, file permissions, session
452
455
  open in two terminals, event reading, process tree cancellation, real parallelism,
453
456
  read-only sandbox, personal data masking, OpenRouter → Codex fallback, model
454
457
  validation, key storage and the account lifecycle. All the
@@ -542,7 +545,6 @@ The workflow already requests `id-token: write` and `NPM_CONFIG_PROVENANCE`; the
542
545
  access the files. The agents do not edit the project at the same time.
543
546
  - The simple/complex classification is by keywords and length, not by a model.
544
547
  - Switching models does not renew the account's quota.
545
- - OpenRouter has no streaming in this version: the answer appears whole at the end.
546
548
  - On Windows the code uses the native equivalents (process groups and `taskkill`, `msvcrt`, folder junctions), but it has only been tested for real on macOS.
547
549
 
548
550
  ---
package/docs/README.es.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![npm](https://img.shields.io/npm/v/omni-ai-cli?color=C4552F)](https://www.npmjs.com/package/omni-ai-cli)
8
8
  [![downloads](https://img.shields.io/npm/dm/omni-ai-cli?color=1A1714)](https://www.npmjs.com/package/omni-ai-cli)
9
9
  [![provenance](https://img.shields.io/badge/npm-OIDC%20trusted%20publishing-C4552F)](https://docs.npmjs.com/trusted-publishers)
10
+ [![coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/samuelrms/omni-ai-cli/badges/coverage.json)](https://github.com/samuelrms/omni-ai-cli/actions/workflows/coverage.yml)
10
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-C4552F.svg)](../LICENSE)
11
12
  ![Plataformas](https://img.shields.io/badge/macOS%20·%20Linux%20·%20Windows-1A1714)
12
13
  ![Idiomas](https://img.shields.io/badge/pt--BR%20·%20en%20·%20es%20·%20fr-C4552F)
@@ -388,6 +389,8 @@ ai --install # instala las herramientas que faltan (pr
388
389
  ai --classify "prompt" # muestra cómo se clasificaría
389
390
  ai --log # últimas llamadas
390
391
  ai doctor # diagnóstico de instalación, cuentas y claves
392
+ ai --version # versión de Omni AI CLI
393
+ ai --completions zsh|bash # autocompletado del shell
391
394
  ```
392
395
 
393
396
  ---
@@ -449,7 +452,7 @@ python3 -m unittest discover -s tests -v
449
452
  bash -n scripts/install.sh
450
453
  ```
451
454
 
452
- **106 pruebas**: persistencia y reanudación de sesión, límite de contexto, permisos de
455
+ **123 pruebas**: persistencia y reanudación de sesión, límite de contexto, permisos de
453
456
  los archivos, sesión abierta en dos terminales, lectura de eventos, cancelación del árbol
454
457
  de procesos, paralelismo real, sandbox de solo lectura, enmascaramiento de datos
455
458
  personales, fallback OpenRouter → Codex, validación de modelos, almacenamiento de las
@@ -545,7 +548,6 @@ guardar ni rotar.
545
548
  a los archivos. Los agentes no editan el proyecto al mismo tiempo.
546
549
  - La clasificación simple/compleja se hace por palabras clave y tamaño, no con un modelo.
547
550
  - Cambiar de modelo no renueva la cuota de la cuenta.
548
- - OpenRouter no tiene streaming en esta versión: la respuesta aparece completa al final.
549
551
  - En Windows el código usa los equivalentes nativos (grupos de procesos y `taskkill`, `msvcrt`, uniones de carpeta), pero solo se probó de verdad en macOS.
550
552
 
551
553
  ---
package/docs/README.fr.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![npm](https://img.shields.io/npm/v/omni-ai-cli?color=C4552F)](https://www.npmjs.com/package/omni-ai-cli)
8
8
  [![downloads](https://img.shields.io/npm/dm/omni-ai-cli?color=1A1714)](https://www.npmjs.com/package/omni-ai-cli)
9
9
  [![provenance](https://img.shields.io/badge/npm-OIDC%20trusted%20publishing-C4552F)](https://docs.npmjs.com/trusted-publishers)
10
+ [![coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/samuelrms/omni-ai-cli/badges/coverage.json)](https://github.com/samuelrms/omni-ai-cli/actions/workflows/coverage.yml)
10
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-C4552F.svg)](../LICENSE)
11
12
  ![Plateformes](https://img.shields.io/badge/macOS%20·%20Linux%20·%20Windows-1A1714)
12
13
  ![Langues](https://img.shields.io/badge/pt--BR%20·%20en%20·%20es%20·%20fr-C4552F)
@@ -397,6 +398,8 @@ ai --install # installe les outils manquants (demande
397
398
  ai --classify "prompt" # montre la classification
398
399
  ai --log # derniers appels
399
400
  ai doctor # diagnostic installation, comptes et clés
401
+ ai --version # version d'Omni AI CLI
402
+ ai --completions zsh|bash # autocomplétion du shell
400
403
  ```
401
404
 
402
405
  ---
@@ -458,7 +461,7 @@ python3 -m unittest discover -s tests -v
458
461
  bash -n scripts/install.sh
459
462
  ```
460
463
 
461
- **106 tests** : persistance et reprise de session, limite de contexte, permissions des
464
+ **123 tests** : persistance et reprise de session, limite de contexte, permissions des
462
465
  fichiers, session ouverte dans deux terminaux, lecture des événements, annulation de
463
466
  l'arbre de processus, parallélisme réel, sandbox en lecture seule, masquage des données
464
467
  personnelles, fallback OpenRouter → Codex, validation des modèles, stockage des clés et
@@ -555,8 +558,6 @@ Le workflow demande déjà `id-token: write` et `NPM_CONFIG_PROVENANCE` ; il n'y
555
558
  - La classification simple/complexe repose sur des mots-clés et la taille, pas sur un
556
559
  modèle.
557
560
  - Changer de modèle ne renouvelle pas le quota du compte.
558
- - OpenRouter n'a pas de streaming dans cette version : la réponse apparaît en entier à
559
- la fin.
560
561
  - Sous Windows le code utilise les équivalents natifs (groupes de processus et `taskkill`, `msvcrt`, jonctions de dossier), mais il n'a été testé pour de vrai que sous macOS.
561
562
 
562
563
  ---
package/omni_ai/chat.py CHANGED
@@ -154,6 +154,7 @@ T = {
154
154
  /account [list|add|login|remove] [codex|agy|grok] NOME Contas (Claude por padrão; quantas quiser)
155
155
  /key [add|remove] [{keys}] [NOME] Chaves de API (várias por ferramenta, usadas em ordem)
156
156
  /status /reset Status das contas / zerar cooldowns
157
+ /doctor Diagnóstico de instalação, contas e chaves
157
158
  /sync [--dry-run] Compartilhar MCPs e skills entre Claude, Codex e agy
158
159
  /mode default|plan|edits|auto|yolo Modo de permissão (vale para Claude, Codex e agy)
159
160
  /setup [install] Guia de primeiro acesso; install instala as ferramentas que faltam
@@ -177,6 +178,7 @@ T = {
177
178
  /account [list|add|login|remove] [codex|agy|grok] NAME Accounts (Claude by default; as many as you want)
178
179
  /key [add|remove] [{keys}] [NAME] API keys (several per tool, tried in order)
179
180
  /status /reset Account status / clear cooldowns
181
+ /doctor Diagnose install, accounts and keys
180
182
  /sync [--dry-run] Share MCPs and skills across Claude, Codex and agy
181
183
  /mode default|plan|edits|auto|yolo Permission mode (applies to Claude, Codex and agy)
182
184
  /setup [install] First-run guide; install installs the missing tools
@@ -200,6 +202,7 @@ T = {
200
202
  /account [list|add|login|remove] [codex|agy|grok] NOMBRE Cuentas (Claude por defecto; las que quieras)
201
203
  /key [add|remove] [{keys}] [NOMBRE] Claves de API (varias por herramienta, en orden)
202
204
  /status /reset Estado de cuentas / limpiar cooldowns
205
+ /doctor Diagnóstico de instalación, cuentas y claves
203
206
  /sync [--dry-run] Compartir MCPs y skills entre Claude, Codex y agy
204
207
  /mode default|plan|edits|auto|yolo Modo de permisos (Claude, Codex y agy)
205
208
  /setup [install] Guía de primer acceso; install instala las herramientas que faltan
@@ -223,6 +226,7 @@ T = {
223
226
  /account [list|add|login|remove] [codex|agy|grok] NOM Comptes (Claude par défaut ; autant que voulu)
224
227
  /key [add|remove] [{keys}] [NOM] Clés API (plusieurs par outil, essayées dans l'ordre)
225
228
  /status /reset État des comptes / réinitialiser les cooldowns
229
+ /doctor Diagnostic installation, comptes et clés
226
230
  /sync [--dry-run] Partager MCP et skills entre Claude, Codex et agy
227
231
  /mode default|plan|edits|auto|yolo Mode de permissions (Claude, Codex et agy)
228
232
  /setup [install] Guide de démarrage ; install installe les outils manquants
@@ -477,10 +481,24 @@ class Runner:
477
481
  if job.get('dispatches'):
478
482
  job['dispatches'][-1]['sent'] = event['data']
479
483
  view.note('dados pessoais mascarados antes do envio · /sent para ver')
484
+ elif kind == 'smart.delta':
485
+ # Stream the answer line by line as the tokens arrive (OpenRouter).
486
+ job['stream_buf'] = job.get('stream_buf', '') + event.get('text', '')
487
+ job['streamed'] = True
488
+ while '\n' in job['stream_buf']:
489
+ line, job['stream_buf'] = job['stream_buf'].split('\n', 1)
490
+ view.stream_line(line)
480
491
  elif kind == 'smart.answer':
481
492
  job['model'] = event.get('model')
482
493
  spend(job, event.get('usage') or {})
483
- self.answer(job, event.get('content', ''))
494
+ if job.get('streamed'):
495
+ if job.get('stream_buf'):
496
+ view.stream_line(job['stream_buf'])
497
+ job['stream_buf'] = ''
498
+ # the streamed lines were display-only; keep the full, unmasked answer for the session
499
+ job['answers'] = [event.get('content', '')]
500
+ else:
501
+ self.answer(job, event.get('content', ''))
484
502
  view.note('modelo: ' + str(event.get('model', '?')))
485
503
  elif kind in ('item.started', 'item.completed'):
486
504
  item = event.get('item', {})
@@ -1016,7 +1034,7 @@ def compact(session):
1016
1034
 
1017
1035
 
1018
1036
  TOOL_COMMANDS = {'/usage': ['--usage'], '/limits': ['--usage'], '/model': ['--model'], '/account': ['--account'], '/key': ['--key'],
1019
- '/status': ['--status'], '/sync': ['--sync'], '/update': ['--update'], '/reset': ['--reset']}
1037
+ '/status': ['--status'], '/doctor': ['--doctor'], '/sync': ['--sync'], '/update': ['--update'], '/reset': ['--reset']}
1020
1038
 
1021
1039
 
1022
1040
  def run_tool(args):
@@ -1027,7 +1045,7 @@ def run_tool(args):
1027
1045
  pass
1028
1046
 
1029
1047
 
1030
- BUILTIN = ['/usage', '/clear', '/compact', '/queue', '/versions', '/setup', '/terminal-setup', '/tool', '/model', '/account', '/key', '/status', '/reset', '/sync', '/update', '/mode', '/lang',
1048
+ BUILTIN = ['/usage', '/clear', '/compact', '/queue', '/versions', '/setup', '/terminal-setup', '/tool', '/model', '/account', '/key', '/status', '/doctor', '/reset', '/sync', '/update', '/mode', '/lang',
1031
1049
  '/skills', '/history', '/sent', '/trace', '/sessions', '/resume', '/new', '/parallel', '/help', '/exit']
1032
1050
 
1033
1051
 
package/omni_ai/cli.py CHANGED
@@ -690,6 +690,37 @@ def ai_version():
690
690
  return '?'
691
691
 
692
692
 
693
+ def completion_words():
694
+ """Every top-level word `ai` accepts, for shell completion."""
695
+ flags = ['--model', '--account', '--key', '--keys', '--sync', '--usage', '--limits', '--setup',
696
+ '--install', '--status', '--doctor', '--reset', '--log', '--classify', '--update',
697
+ '--mode', '--chat', '--resume', '--sessions', '--new', '--help', '--version', '--completions']
698
+ return flags + ['--' + name for name in TOOLS] + ['doctor']
699
+
700
+
701
+ def completions(shell):
702
+ """A completion script for `ai`, printed for the user to source. Completes the top-level flags."""
703
+ words = ' '.join(completion_words())
704
+ if shell == 'bash':
705
+ say(f'''# Omni AI CLI bash completion. Install: ai --completions bash > ~/.ai-completion.bash
706
+ # then add to ~/.bashrc: source ~/.ai-completion.bash
707
+ _ai_complete() {{
708
+ local cur="${{COMP_WORDS[COMP_CWORD]}}"
709
+ COMPREPLY=( $(compgen -W "{words}" -- "$cur") )
710
+ }}
711
+ complete -F _ai_complete ai''')
712
+ return 0
713
+ if shell == 'zsh':
714
+ say(f'''#compdef ai
715
+ # Omni AI CLI zsh completion. Install: ai --completions zsh > ~/.zsh/_ai
716
+ # then ensure that dir is on $fpath before compinit in ~/.zshrc
717
+ _ai() {{ compadd -- {words} }}
718
+ compdef _ai ai''')
719
+ return 0
720
+ say(f'{RED}✗ Shell não suportado: {shell}. Use zsh ou bash.{NC}')
721
+ return 1
722
+
723
+
693
724
  def doctor():
694
725
  """Health check: what is installed, logged in and configured, and what is missing. Returns 1 when
695
726
  nothing can answer a request (no Claude login and no API key), 0 otherwise."""
@@ -987,6 +1018,8 @@ USAGE = f'''{BOLD}Uso:{NC}
987
1018
  ai --install Instala as ferramentas que faltam (pergunta antes)
988
1019
  ai --status | --doctor | --reset | --log | --classify "prompt"
989
1020
  ai --update Atualizar para a versão mais nova
1021
+ ai --version Versão do Omni AI CLI
1022
+ ai --completions zsh|bash Script de autocomplete do shell
990
1023
  '''
991
1024
 
992
1025
 
@@ -1037,6 +1070,11 @@ def main(argv):
1037
1070
  return 0
1038
1071
  elif arg in ('--doctor', 'doctor'):
1039
1072
  return doctor()
1073
+ elif arg in ('--version', '-v'):
1074
+ say(ai_version())
1075
+ return 0
1076
+ elif arg == '--completions':
1077
+ return completions(argv[i + 1] if i + 1 < len(argv) else '')
1040
1078
  elif arg in ('--setup', '--install'):
1041
1079
  from . import onboarding
1042
1080
  onboarding.main(install_missing=arg == '--install' or 'install' in argv[i + 1:])
@@ -105,6 +105,55 @@ class OpenRouterClient:
105
105
  except urllib.error.URLError as e:
106
106
  raise RuntimeError("Falha de rede ao acessar OpenRouter") from None
107
107
 
108
+ def _stream_request(self, data: dict):
109
+ """POST /chat/completions with stream=True, yielding ('delta', text) per token and a final
110
+ ('final', {content, model, usage}). Raises RuntimeError on any failure, like _request."""
111
+ if os.environ.get('SMART_AI_EVENTS') == '1':
112
+ print(json.dumps({'type': 'smart.request', 'data': data}, ensure_ascii=False), file=sys.stderr, flush=True)
113
+ headers = {
114
+ "Content-Type": "application/json",
115
+ "HTTP-Referer": "https://github.com/samuelrms/omni-ai-cli",
116
+ "X-Title": "Omni AI CLI",
117
+ "Accept": "text/event-stream",
118
+ }
119
+ if self.api_key:
120
+ headers["Authorization"] = f"Bearer {self.api_key}"
121
+ body = json.dumps(dict(data, stream=True, stream_options={"include_usage": True})).encode()
122
+ req = urllib.request.Request(f"{OPENROUTER_BASE}/chat/completions", data=body, headers=headers, method="POST")
123
+ content, model, usage = [], None, {}
124
+ try:
125
+ with urllib.request.urlopen(req, timeout=60) as resp:
126
+ for raw in resp:
127
+ line = raw.decode("utf-8", "replace").strip()
128
+ if not line.startswith("data:"):
129
+ continue
130
+ payload = line[5:].strip()
131
+ if payload == "[DONE]":
132
+ break
133
+ try:
134
+ chunk = json.loads(payload)
135
+ except ValueError:
136
+ continue
137
+ model = chunk.get("model") or model
138
+ usage = chunk.get("usage") or usage
139
+ for choice in chunk.get("choices", []):
140
+ piece = (choice.get("delta") or {}).get("content") or ""
141
+ if piece:
142
+ content.append(piece)
143
+ yield ("delta", piece)
144
+ except urllib.error.HTTPError as e:
145
+ try:
146
+ reason = json.loads(e.read().decode())["error"]["message"][:160]
147
+ except (ValueError, KeyError, TypeError, OSError):
148
+ reason = ""
149
+ e.close()
150
+ raise RuntimeError(f"OpenRouter HTTP {e.code}" + (f": {reason}" if reason else "")) from None
151
+ except (ValueError, TimeoutError):
152
+ raise RuntimeError("Resposta inválida ou timeout do OpenRouter") from None
153
+ except urllib.error.URLError:
154
+ raise RuntimeError("Falha de rede ao acessar OpenRouter") from None
155
+ yield ("final", {"content": "".join(content), "model": model, "usage": usage})
156
+
108
157
  def list_free_models(self) -> list:
109
158
  """Lista todos os modelos gratuitos disponíveis."""
110
159
  resp = self._request("/models")
@@ -122,6 +171,7 @@ class OpenRouterClient:
122
171
  system_prompt: str = None,
123
172
  temperature: float = 0.7,
124
173
  max_tokens: int = 4096,
174
+ on_delta=None,
125
175
  ) -> dict:
126
176
  """
127
177
  Envia um prompt e retorna a resposta.
@@ -185,10 +235,26 @@ class OpenRouterClient:
185
235
  if not self.allow_paid:
186
236
  data["provider"] = {"max_price": {"prompt": 0, "completion": 0}}
187
237
  try:
188
- resp = self._request("/chat/completions", data=data, method="POST")
189
- if resp.get("choices"):
190
- break
191
- last_error = RuntimeError("OpenRouter não retornou uma resposta válida")
238
+ if on_delta is not None:
239
+ # Stream the tokens as they arrive; a chunk may still be a masked placeholder, so
240
+ # unmask each piece best-effort and unmask the whole answer again at the end.
241
+ final = None
242
+ for kind, payload in self._stream_request(data):
243
+ if kind == "delta":
244
+ piece = self.masker.unmask(payload, self._pii_map) if (self.masker and self._pii_map) else payload
245
+ on_delta(piece)
246
+ else:
247
+ final = payload
248
+ if final and final["content"].strip():
249
+ resp = {"choices": [{"message": {"content": final["content"]}}],
250
+ "model": final["model"], "usage": final["usage"]}
251
+ break
252
+ last_error = RuntimeError("OpenRouter não retornou uma resposta válida")
253
+ else:
254
+ resp = self._request("/chat/completions", data=data, method="POST")
255
+ if resp.get("choices"):
256
+ break
257
+ last_error = RuntimeError("OpenRouter não retornou uma resposta válida")
192
258
  except RuntimeError as error:
193
259
  last_error = error
194
260
  # Bad key or no credit: another model won't help
@@ -310,19 +376,31 @@ def main():
310
376
  parser.print_help()
311
377
  sys.exit(1)
312
378
 
379
+ events = os.environ.get('SMART_AI_EVENTS') == '1'
380
+ # Stream tokens for the chat (events) and for a human at a terminal; keep --json buffered.
381
+ if events:
382
+ on_delta = lambda piece: print(json.dumps({'type': 'smart.delta', 'text': piece}, ensure_ascii=False), flush=True)
383
+ elif not args.json:
384
+ on_delta = lambda piece: (sys.stdout.write(piece), sys.stdout.flush())
385
+ else:
386
+ on_delta = None
387
+
313
388
  try:
314
389
  result = client.chat(
315
390
  prompt=prompt,
316
391
  system_prompt=args.system,
317
392
  temperature=args.temperature,
393
+ on_delta=on_delta,
318
394
  )
319
395
 
320
- if os.environ.get('SMART_AI_EVENTS') == '1':
396
+ if events:
321
397
  print(json.dumps({'type': 'smart.answer', **result}, ensure_ascii=False), flush=True)
322
398
  elif args.json:
323
399
  print(json.dumps(result, indent=2, ensure_ascii=False))
324
- else:
400
+ elif on_delta is None:
325
401
  print(result["content"])
402
+ else:
403
+ print() # end the streamed line
326
404
 
327
405
  except RuntimeError as e:
328
406
  print(f"❌ Erro: {e}", file=sys.stderr)
package/omni_ai/ui.py CHANGED
@@ -214,10 +214,20 @@ class View:
214
214
  def __init__(self, prefix=''):
215
215
  self.prefix = prefix
216
216
  self.pending = None # last action, so its result can be attached under it
217
+ self._streamed = False # whether a streaming answer has started printing
217
218
 
218
219
  def _p(self, text):
219
220
  out('\n'.join(self.prefix + line for line in text.split('\n')))
220
221
 
222
+ def stream_line(self, line):
223
+ """One completed line of a streaming answer, printed as it arrives (line-granular keeps the
224
+ chat's input box intact, unlike raw partial writes). The first line carries the answer mark."""
225
+ if not self._streamed:
226
+ self._p('\n' + gradient(MARK) + ' ' + line)
227
+ self._streamed = True
228
+ else:
229
+ self._p(' ' + line)
230
+
221
231
  def provider(self, tool, detail):
222
232
  self._p(dim(' ') + rgb(TOOL_RGB.get(tool, (6, 182, 212)), '● ' + tool) + (dim(' · ' + detail) if detail else ''))
223
233
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-ai-cli",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "One chat for Claude Code, Codex, Antigravity and OpenRouter, with automatic routing, account fallback, API keys, permission modes and MCP/skill sync.",
5
5
  "bin": {
6
6
  "ai": "bin/ai.js",