@leg3ndy/otto-bridge 1.1.0 → 1.1.2

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
@@ -15,7 +15,7 @@ Para o estado atual da arquitetura, capacidades entregues, limitacoes e roadmap
15
15
 
16
16
  Para o corte de arquitetura do `0.9.0`, veja [`leg3ndy-ai-backend/docs/otto-bridge/releases/OTTO_BRIDGE_0_9_0_RELEASE.md`](../leg3ndy-ai-backend/docs/otto-bridge/releases/OTTO_BRIDGE_0_9_0_RELEASE.md).
17
17
 
18
- Para a release atual `1.1.0`, que consolida o baseline TTY single-line e inaugura a rail de coding com `Commit`, `Workspace` e `Snapshots`, veja [`leg3ndy-ai-backend/docs/otto-bridge/releases/OTTO_BRIDGE_1_1_0_RELEASE.md`](../leg3ndy-ai-backend/docs/otto-bridge/releases/OTTO_BRIDGE_1_1_0_RELEASE.md).
18
+ Para o patch atual `1.1.2`, veja [`leg3ndy-ai-backend/docs/otto-bridge/releases/OTTO_BRIDGE_1_1_2_PATCH.md`](../leg3ndy-ai-backend/docs/otto-bridge/releases/OTTO_BRIDGE_1_1_2_PATCH.md). Para o corte funcional da linha `1.1.0`, veja [`leg3ndy-ai-backend/docs/otto-bridge/releases/OTTO_BRIDGE_1_1_0_RELEASE.md`](../leg3ndy-ai-backend/docs/otto-bridge/releases/OTTO_BRIDGE_1_1_0_RELEASE.md).
19
19
 
20
20
  ## Distribuicao
21
21
 
@@ -38,14 +38,14 @@ Enquanto o pacote nao estiver publicado, voce pode gerar um tarball local:
38
38
 
39
39
  ```bash
40
40
  npm pack
41
- npm install -g ./leg3ndy-otto-bridge-1.1.0.tgz
41
+ npm install -g ./leg3ndy-otto-bridge-1.1.2.tgz
42
42
  ```
43
43
 
44
- Na linha `1.1.0`, `playwright` segue como dependencia obrigatoria no `otto-bridge`. O primeiro `npm install -g @leg3ndy/otto-bridge` pode demorar mais porque instala o browser persistente usado pelo WhatsApp Web e pelos fluxos web em background do bridge.
44
+ Na linha `1.1.2`, `playwright` segue como dependencia obrigatoria no `otto-bridge`. O primeiro `npm install -g @leg3ndy/otto-bridge` pode demorar mais porque instala o browser persistente usado pelo WhatsApp Web e pelos fluxos web em background do bridge.
45
45
 
46
- No macOS, a linha `1.1.0` usa o provider `macos-helper`, um helper `WKWebView` sem Dock para o WhatsApp Web. O helper sobe com user-agent de Chrome moderno para evitar o bloqueio do WhatsApp ao detectar Safari/WebKit. O runtime antigo com Chromium/Playwright fica disponivel apenas como override explicito via `OTTO_BRIDGE_WHATSAPP_RUNTIME_PROVIDER=embedded-playwright`.
46
+ No macOS, a linha `1.1.2` usa o provider `macos-helper`, um helper `WKWebView` sem Dock para o WhatsApp Web. O helper sobe com user-agent de Chrome moderno para evitar o bloqueio do WhatsApp ao detectar Safari/WebKit. O runtime antigo com Chromium/Playwright fica disponivel apenas como override explicito via `OTTO_BRIDGE_WHATSAPP_RUNTIME_PROVIDER=embedded-playwright`.
47
47
 
48
- No nivel arquitetural, o `0.9.0` marcou a mudanca de papel do bridge: ele publica tools e resultados estruturados para o Otto, em vez de injetar resposta pronta como caminho principal do chat. O `1.0.0` oficializou isso como runtime agentico; o `1.1.0` preserva o prompt TTY single-line como baseline estavel e adiciona a camada workspace-first com rail de coding, trust/policy por workspace, source control first-class, working set persistido e grounding remoto por repositório.
48
+ No nivel arquitetural, o `0.9.0` marcou a mudanca de papel do bridge: ele publica tools e resultados estruturados para o Otto, em vez de injetar resposta pronta como caminho principal do chat. O `1.0.0` oficializou isso como runtime agentico; o `1.1.2` mantem a camada workspace-first com rail de coding, trust/policy por workspace, source control first-class, working set persistido e grounding remoto por repositório, enquanto estabiliza o `Otto Console` com header fixo, intro card sem `model:` e redraw limpo no resize.
49
49
 
50
50
  ## Publicacao
51
51
 
@@ -145,6 +145,7 @@ Dentro do console, use:
145
145
 
146
146
  - `/model fast` para `OttoAI Fast`
147
147
  - `/model thinking` para `OttoAI Thinking`
148
+ - `/approval preview`, `/approval confirm` ou `/approval trusted` para trocar o modo de aprovação do device
148
149
  - `/status` para ver detalhes técnicos do bridge e do runtime
149
150
  - `/workspace` ou `/workspace status` para ver o workspace ativo desta sessão
150
151
  - `/workspace list` para listar workspaces anexados ao device
@@ -152,6 +153,8 @@ Dentro do console, use:
152
153
  - `/workspace use <id|n>` para fixar um workspace anexado na sessão atual
153
154
  - `/workspace clear` para limpar o binding atual do chat/sessão
154
155
 
156
+ No TTY, o console agora usa buffer alternativo com header fixo no topo: banner, card azul com `cwd`/`bridge` e linha de comandos continuam visíveis enquanto o transcript cresce. O composer fica ancorado no rodapé com placeholder `Peça algo ao Otto`, quebra por largura real do terminal e mantém o transcript do Otto sempre acima sem duplicar a viewport no resize. O intro card não repete `model:`, o footer mostra o modelo ativo em cinza claro, e as mensagens renderizam o usuário com `>` e o assistente com bolinha azul, com espaçamento entre os blocos; `Shift+Tab` alterna o modo de aprovação sem sair do console.
157
+
155
158
  No modo `OttoAI Thinking`, o terminal agora marca explicitamente o trecho de raciocínio com `Pensando (OttoAI Thinking)` e separa esse bloco da resposta final do Otto.
156
159
 
157
160
  Quando o handoff local devolver resultado estruturado, o CLI agora mostra inline a listagem de arquivos e o conteúdo de `read_file`, em vez de só resumir que executou a tarefa.
@@ -166,7 +169,7 @@ Esse comando abre um shell local interativo para instalar extensoes, rodar coman
166
169
 
167
170
  ### WhatsApp Web em background
168
171
 
169
- Fluxo recomendado na linha `1.1.0`:
172
+ Fluxo recomendado na linha `1.1.2`:
170
173
 
171
174
  ```bash
172
175
  otto-bridge extensions --install whatsappweb
@@ -176,13 +179,13 @@ otto-bridge extensions --status whatsappweb
176
179
 
177
180
  O setup agora abre o login do WhatsApp Web no helper/background browser do proprio bridge. Depois do QR code, o Otto usa a sessao local em background, sem depender de aba visivel no Safari.
178
181
 
179
- Contrato da linha `1.1.0`:
182
+ Contrato da linha `1.1.2`:
180
183
 
181
184
  - `otto-bridge extensions --setup whatsappweb`: autentica a sessao uma vez
182
185
  - `otto-bridge`: mantem o browser persistente do WhatsApp vivo em background enquanto o runtime do hub estiver ativo, sem depender de uma aba aberta no Safari
183
186
  - ao fechar o `otto-bridge`: o browser em background e desligado, mas a sessao local fica lembrada para o proximo boot
184
187
 
185
- ## Handoff rapido da linha 1.1.0
188
+ ## Handoff rapido da linha 1.1.2
186
189
 
187
190
  Ja fechado no codigo:
188
191
 
@@ -4,9 +4,10 @@ import { readFile } from "node:fs/promises";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { clearScreenDown, cursorTo, moveCursor, } from "node:readline";
6
6
  import process, { stdin as input, stdout as output } from "node:process";
7
- import { defaultDeviceName, getBridgeConfigPath, loadBridgeConfig, normalizeInstalledExtensions, resolveApiBaseUrl, resolveExecutorConfig, } from "./config.js";
7
+ import { defaultDeviceName, getBridgeConfigPath, loadBridgeConfig, normalizeInstalledExtensions, resolveApiBaseUrl, resolveExecutorConfig, saveBridgeConfig, } from "./config.js";
8
8
  import { activateWorkspaceForRuntime, attachWorkspaceForRuntime, clearRuntimeWorkspaceForSession, getRuntimeSessionWorkspace, listAttachedWorkspacesForRuntime, } from "./attached_workspaces.js";
9
9
  import { streamDeviceCliChat, } from "./chat_cli_client.js";
10
+ import { patchDeviceJson } from "./http.js";
10
11
  import { formatManagedBridgeExtensionStatus, isManagedBridgeExtensionSlug, loadManagedBridgeExtensionState, } from "./extensions.js";
11
12
  import { pairDevice } from "./pairing.js";
12
13
  import { BridgeRuntime, } from "./runtime.js";
@@ -41,11 +42,13 @@ const CLI_MODEL_REGISTRY = {
41
42
  label: "OttoAI Fast",
42
43
  requestModel: "deepseek-chat",
43
44
  aliases: ["fast", "chat", "default", "ottoai fast", "otto fast"],
45
+ contextWindowTokens: 128_000,
44
46
  },
45
47
  thinking: {
46
48
  label: "OttoAI Thinking",
47
49
  requestModel: "deepseek-reasoner",
48
50
  aliases: ["thinking", "reasoner", "think", "ottoai thinking", "otto thinking"],
51
+ contextWindowTokens: 128_000,
49
52
  },
50
53
  };
51
54
  const MAX_RENDERED_LIST_ENTRIES = 28;
@@ -53,9 +56,11 @@ const MAX_RENDERED_LIST_ENTRIES_COMPACT = 10;
53
56
  const MAX_RENDERED_FILE_CHARS = 6_000;
54
57
  const MAX_RENDERED_FILE_CHARS_COMPACT = 1_400;
55
58
  const CONSOLE_PLACEHOLDER = "Peça algo ao Otto";
56
- const CONSOLE_COMMAND_HINT = "/help, /model [fast|thinking], /workspace, /status, /clear, /exit";
59
+ const CONSOLE_COMMAND_HINT = "/help, /model [fast|thinking], /approval [preview|confirm|trusted], /workspace, /status, /clear, /exit";
57
60
  const CONSOLE_COMPOSER_PROMPT_WIDTH = 2;
58
61
  const CONSOLE_COMPOSER_CURSOR_COLUMN = 2;
62
+ const CONSOLE_EST_CHARS_PER_TOKEN = 4;
63
+ const CONSOLE_EST_MESSAGE_OVERHEAD_TOKENS = 8;
59
64
  class CliRuntimeSession {
60
65
  config;
61
66
  runtime = null;
@@ -233,6 +238,9 @@ function renderBanner() {
233
238
  `${style(subtitle, ANSI.slate, enabled)}`,
234
239
  ].join("\n");
235
240
  }
241
+ function getCardMaxContentWidth() {
242
+ return Math.max(24, Number(output.columns || 96) - 4);
243
+ }
236
244
  function createCliExitError() {
237
245
  return new Error(CLI_EXIT_SENTINEL);
238
246
  }
@@ -297,6 +305,292 @@ function getCliModelLabel(mode) {
297
305
  function getCliModelRequestModel(mode) {
298
306
  return CLI_MODEL_REGISTRY[mode].requestModel;
299
307
  }
308
+ function getCliModelContextWindowTokens(mode) {
309
+ return CLI_MODEL_REGISTRY[mode].contextWindowTokens;
310
+ }
311
+ function countConsoleChars(value) {
312
+ if (value === null || value === undefined) {
313
+ return 0;
314
+ }
315
+ if (typeof value === "string") {
316
+ return value.length;
317
+ }
318
+ if (typeof value === "number" || typeof value === "boolean") {
319
+ return String(value).length;
320
+ }
321
+ if (Array.isArray(value)) {
322
+ return value.reduce((total, item) => total + countConsoleChars(item), 0);
323
+ }
324
+ if (typeof value === "object") {
325
+ return Object.values(value).reduce((total, item) => total + countConsoleChars(item), 0);
326
+ }
327
+ return String(value).length;
328
+ }
329
+ function estimateConsoleMessageTokens(messages) {
330
+ let charCount = 0;
331
+ let messageCount = 0;
332
+ for (const message of messages) {
333
+ if (!message || typeof message !== "object") {
334
+ continue;
335
+ }
336
+ messageCount += 1;
337
+ charCount += countConsoleChars(message.role);
338
+ charCount += countConsoleChars(message.content);
339
+ }
340
+ const estimatedTokens = Math.ceil(charCount / CONSOLE_EST_CHARS_PER_TOKEN);
341
+ return Math.max(0, estimatedTokens + (messageCount * CONSOLE_EST_MESSAGE_OVERHEAD_TOKENS));
342
+ }
343
+ function estimateConsoleContextTokens(messages, draftValue) {
344
+ const draft = String(draftValue || "");
345
+ const requestMessages = draft
346
+ ? [...messages, { role: "user", content: draft }]
347
+ : [...messages];
348
+ return estimateConsoleMessageTokens(requestMessages);
349
+ }
350
+ function formatCompactTokenCount(tokens) {
351
+ const value = Math.max(0, Math.round(tokens));
352
+ if (value < 1_000) {
353
+ return String(value);
354
+ }
355
+ if (value < 10_000) {
356
+ return `${(value / 1_000).toFixed(1)}K`;
357
+ }
358
+ if (value < 1_000_000) {
359
+ return `${Math.round(value / 1_000)}K`;
360
+ }
361
+ return `${(value / 1_000_000).toFixed(1)}M`;
362
+ }
363
+ function getConsoleApprovalFooterState(mode) {
364
+ if (mode === "trusted") {
365
+ return {
366
+ tone: "warning",
367
+ label: "bypass permissões ativo",
368
+ };
369
+ }
370
+ if (mode === "confirm") {
371
+ return {
372
+ tone: "primary",
373
+ label: "confirmação manual",
374
+ };
375
+ }
376
+ return {
377
+ tone: "muted",
378
+ label: "permissões padrão",
379
+ };
380
+ }
381
+ function getNextApprovalMode(mode) {
382
+ if (mode === "preview") {
383
+ return "confirm";
384
+ }
385
+ if (mode === "confirm") {
386
+ return "trusted";
387
+ }
388
+ return "preview";
389
+ }
390
+ function styleTranscriptLine(text, tone, enabled) {
391
+ if (!enabled) {
392
+ return text;
393
+ }
394
+ if (tone === "headline") {
395
+ return `${ANSI.bold}${ANSI.brandBlue}${text}${ANSI.reset}`;
396
+ }
397
+ if (tone === "assistant") {
398
+ return `${ANSI.white}${text}${ANSI.reset}`;
399
+ }
400
+ if (tone === "user") {
401
+ return `${ANSI.bold}${ANSI.white}${text}${ANSI.reset}`;
402
+ }
403
+ if (tone === "reasoning") {
404
+ return `${ANSI.slateItalic}${text}${ANSI.reset}`;
405
+ }
406
+ if (tone === "success") {
407
+ return `${ANSI.green}${text}${ANSI.reset}`;
408
+ }
409
+ if (tone === "warning") {
410
+ return `${ANSI.amber}${text}${ANSI.reset}`;
411
+ }
412
+ if (tone === "error") {
413
+ return `${ANSI.red}${text}${ANSI.reset}`;
414
+ }
415
+ return `${ANSI.slate}${text}${ANSI.reset}`;
416
+ }
417
+ function buildConsoleUsageBar(width, usageRatio, enabled) {
418
+ const clampedWidth = Math.max(10, width);
419
+ const clampedRatio = Math.max(0, Math.min(usageRatio, 1));
420
+ const filled = Math.round(clampedWidth * clampedRatio);
421
+ const empty = Math.max(0, clampedWidth - filled);
422
+ const filledBar = style("█".repeat(filled), ANSI.brandBlue, enabled);
423
+ const emptyBar = style("░".repeat(empty), ANSI.slate, enabled);
424
+ return `${style("[", ANSI.white, enabled)}${filledBar}${emptyBar}${style("]", ANSI.white, enabled)}`;
425
+ }
426
+ function buildConsoleFooterStatusLine(width, modelMode, usageTokens, enabled) {
427
+ const contextLimit = getCliModelContextWindowTokens(modelMode);
428
+ const usageRatio = contextLimit > 0 ? usageTokens / contextLimit : 0;
429
+ const percent = Math.round(Math.max(0, Math.min(usageRatio, 1)) * 100);
430
+ const barWidth = Math.max(12, Math.min(28, Math.floor(width * 0.18)));
431
+ const modelLabel = style(getCliModelLabel(modelMode), ANSI.slate, enabled);
432
+ return `${modelLabel} | ${buildConsoleUsageBar(barWidth, usageRatio, enabled)} ${percent}% | ${formatCompactTokenCount(usageTokens)} tokens`;
433
+ }
434
+ function buildConsoleFooterApprovalLine(mode, enabled, statusSuffix) {
435
+ const state = getConsoleApprovalFooterState(mode);
436
+ const hint = statusSuffix || "Shift+Tab para alternar";
437
+ return style(`${state.label} (${hint})`, state.tone === "warning" ? ANSI.red : state.tone === "primary" ? ANSI.amber : ANSI.slate, enabled);
438
+ }
439
+ function styleTranscriptPrefix(text, tone, enabled) {
440
+ if (!enabled || !text) {
441
+ return text;
442
+ }
443
+ if (tone === "assistant") {
444
+ return `${ANSI.bold}${ANSI.brandBlue}${text}${ANSI.reset}`;
445
+ }
446
+ if (tone === "user") {
447
+ return `${ANSI.bold}${ANSI.white}${text}${ANSI.reset}`;
448
+ }
449
+ return text;
450
+ }
451
+ class ConsoleScreenRenderer {
452
+ modelMode;
453
+ approvalMode;
454
+ headerFactory;
455
+ transcript = [];
456
+ onResize = () => {
457
+ this.render();
458
+ };
459
+ nextEntryId = 1;
460
+ active = false;
461
+ draftValue = "";
462
+ conversationMessages = [];
463
+ approvalStatusSuffix = null;
464
+ usingAlternateBuffer = false;
465
+ constructor(modelMode, approvalMode, headerFactory = () => []) {
466
+ this.modelMode = modelMode;
467
+ this.approvalMode = approvalMode;
468
+ this.headerFactory = headerFactory;
469
+ }
470
+ activate() {
471
+ if (this.active || !supportsAnsi() || !input.isTTY || !output.isTTY) {
472
+ return;
473
+ }
474
+ this.active = true;
475
+ output.write("\u001b[?1049h");
476
+ this.usingAlternateBuffer = true;
477
+ output.on("resize", this.onResize);
478
+ this.render();
479
+ }
480
+ dispose() {
481
+ if (!this.active) {
482
+ return;
483
+ }
484
+ output.off("resize", this.onResize);
485
+ this.active = false;
486
+ output.write("\u001b[?25h");
487
+ if (this.usingAlternateBuffer) {
488
+ output.write("\u001b[?1049l");
489
+ this.usingAlternateBuffer = false;
490
+ }
491
+ }
492
+ isActive() {
493
+ return this.active;
494
+ }
495
+ clearTranscript() {
496
+ this.transcript.splice(0, this.transcript.length);
497
+ this.render();
498
+ }
499
+ pushEntry(entry) {
500
+ const id = this.nextEntryId++;
501
+ this.transcript.push({
502
+ id,
503
+ ...entry,
504
+ });
505
+ this.render();
506
+ return id;
507
+ }
508
+ appendToEntry(id, text) {
509
+ if (!id || !text) {
510
+ return;
511
+ }
512
+ const entry = this.transcript.find((item) => item.id === id);
513
+ if (!entry) {
514
+ return;
515
+ }
516
+ entry.text += text;
517
+ this.render();
518
+ }
519
+ setDraftValue(value) {
520
+ this.draftValue = value;
521
+ this.render();
522
+ }
523
+ setConversationMessages(messages) {
524
+ this.conversationMessages = [...messages];
525
+ this.render();
526
+ }
527
+ setModelMode(mode) {
528
+ this.modelMode = mode;
529
+ this.render();
530
+ }
531
+ setApprovalMode(mode) {
532
+ this.approvalMode = mode;
533
+ this.approvalStatusSuffix = null;
534
+ this.render();
535
+ }
536
+ setApprovalStatusSuffix(value) {
537
+ this.approvalStatusSuffix = value;
538
+ this.render();
539
+ }
540
+ buildEntryLines(entry, width) {
541
+ const logicalLines = entry.text.split("\n");
542
+ const prefix = entry.prefix || "";
543
+ const continuationPrefix = entry.continuationPrefix ?? " ".repeat(prefix.length);
544
+ const rendered = [];
545
+ let isFirstVisualLine = true;
546
+ for (const logicalLine of logicalLines) {
547
+ const currentPrefix = isFirstVisualLine ? prefix : continuationPrefix;
548
+ const wrapped = sliceByWidth(logicalLine, Math.max(1, width - currentPrefix.length));
549
+ wrapped.forEach((segment, index) => {
550
+ const linePrefix = isFirstVisualLine && index === 0 ? prefix : continuationPrefix;
551
+ const prefixTone = isFirstVisualLine && index === 0 ? entry.prefixTone : undefined;
552
+ rendered.push(`${styleTranscriptPrefix(linePrefix, prefixTone, supportsAnsi())}${styleTranscriptLine(segment, entry.tone, supportsAnsi())}`);
553
+ });
554
+ if (logicalLine.length === 0 && wrapped.length === 0) {
555
+ rendered.push("");
556
+ }
557
+ isFirstVisualLine = false;
558
+ }
559
+ return rendered;
560
+ }
561
+ render() {
562
+ if (!this.active) {
563
+ return;
564
+ }
565
+ const enabled = supportsAnsi();
566
+ const width = Math.max(48, Number(output.columns || 96));
567
+ const height = Math.max(12, Number(output.rows || 24));
568
+ const headerLines = this.headerFactory();
569
+ const separator = style("─".repeat(width), ANSI.brandBlue, enabled);
570
+ const composer = renderConsoleComposerLines(this.draftValue, width, enabled);
571
+ const usageTokens = Math.min(estimateConsoleContextTokens(this.conversationMessages, this.draftValue), getCliModelContextWindowTokens(this.modelMode));
572
+ const footerLines = [
573
+ separator,
574
+ ...composer.renderedLines,
575
+ separator,
576
+ buildConsoleFooterStatusLine(width, this.modelMode, usageTokens, enabled),
577
+ buildConsoleFooterApprovalLine(this.approvalMode, enabled, this.approvalStatusSuffix),
578
+ ];
579
+ const visibleHeader = headerLines.slice(0, Math.max(0, height - footerLines.length));
580
+ const transcriptHeight = Math.max(0, height - visibleHeader.length - footerLines.length);
581
+ const transcriptLines = this.transcript.flatMap((entry) => this.buildEntryLines(entry, width));
582
+ const visibleTranscript = transcriptLines.slice(-transcriptHeight);
583
+ const paddedTranscript = [
584
+ ...Array.from({ length: Math.max(0, transcriptHeight - visibleTranscript.length) }, () => ""),
585
+ ...visibleTranscript,
586
+ ];
587
+ output.write("\u001b[?25l");
588
+ output.write("\u001b[H\u001b[2J");
589
+ output.write([...visibleHeader, ...paddedTranscript, ...footerLines].join("\n"));
590
+ cursorTo(output, Math.min(width - 1, CONSOLE_COMPOSER_CURSOR_COLUMN + composer.cursorColumn), visibleHeader.length + transcriptHeight + 1 + composer.cursorLineIndex);
591
+ output.write("\u001b[?25h");
592
+ }
593
+ }
300
594
  export function resolveCliModelMode(value) {
301
595
  const normalized = normalizeText(value).toLowerCase();
302
596
  if (!normalized) {
@@ -531,13 +825,14 @@ function styleCardLine(line, tone, enabled) {
531
825
  }
532
826
  return `${ANSI.white}${line}${ANSI.reset}`;
533
827
  }
534
- function renderInfoCard(lines) {
828
+ export function renderInfoCard(lines, maxContentWidth = 82) {
535
829
  const enabled = supportsAnsi();
830
+ const safeMaxContentWidth = Math.max(20, maxContentWidth);
536
831
  const normalized = lines.map((line) => ({
537
- text: truncate(line.text, 82),
832
+ text: truncate(line.text, safeMaxContentWidth),
538
833
  tone: line.tone,
539
834
  }));
540
- const width = Math.max(44, ...normalized.map((line) => line.text.length));
835
+ const width = Math.max(Math.min(44, safeMaxContentWidth), ...normalized.map((line) => line.text.length));
541
836
  const top = style(`┌${"─".repeat(width + 2)}┐`, ANSI.brandBlue, enabled);
542
837
  const bottom = style(`└${"─".repeat(width + 2)}┘`, ANSI.brandBlue, enabled);
543
838
  return [
@@ -550,17 +845,31 @@ function renderInfoCard(lines) {
550
845
  bottom,
551
846
  ].join("\n");
552
847
  }
553
- function buildWelcomeCard(runtimeSession, modelMode) {
848
+ export function buildWelcomeCard(runtimeSession) {
554
849
  return [
555
850
  { text: "Welcome to OttoAI", tone: "title" },
556
851
  { text: "", tone: "muted" },
557
852
  { text: "/help inside the console, /status for bridge details", tone: "muted" },
558
853
  { text: "", tone: "muted" },
559
- { text: `model: ${getCliModelLabel(modelMode)}`, tone: "muted" },
560
854
  { text: `cwd: ${process.cwd()}`, tone: "muted" },
561
855
  { text: renderRuntimeHeadline(runtimeSession), tone: "muted" },
562
856
  ];
563
857
  }
858
+ function buildConsoleHeaderLines(runtimeSession) {
859
+ const lines = [
860
+ ...renderBanner().split("\n"),
861
+ "",
862
+ ...renderInfoCard(buildWelcomeCard(runtimeSession), getCardMaxContentWidth()).split("\n"),
863
+ ];
864
+ const releaseNotice = runtimeSession.getReleaseNotice();
865
+ if (releaseNotice) {
866
+ lines.push("");
867
+ lines.push(...renderInfoCard(buildBridgeReleaseCard(releaseNotice), getCardMaxContentWidth()).split("\n"));
868
+ }
869
+ lines.push("");
870
+ lines.push(style(`Comandos: ${CONSOLE_COMMAND_HINT}`, ANSI.slateItalic, supportsAnsi()));
871
+ return lines;
872
+ }
564
873
  function buildBridgeReleaseCard(notice) {
565
874
  const isRequired = notice.kind === "required";
566
875
  return [
@@ -650,29 +959,20 @@ async function buildExtensionsCard(config) {
650
959
  }
651
960
  return lines;
652
961
  }
653
- function printHubScreen(runtimeSession, modelMode) {
962
+ function printHubScreen(runtimeSession) {
654
963
  clearScreen();
655
964
  console.log(renderBanner());
656
965
  console.log("");
657
- console.log(renderInfoCard(buildWelcomeCard(runtimeSession, modelMode)));
966
+ console.log(renderInfoCard(buildWelcomeCard(runtimeSession), getCardMaxContentWidth()));
658
967
  const releaseNotice = runtimeSession?.getReleaseNotice();
659
968
  if (releaseNotice) {
660
969
  console.log("");
661
- console.log(renderInfoCard(buildBridgeReleaseCard(releaseNotice)));
970
+ console.log(renderInfoCard(buildBridgeReleaseCard(releaseNotice), getCardMaxContentWidth()));
662
971
  }
663
972
  }
664
- function printConsoleScreen(runtimeSession, modelMode) {
973
+ function printConsoleScreen(runtimeSession) {
665
974
  clearScreen();
666
- console.log(renderBanner());
667
- console.log("");
668
- console.log(renderInfoCard(buildWelcomeCard(runtimeSession, modelMode)));
669
- const releaseNotice = runtimeSession.getReleaseNotice();
670
- if (releaseNotice) {
671
- console.log("");
672
- console.log(renderInfoCard(buildBridgeReleaseCard(releaseNotice)));
673
- }
674
- console.log("");
675
- printSoft(`Comandos: ${CONSOLE_COMMAND_HINT}`);
975
+ console.log(buildConsoleHeaderLines(runtimeSession).join("\n"));
676
976
  console.log("");
677
977
  }
678
978
  function formatConsoleWorkspaceLine(workspace, options) {
@@ -806,6 +1106,16 @@ async function handleConsoleWorkspaceCommand(normalizedPrompt, config, sessionId
806
1106
  printWarning("Comando de workspace inválido. Use /workspace help.");
807
1107
  return true;
808
1108
  }
1109
+ async function updateConsoleApprovalMode(config, approvalMode) {
1110
+ const response = await patchDeviceJson(config.apiBaseUrl, config.deviceToken, "/v1/devices/cli/device/settings", {
1111
+ approval_mode: approvalMode,
1112
+ });
1113
+ const resolvedMode = normalizeText(response.device?.approval_mode);
1114
+ const nextMode = resolvedMode || approvalMode;
1115
+ config.approvalMode = nextMode;
1116
+ await saveBridgeConfig(config);
1117
+ return nextMode;
1118
+ }
809
1119
  function renderPromptFrameLine(width, edgeLeft, edgeRight) {
810
1120
  return style(`${edgeLeft}${"─".repeat(width)}${edgeRight}`, ANSI.brandBlue, supportsAnsi());
811
1121
  }
@@ -817,11 +1127,18 @@ function sliceByWidth(text, width) {
817
1127
  return [""];
818
1128
  }
819
1129
  const parts = [];
820
- let offset = 0;
821
- while (offset < text.length) {
822
- parts.push(text.slice(offset, offset + width));
823
- offset += width;
1130
+ let remaining = text;
1131
+ while (remaining.length > width) {
1132
+ const breakpoint = Math.max(remaining.lastIndexOf(" ", width), remaining.lastIndexOf("\t", width));
1133
+ if (breakpoint > 0) {
1134
+ parts.push(remaining.slice(0, breakpoint).trimEnd());
1135
+ remaining = remaining.slice(breakpoint).replace(/^[ \t]+/, "");
1136
+ continue;
1137
+ }
1138
+ parts.push(remaining.slice(0, width));
1139
+ remaining = remaining.slice(width);
824
1140
  }
1141
+ parts.push(remaining);
825
1142
  return parts.length ? parts : [""];
826
1143
  }
827
1144
  export function buildConsoleComposerLayout(value, innerWidth) {
@@ -834,16 +1151,16 @@ export function buildConsoleComposerLayout(value, innerWidth) {
834
1151
  };
835
1152
  }
836
1153
  const logicalLines = value.split("\n");
837
- const lastLogicalLine = logicalLines[logicalLines.length - 1] ?? "";
838
1154
  const lines = [];
839
1155
  for (const logicalLine of logicalLines) {
840
1156
  const wrapped = sliceByWidth(logicalLine, lineContentWidth);
841
1157
  lines.push(...wrapped);
842
1158
  }
843
1159
  const trailingNewline = value.endsWith("\n");
1160
+ const lastVisualLine = lines[lines.length - 1] ?? "";
844
1161
  const needsSoftWrapCursorRow = !trailingNewline
845
- && lastLogicalLine.length > 0
846
- && lastLogicalLine.length % lineContentWidth === 0;
1162
+ && lastVisualLine.length > 0
1163
+ && lastVisualLine.length === lineContentWidth;
847
1164
  if (needsSoftWrapCursorRow) {
848
1165
  lines.push("");
849
1166
  }
@@ -856,7 +1173,7 @@ export function buildConsoleComposerLayout(value, innerWidth) {
856
1173
  ? 0
857
1174
  : needsSoftWrapCursorRow
858
1175
  ? 0
859
- : lastLogicalLine.length % lineContentWidth;
1176
+ : lastVisualLine.length;
860
1177
  return {
861
1178
  lines,
862
1179
  cursorLineIndex,
@@ -906,6 +1223,19 @@ export function tryConsumeControlSequence(buffer) {
906
1223
  };
907
1224
  }
908
1225
  }
1226
+ const approvalCycleSequences = [
1227
+ "\u001b[Z",
1228
+ "\u001b[1;2Z",
1229
+ "\u001b[27;2;9~",
1230
+ ];
1231
+ for (const sequence of approvalCycleSequences) {
1232
+ if (buffer.startsWith(sequence)) {
1233
+ return {
1234
+ consumed: sequence.length,
1235
+ action: "cycle_approval",
1236
+ };
1237
+ }
1238
+ }
909
1239
  if (buffer === "\u001b" || /^\u001b\[[0-9;?]*$/.test(buffer)) {
910
1240
  return {
911
1241
  consumed: 0,
@@ -934,7 +1264,7 @@ export function tryConsumeControlSequence(buffer) {
934
1264
  }
935
1265
  return null;
936
1266
  }
937
- async function askConsoleInput(rl) {
1267
+ async function askConsoleInput(rl, options) {
938
1268
  if (!supportsAnsi() || typeof input.setRawMode !== "function" || !input.isTTY) {
939
1269
  return normalizeText(await question(rl, "> "));
940
1270
  }
@@ -943,16 +1273,19 @@ async function askConsoleInput(rl) {
943
1273
  input.resume();
944
1274
  return await new Promise((resolve, reject) => {
945
1275
  const enabled = supportsAnsi();
1276
+ const ui = options?.ui && options.ui.isActive() ? options.ui : null;
946
1277
  const availableWidth = Number(output.columns || 96);
947
1278
  const innerWidth = Math.max(42, Math.min(availableWidth - 8, 116));
948
1279
  const sectionTopOffsetFromInputLine = 1;
949
- let renderedOnce = false;
950
1280
  let value = "";
1281
+ let renderedOnce = false;
1282
+ let controlBuffer = "";
951
1283
  const cleanup = () => {
952
1284
  input.removeListener("data", onData);
953
1285
  input.setRawMode(false);
954
1286
  input.pause();
955
1287
  rl.resume();
1288
+ ui?.setDraftValue("");
956
1289
  };
957
1290
  const renderInputContent = () => {
958
1291
  const promptPlain = "> ";
@@ -968,7 +1301,11 @@ async function askConsoleInput(rl) {
968
1301
  : value;
969
1302
  return `${promptStyled} ${visibleValue}${" ".repeat(Math.max(0, maxValueLength - visibleValue.length))}`;
970
1303
  };
971
- const render = () => {
1304
+ const renderLegacyInput = () => {
1305
+ if (ui) {
1306
+ ui.setDraftValue(value);
1307
+ return;
1308
+ }
972
1309
  if (renderedOnce) {
973
1310
  cursorTo(output, 0);
974
1311
  moveCursor(output, 0, -sectionTopOffsetFromInputLine);
@@ -987,9 +1324,38 @@ async function askConsoleInput(rl) {
987
1324
  const visibleValueLength = Math.min(value.length, Math.max(0, innerWidth - 2));
988
1325
  cursorTo(output, 4 + visibleValueLength);
989
1326
  };
1327
+ const consumeControlBuffer = () => {
1328
+ while (controlBuffer.length > 0) {
1329
+ const parsed = tryConsumeControlSequence(controlBuffer);
1330
+ if (!parsed) {
1331
+ controlBuffer = "";
1332
+ return;
1333
+ }
1334
+ if (parsed.action === "incomplete") {
1335
+ return;
1336
+ }
1337
+ controlBuffer = controlBuffer.slice(parsed.consumed);
1338
+ if (parsed.action === "ignore") {
1339
+ continue;
1340
+ }
1341
+ if (parsed.action === "cycle_approval") {
1342
+ void options?.onCycleApprovalMode?.();
1343
+ continue;
1344
+ }
1345
+ if (parsed.action === "newline") {
1346
+ value += "\n";
1347
+ renderLegacyInput();
1348
+ }
1349
+ }
1350
+ };
990
1351
  const onData = (chunk) => {
991
1352
  const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
992
1353
  for (const char of Array.from(text)) {
1354
+ if (controlBuffer || char === "\u001b") {
1355
+ controlBuffer += char;
1356
+ consumeControlBuffer();
1357
+ continue;
1358
+ }
993
1359
  if (char === "\u0003") {
994
1360
  cleanup();
995
1361
  reject(createCliExitError());
@@ -997,23 +1363,25 @@ async function askConsoleInput(rl) {
997
1363
  }
998
1364
  if (char === "\r" || char === "\n") {
999
1365
  cleanup();
1000
- output.write("\n");
1366
+ if (!ui) {
1367
+ output.write("\n");
1368
+ }
1001
1369
  resolve(normalizeText(value));
1002
1370
  return;
1003
1371
  }
1004
1372
  if (char === "\u007f" || char === "\b") {
1005
1373
  value = value.slice(0, -1);
1006
- render();
1374
+ renderLegacyInput();
1007
1375
  continue;
1008
1376
  }
1009
- if (char === "\u001b") {
1377
+ if (char === "\t") {
1010
1378
  continue;
1011
1379
  }
1012
1380
  value += char;
1013
- render();
1381
+ renderLegacyInput();
1014
1382
  }
1015
1383
  };
1016
- render();
1384
+ renderLegacyInput();
1017
1385
  input.on("data", onData);
1018
1386
  });
1019
1387
  }
@@ -1315,15 +1683,266 @@ async function followConsoleJob(rl, config, jobId) {
1315
1683
  }
1316
1684
  async function runOttoConsole(rl, config, runtimeSession, options) {
1317
1685
  let activeModel = "fast";
1318
- printConsoleScreen(runtimeSession, activeModel);
1319
1686
  const sessionId = randomUUID();
1320
1687
  const conversation = [];
1688
+ const ui = supportsAnsi() && typeof input.setRawMode === "function" && input.isTTY && output.isTTY
1689
+ ? new ConsoleScreenRenderer(activeModel, config.approvalMode, () => buildConsoleHeaderLines(runtimeSession))
1690
+ : null;
1691
+ ui?.activate();
1692
+ if (!ui) {
1693
+ printConsoleScreen(runtimeSession);
1694
+ }
1695
+ const renderConversationState = () => {
1696
+ ui?.setConversationMessages(conversation);
1697
+ ui?.setModelMode(activeModel);
1698
+ };
1699
+ renderConversationState();
1700
+ const emitConsoleEntry = (text, tone, options) => {
1701
+ if (ui) {
1702
+ ui.pushEntry({
1703
+ text,
1704
+ tone,
1705
+ prefix: options?.prefix,
1706
+ prefixTone: options?.prefixTone,
1707
+ continuationPrefix: options?.continuationPrefix,
1708
+ });
1709
+ return;
1710
+ }
1711
+ if (options?.prefix) {
1712
+ console.log(`${styleTranscriptPrefix(options.prefix, options.prefixTone, supportsAnsi())}${styleTranscriptLine(text, tone, supportsAnsi())}`);
1713
+ return;
1714
+ }
1715
+ if (tone === "warning") {
1716
+ printWarning(text);
1717
+ return;
1718
+ }
1719
+ if (tone === "error") {
1720
+ printError(text);
1721
+ return;
1722
+ }
1723
+ if (tone === "success") {
1724
+ printSuccess(text);
1725
+ return;
1726
+ }
1727
+ if (tone === "muted" || tone === "reasoning") {
1728
+ printSoft(text);
1729
+ return;
1730
+ }
1731
+ console.log(text);
1732
+ };
1733
+ const emitUserPrompt = (text) => {
1734
+ emitConsoleEntry(text, "user", {
1735
+ prefix: "> ",
1736
+ prefixTone: "user",
1737
+ continuationPrefix: " ",
1738
+ });
1739
+ };
1740
+ const emitAssistantPrompt = (text) => {
1741
+ emitConsoleEntry(text, "assistant", {
1742
+ prefix: "• ",
1743
+ prefixTone: "assistant",
1744
+ continuationPrefix: " ",
1745
+ });
1746
+ };
1747
+ const setApprovalMode = async (nextMode) => {
1748
+ if (ui) {
1749
+ ui.setApprovalStatusSuffix("atualizando...");
1750
+ }
1751
+ try {
1752
+ const resolvedMode = await updateConsoleApprovalMode(config, nextMode);
1753
+ ui?.setApprovalMode(resolvedMode);
1754
+ emitConsoleEntry(`Modo de aprovação: ${getConsoleApprovalFooterState(resolvedMode).label}.`, "muted");
1755
+ }
1756
+ catch (error) {
1757
+ const detail = error instanceof Error ? error.message : String(error || "Falha ao atualizar o modo de aprovação.");
1758
+ ui?.setApprovalStatusSuffix("falha ao atualizar");
1759
+ emitConsoleEntry(detail, "error");
1760
+ }
1761
+ };
1762
+ const cycleApprovalMode = () => {
1763
+ void setApprovalMode(getNextApprovalMode(config.approvalMode));
1764
+ };
1765
+ const readConsoleInput = async () => {
1766
+ return await askConsoleInput(rl, {
1767
+ ui,
1768
+ onCycleApprovalMode: cycleApprovalMode,
1769
+ });
1770
+ };
1771
+ const askConsoleDecision = async (promptText, defaultValue = true) => {
1772
+ emitConsoleEntry(`${promptText} ${defaultValue ? "[Y/n]" : "[y/N]"}`, "warning");
1773
+ const answer = normalizeText(await readConsoleInput()).toLowerCase();
1774
+ emitUserPrompt(answer || (defaultValue ? "sim" : "não"));
1775
+ if (!answer) {
1776
+ return defaultValue;
1777
+ }
1778
+ return ["y", "yes", "s", "sim"].includes(answer);
1779
+ };
1321
1780
  const printConsoleHelp = () => {
1322
- printSection("Console");
1323
- printSoft("Comandos: /help, /model [fast|thinking], /workspace, /status, /clear, /exit");
1324
- printSoft("Bridge: otto-bridge terminal, otto-bridge extensions --install <name>, otto-bridge update");
1325
- printSoft("Composer: Enter envia. Multiline desativado para evitar duplicacao no terminal.");
1326
- printSoft("Workspace: /workspace list, /workspace attach <path>, /workspace use <id|n>, /workspace clear");
1781
+ emitConsoleEntry("Console", "headline");
1782
+ emitConsoleEntry("Comandos: /help, /model [fast|thinking], /approval [preview|confirm|trusted], /workspace, /status, /clear, /exit", "muted");
1783
+ emitConsoleEntry("Composer: Enter envia, Shift+Enter adiciona linha e Shift+Tab alterna o modo de aprovação.", "muted");
1784
+ emitConsoleEntry("Workspace: /workspace list, /workspace attach <path>, /workspace use <id|n>, /workspace clear", "muted");
1785
+ };
1786
+ const printConsoleWorkspaceStatus = async () => {
1787
+ const [workspaces, activeWorkspace] = await Promise.all([
1788
+ listAttachedWorkspacesForRuntime({
1789
+ apiBaseUrl: config.apiBaseUrl,
1790
+ deviceToken: config.deviceToken,
1791
+ baseCwd: process.cwd(),
1792
+ }),
1793
+ getRuntimeSessionWorkspace({
1794
+ apiBaseUrl: config.apiBaseUrl,
1795
+ deviceToken: config.deviceToken,
1796
+ sessionId,
1797
+ baseCwd: process.cwd(),
1798
+ }),
1799
+ ]);
1800
+ emitConsoleEntry("Workspace", "headline");
1801
+ if (activeWorkspace) {
1802
+ emitConsoleEntry(`Ativo neste console: ${formatConsoleWorkspaceLine(activeWorkspace, { isActive: true })}`, "success");
1803
+ }
1804
+ else {
1805
+ emitConsoleEntry("Nenhum workspace ativo neste console.", "muted");
1806
+ }
1807
+ if (workspaces.length === 0) {
1808
+ emitConsoleEntry("Nenhum workspace anexado. Use /workspace attach <caminho>.", "muted");
1809
+ return;
1810
+ }
1811
+ workspaces.forEach((workspace, index) => {
1812
+ const isActive = normalizeText(workspace.workspace_id) === normalizeText(activeWorkspace?.workspace_id);
1813
+ emitConsoleEntry(formatConsoleWorkspaceLine(workspace, { index, isActive }), "muted");
1814
+ });
1815
+ };
1816
+ const handleWorkspaceCommand = async (normalizedPrompt) => {
1817
+ if (!normalizedPrompt.startsWith("/workspace")) {
1818
+ return false;
1819
+ }
1820
+ const remainder = normalizeText(normalizedPrompt.slice("/workspace".length));
1821
+ if (!remainder || remainder === "list" || remainder === "status") {
1822
+ await printConsoleWorkspaceStatus();
1823
+ return true;
1824
+ }
1825
+ if (remainder === "help") {
1826
+ emitConsoleEntry("Workspace", "headline");
1827
+ emitConsoleEntry("/workspace lista os workspaces anexados e o ativo", "muted");
1828
+ emitConsoleEntry("/workspace attach <path> anexa uma pasta ou repo neste device", "muted");
1829
+ emitConsoleEntry("/workspace use <id|n> ativa o workspace para o console atual", "muted");
1830
+ emitConsoleEntry("/workspace clear remove o workspace ativo deste console", "muted");
1831
+ return true;
1832
+ }
1833
+ if (remainder.startsWith("attach ")) {
1834
+ const rootPath = normalizeText(remainder.slice("attach ".length));
1835
+ if (!rootPath) {
1836
+ emitConsoleEntry("Use /workspace attach <caminho>.", "warning");
1837
+ return true;
1838
+ }
1839
+ const workspace = await attachWorkspaceForRuntime({
1840
+ apiBaseUrl: config.apiBaseUrl,
1841
+ deviceToken: config.deviceToken,
1842
+ rootPath,
1843
+ baseCwd: process.cwd(),
1844
+ });
1845
+ emitConsoleEntry(`Workspace anexado: ${formatConsoleWorkspaceLine(workspace)}`, "success");
1846
+ return true;
1847
+ }
1848
+ if (remainder.startsWith("use ")) {
1849
+ const selectionToken = normalizeText(remainder.slice("use ".length));
1850
+ if (!selectionToken) {
1851
+ emitConsoleEntry("Use /workspace use <id|numero>.", "warning");
1852
+ return true;
1853
+ }
1854
+ const workspaces = await listAttachedWorkspacesForRuntime({
1855
+ apiBaseUrl: config.apiBaseUrl,
1856
+ deviceToken: config.deviceToken,
1857
+ baseCwd: process.cwd(),
1858
+ });
1859
+ const selectedWorkspace = resolveConsoleWorkspaceSelection(selectionToken, workspaces);
1860
+ if (!selectedWorkspace?.workspace_id) {
1861
+ emitConsoleEntry("Workspace não encontrado. Rode /workspace para listar as opções.", "warning");
1862
+ return true;
1863
+ }
1864
+ const activated = await activateWorkspaceForRuntime({
1865
+ apiBaseUrl: config.apiBaseUrl,
1866
+ deviceToken: config.deviceToken,
1867
+ workspaceId: selectedWorkspace.workspace_id,
1868
+ sessionId,
1869
+ baseCwd: process.cwd(),
1870
+ });
1871
+ if (activated) {
1872
+ emitConsoleEntry(`Workspace ativo neste console: ${formatConsoleWorkspaceLine(activated, { isActive: true })}`, "success");
1873
+ }
1874
+ else {
1875
+ emitConsoleEntry("Não foi possível ativar o workspace selecionado.", "warning");
1876
+ }
1877
+ return true;
1878
+ }
1879
+ if (remainder === "clear") {
1880
+ await clearRuntimeWorkspaceForSession({
1881
+ apiBaseUrl: config.apiBaseUrl,
1882
+ deviceToken: config.deviceToken,
1883
+ sessionId,
1884
+ });
1885
+ emitConsoleEntry("Workspace ativo deste console removido.", "muted");
1886
+ return true;
1887
+ }
1888
+ emitConsoleEntry("Comando de workspace inválido. Use /workspace help.", "warning");
1889
+ return true;
1890
+ };
1891
+ const followConsoleJobUi = async (jobId) => {
1892
+ let lastStatus = "";
1893
+ let lastStepId = "";
1894
+ let awaitingDecision = false;
1895
+ for (;;) {
1896
+ const envelope = await getRuntimeCliJob(config, jobId);
1897
+ const job = envelope.job || {};
1898
+ const status = normalizeText(job.status).toLowerCase() || "unknown";
1899
+ const stepId = extractJobStepId(job);
1900
+ if (status !== lastStatus || stepId !== lastStepId) {
1901
+ const statusLabel = `${status}${stepId ? ` · ${stepId}` : ""}`;
1902
+ emitConsoleEntry(`local: ${statusLabel}`, "muted");
1903
+ lastStatus = status;
1904
+ lastStepId = stepId;
1905
+ }
1906
+ if (status === "confirm_required" && !awaitingDecision) {
1907
+ awaitingDecision = true;
1908
+ emitConsoleEntry(extractConfirmationPrompt(job), "warning");
1909
+ const approve = await askConsoleDecision("Aprovar este passo", true);
1910
+ if (approve) {
1911
+ await confirmRuntimeCliJob(config, jobId, "approve");
1912
+ }
1913
+ else {
1914
+ const reject = await askConsoleDecision("Rejeitar explicitamente este passo", true);
1915
+ if (reject) {
1916
+ await confirmRuntimeCliJob(config, jobId, "reject");
1917
+ }
1918
+ else {
1919
+ await cancelRuntimeCliJob(config, jobId, "Cancelado no Otto Console");
1920
+ }
1921
+ }
1922
+ awaitingDecision = false;
1923
+ continue;
1924
+ }
1925
+ if (status === "completed" || status === "failed" || status === "cancelled") {
1926
+ const summary = extractJobSummary(job)
1927
+ || (status === "completed"
1928
+ ? "Execução local concluída."
1929
+ : status === "failed"
1930
+ ? "Execução local falhou."
1931
+ : "Execução local cancelada.");
1932
+ if (status === "completed") {
1933
+ emitAssistantPrompt(summary);
1934
+ }
1935
+ else {
1936
+ emitConsoleEntry(summary, status === "failed" ? "error" : "warning");
1937
+ }
1938
+ const rendered = renderStructuredOutcome(job);
1939
+ if (rendered) {
1940
+ emitConsoleEntry(rendered, "muted");
1941
+ }
1942
+ return buildConversationSummary(summary, job);
1943
+ }
1944
+ await delay(1400);
1945
+ }
1327
1946
  };
1328
1947
  const handlePrompt = async (promptText) => {
1329
1948
  const normalizedPrompt = normalizeText(promptText);
@@ -1336,62 +1955,89 @@ async function runOttoConsole(rl, config, runtimeSession, options) {
1336
1955
  }
1337
1956
  if (normalizedPrompt === "/clear") {
1338
1957
  conversation.splice(0, conversation.length);
1339
- printConsoleScreen(runtimeSession, activeModel);
1340
- printMuted("Contexto local do console limpo.");
1958
+ ui?.clearTranscript();
1959
+ renderConversationState();
1960
+ if (!ui) {
1961
+ printConsoleScreen(runtimeSession);
1962
+ }
1963
+ emitConsoleEntry("Contexto local do console limpo.", "muted");
1341
1964
  return;
1342
1965
  }
1343
1966
  if (normalizedPrompt === "/model") {
1344
- printMuted(`Modelo ativo: ${getCliModelLabel(activeModel)}.`);
1345
- printSoft("Use /model fast ou /model thinking para trocar.");
1967
+ emitConsoleEntry(`Modelo ativo: ${getCliModelLabel(activeModel)}.`, "muted");
1968
+ emitConsoleEntry("Use /model fast ou /model thinking para trocar.", "muted");
1346
1969
  return;
1347
1970
  }
1348
1971
  if (normalizedPrompt.startsWith("/model ")) {
1349
1972
  const nextMode = resolveCliModelMode(normalizedPrompt.slice("/model ".length));
1350
1973
  if (!nextMode) {
1351
- printWarning("Modelo inválido. Use /model fast ou /model thinking.");
1974
+ emitConsoleEntry("Modelo inválido. Use /model fast ou /model thinking.", "warning");
1352
1975
  return;
1353
1976
  }
1354
1977
  if (nextMode === activeModel) {
1355
- printMuted(`Modelo já está em ${getCliModelLabel(activeModel)}.`);
1978
+ emitConsoleEntry(`Modelo já está em ${getCliModelLabel(activeModel)}.`, "muted");
1356
1979
  return;
1357
1980
  }
1358
1981
  activeModel = nextMode;
1359
- printConsoleScreen(runtimeSession, activeModel);
1360
- printSuccess(`Modelo ativo: ${getCliModelLabel(activeModel)}.`);
1982
+ renderConversationState();
1983
+ emitConsoleEntry(`Modelo ativo: ${getCliModelLabel(activeModel)}.`, "success");
1984
+ return;
1985
+ }
1986
+ if (normalizedPrompt === "/approval") {
1987
+ emitConsoleEntry(`Modo atual: ${getConsoleApprovalFooterState(config.approvalMode).label}.`, "muted");
1988
+ emitConsoleEntry("Use /approval preview, /approval confirm ou /approval trusted.", "muted");
1989
+ return;
1990
+ }
1991
+ if (normalizedPrompt.startsWith("/approval ")) {
1992
+ const nextMode = normalizeText(normalizedPrompt.slice("/approval ".length)).toLowerCase();
1993
+ if (!["preview", "confirm", "trusted"].includes(nextMode)) {
1994
+ emitConsoleEntry("Modo inválido. Use /approval preview, /approval confirm ou /approval trusted.", "warning");
1995
+ return;
1996
+ }
1997
+ if (nextMode === config.approvalMode) {
1998
+ emitConsoleEntry(`O modo de aprovação já está em ${getConsoleApprovalFooterState(config.approvalMode).label}.`, "muted");
1999
+ return;
2000
+ }
2001
+ await setApprovalMode(nextMode);
1361
2002
  return;
1362
2003
  }
1363
2004
  if (normalizedPrompt === "/status") {
1364
- console.log(`${style("Model", ANSI.brandBlue, supportsAnsi())}: ${getCliModelLabel(activeModel)}`);
1365
- renderStatusOverview(config, runtimeSession).forEach((line) => console.log(line));
2005
+ emitConsoleEntry(`Model: ${getCliModelLabel(activeModel)}`, "muted");
2006
+ renderStatusOverview(config, runtimeSession).forEach((line) => emitConsoleEntry(line, "muted"));
1366
2007
  const runtimeFailure = runtimeSession.getLastError();
1367
2008
  if (runtimeFailure) {
1368
- printWarning(`Runtime reportou erro: ${runtimeFailure}`);
2009
+ emitConsoleEntry(`Runtime reportou erro: ${runtimeFailure}`, "warning");
1369
2010
  }
1370
2011
  return;
1371
2012
  }
1372
2013
  if (normalizedPrompt.startsWith("/workspace")) {
1373
2014
  try {
1374
- if (await handleConsoleWorkspaceCommand(normalizedPrompt, config, sessionId)) {
2015
+ if (await handleWorkspaceCommand(normalizedPrompt)) {
1375
2016
  return;
1376
2017
  }
1377
2018
  }
1378
2019
  catch (error) {
1379
2020
  const detail = error instanceof Error ? error.message : String(error || "Erro ao gerenciar workspace.");
1380
- printError(detail);
2021
+ emitConsoleEntry(detail, "error");
1381
2022
  return;
1382
2023
  }
1383
2024
  }
1384
2025
  if (normalizedPrompt === "/exit") {
1385
2026
  throw createCliExitError();
1386
2027
  }
2028
+ emitUserPrompt(normalizedPrompt);
2029
+ emitConsoleEntry("", "muted");
1387
2030
  conversation.push({ role: "user", content: normalizedPrompt });
1388
2031
  while (conversation.length > 18) {
1389
2032
  conversation.shift();
1390
2033
  }
2034
+ renderConversationState();
1391
2035
  let streamedAssistant = "";
2036
+ let assistantEntryId = null;
1392
2037
  let assistantPrefixPrinted = false;
1393
2038
  let reasoningPrefixPrinted = false;
1394
2039
  let contentSeparatedFromReasoning = false;
2040
+ let reasoningEntryId = null;
1395
2041
  let handoffPayload = null;
1396
2042
  await streamDeviceCliChat(config, {
1397
2043
  messages: conversation,
@@ -1407,7 +2053,7 @@ async function runOttoConsole(rl, config, runtimeSession, options) {
1407
2053
  if (chunkType === "search_status") {
1408
2054
  const status = normalizeText(event.status);
1409
2055
  if (status) {
1410
- printMuted(`Busca: ${status}`);
2056
+ emitConsoleEntry(`Busca: ${status}`, "muted");
1411
2057
  }
1412
2058
  return;
1413
2059
  }
@@ -1420,10 +2066,18 @@ async function runOttoConsole(rl, config, runtimeSession, options) {
1420
2066
  : "";
1421
2067
  if (reasoningChunk) {
1422
2068
  if (!reasoningPrefixPrinted) {
1423
- output.write(`${style("Pensando (OttoAI Thinking)\n", ANSI.brandBlue, supportsAnsi())}`);
2069
+ emitConsoleEntry("Pensando (OttoAI Thinking)", "headline");
2070
+ reasoningEntryId = ui
2071
+ ? ui.pushEntry({ text: "", tone: "reasoning" })
2072
+ : null;
1424
2073
  reasoningPrefixPrinted = true;
1425
2074
  }
1426
- output.write(style(reasoningChunk, ANSI.slateItalic, supportsAnsi()));
2075
+ if (ui && reasoningEntryId) {
2076
+ ui.appendToEntry(reasoningEntryId, reasoningChunk);
2077
+ }
2078
+ else {
2079
+ output.write(style(reasoningChunk, ANSI.slateItalic, supportsAnsi()));
2080
+ }
1427
2081
  return;
1428
2082
  }
1429
2083
  const contentChunk = typeof event.content === "string" ? event.content : "";
@@ -1431,17 +2085,33 @@ async function runOttoConsole(rl, config, runtimeSession, options) {
1431
2085
  return;
1432
2086
  }
1433
2087
  if (reasoningPrefixPrinted && !contentSeparatedFromReasoning) {
1434
- output.write("\n\n");
2088
+ emitConsoleEntry("", "muted");
1435
2089
  contentSeparatedFromReasoning = true;
1436
2090
  }
1437
2091
  if (!assistantPrefixPrinted) {
1438
- output.write(`${style("•", ANSI.brandBlue, supportsAnsi())} `);
2092
+ assistantEntryId = ui
2093
+ ? ui.pushEntry({
2094
+ text: "",
2095
+ tone: "assistant",
2096
+ prefix: "• ",
2097
+ prefixTone: "assistant",
2098
+ continuationPrefix: " ",
2099
+ })
2100
+ : null;
2101
+ if (!ui) {
2102
+ output.write(styleTranscriptPrefix("• ", "assistant", supportsAnsi()));
2103
+ }
1439
2104
  assistantPrefixPrinted = true;
1440
2105
  }
1441
- output.write(contentChunk);
2106
+ if (ui && assistantEntryId) {
2107
+ ui.appendToEntry(assistantEntryId, contentChunk);
2108
+ }
2109
+ else {
2110
+ output.write(contentChunk);
2111
+ }
1442
2112
  streamedAssistant += contentChunk;
1443
2113
  });
1444
- if (assistantPrefixPrinted) {
2114
+ if (assistantPrefixPrinted && !ui) {
1445
2115
  output.write("\n");
1446
2116
  }
1447
2117
  let finalAssistantSummary = normalizeText(streamedAssistant);
@@ -1453,10 +2123,10 @@ async function runOttoConsole(rl, config, runtimeSession, options) {
1453
2123
  : null;
1454
2124
  const jobId = normalizeText(job?.id);
1455
2125
  if (bridgeSummary && !jobId) {
1456
- printAssistantMessage(bridgeSummary);
2126
+ emitAssistantPrompt(bridgeSummary);
1457
2127
  }
1458
2128
  if (jobId) {
1459
- finalAssistantSummary = await followConsoleJob(rl, config, jobId);
2129
+ finalAssistantSummary = await followConsoleJobUi(jobId);
1460
2130
  }
1461
2131
  else if (bridgeSummary) {
1462
2132
  finalAssistantSummary = bridgeSummary;
@@ -1467,23 +2137,29 @@ async function runOttoConsole(rl, config, runtimeSession, options) {
1467
2137
  while (conversation.length > 18) {
1468
2138
  conversation.shift();
1469
2139
  }
2140
+ renderConversationState();
1470
2141
  }
1471
2142
  };
1472
- if (options?.initialPrompt) {
1473
- await handlePrompt(options.initialPrompt);
1474
- }
1475
- for (;;) {
1476
- const promptText = await askConsoleInput(rl);
1477
- try {
1478
- await handlePrompt(promptText);
2143
+ try {
2144
+ if (options?.initialPrompt) {
2145
+ await handlePrompt(options.initialPrompt);
1479
2146
  }
1480
- catch (error) {
1481
- if (isCliExitError(error)) {
1482
- break;
2147
+ for (;;) {
2148
+ const promptText = await readConsoleInput();
2149
+ try {
2150
+ await handlePrompt(promptText);
2151
+ }
2152
+ catch (error) {
2153
+ if (isCliExitError(error)) {
2154
+ break;
2155
+ }
2156
+ throw error;
1483
2157
  }
1484
- throw error;
1485
2158
  }
1486
2159
  }
2160
+ finally {
2161
+ ui?.dispose();
2162
+ }
1487
2163
  }
1488
2164
  async function printStatusView(rl, config, runtimeSession) {
1489
2165
  printSection("Bridge Status");
@@ -1516,7 +2192,7 @@ async function printHelpView(rl) {
1516
2192
  { text: "otto-bridge version | otto-bridge unpair", tone: "primary" },
1517
2193
  { text: "Mostra a versao instalada ou remove o pairing local.", tone: "muted" },
1518
2194
  { text: "", tone: "muted" },
1519
- { text: "Dentro do console: /help, /model fast|thinking, /status, /clear, /exit", tone: "muted" },
2195
+ { text: "Dentro do console: /help, /model fast|thinking, /approval preview|confirm|trusted, /status, /clear, /exit", tone: "muted" },
1520
2196
  ]));
1521
2197
  await pauseForEnter(rl);
1522
2198
  }
@@ -1570,7 +2246,7 @@ export async function launchInteractiveCli(options) {
1570
2246
  try {
1571
2247
  let config = await loadBridgeConfig();
1572
2248
  if (!config) {
1573
- printHubScreen(null, "fast");
2249
+ printHubScreen(null);
1574
2250
  const setup = await runSetupWizard(rl, options);
1575
2251
  config = setup.config;
1576
2252
  if (config && setup.openConsole) {
@@ -1587,7 +2263,7 @@ export async function launchInteractiveCli(options) {
1587
2263
  await runtimeSession.ensureStarted();
1588
2264
  await runtimeSession.waitForReady();
1589
2265
  for (;;) {
1590
- printHubScreen(runtimeSession, "fast");
2266
+ printHubScreen(runtimeSession);
1591
2267
  const choice = await pickHomeChoice(rl, true);
1592
2268
  if (choice === "exit") {
1593
2269
  break;
@@ -1685,7 +2361,7 @@ export async function runSetupCommand(options) {
1685
2361
  const rl = await createPromptInterface();
1686
2362
  let runtimeSession = null;
1687
2363
  try {
1688
- printHubScreen(null, "fast");
2364
+ printHubScreen(null);
1689
2365
  const setup = await runSetupWizard(rl, options);
1690
2366
  if (setup.config && setup.openConsole) {
1691
2367
  runtimeSession = new CliRuntimeSession(setup.config);
@@ -1708,7 +2384,7 @@ export async function runConsoleCommand(initialPrompt) {
1708
2384
  const rl = await createPromptInterface();
1709
2385
  let runtimeSession = null;
1710
2386
  try {
1711
- printHubScreen(null, "fast");
2387
+ printHubScreen(null);
1712
2388
  let config = await loadBridgeConfig();
1713
2389
  if (!config) {
1714
2390
  const setup = await runSetupWizard(rl);
package/dist/http.js CHANGED
@@ -60,6 +60,15 @@ export async function postDeviceJson(apiBaseUrl, deviceToken, pathname, body) {
60
60
  body: JSON.stringify(body),
61
61
  });
62
62
  }
63
+ export async function patchDeviceJson(apiBaseUrl, deviceToken, pathname, body) {
64
+ return await requestJson(apiBaseUrl, pathname, {
65
+ method: "PATCH",
66
+ headers: buildDeviceAuthHeaders(deviceToken, {
67
+ "Content-Type": "application/json",
68
+ }),
69
+ body: JSON.stringify(body),
70
+ });
71
+ }
63
72
  export async function deleteDeviceJson(apiBaseUrl, deviceToken, pathname) {
64
73
  return await requestJson(apiBaseUrl, pathname, {
65
74
  method: "DELETE",
package/dist/main.js CHANGED
@@ -122,6 +122,7 @@ Console:
122
122
  /help
123
123
  /model fast
124
124
  /model thinking
125
+ /approval preview|confirm|trusted
125
126
  /workspace
126
127
  /workspace attach <path>
127
128
  /workspace use <id|n>
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "1.1.0";
2
+ export const BRIDGE_VERSION = "1.1.2";
3
3
  export const BRIDGE_PACKAGE_NAME = "@leg3ndy/otto-bridge";
4
4
  export const DEFAULT_API_BASE_URL = "http://localhost:8000";
5
5
  export const DEFAULT_POLL_INTERVAL_MS = 3000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leg3ndy/otto-bridge",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",
@@ -24,7 +24,7 @@ if (!existsSync(mainPath)) {
24
24
  process.exit(0);
25
25
  }
26
26
 
27
- console.log("\n[otto-bridge] Welcome to OTTOAI 1.1.0");
27
+ console.log("\n[otto-bridge] Welcome to OTTOAI 1.1.2");
28
28
  console.log("[otto-bridge] Vamos iniciar o setup interativo do bridge.\n");
29
29
 
30
30
  const result = spawnSync(process.execPath, [mainPath, "setup", "--postinstall"], {