ffai-cli 0.2.0__py3-none-any.whl

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.
ffai/repl.py ADDED
@@ -0,0 +1,933 @@
1
+ """Interactive REPL and Rich presentation for ffai."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import assert_never, cast
10
+
11
+ from pydantic import SecretStr, ValidationError
12
+ from rich import box
13
+ from rich.console import Console, Group
14
+ from rich.panel import Panel
15
+ from rich.prompt import Confirm
16
+ from rich.syntax import Syntax
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from ffai import __version__
21
+ from ffai.config import (
22
+ API_KEY_ENV,
23
+ MODEL_ENV,
24
+ Settings,
25
+ env_file_path,
26
+ save_llm_settings,
27
+ )
28
+ from ffai.errors import CommandBuildError, ConfigError, FFAIError, ParseError
29
+ from ffai.executor import ExecutionResult, execute_tool
30
+ from ffai.i18n import Translator, resolve_language
31
+ from ffai.llm import LLMClient
32
+ from ffai.models import LLMDecision, ToolName
33
+ from ffai.parser import BuiltinCommand, NaturalLanguage, ParsedInput, parse_input
34
+ from ffai.providers import (
35
+ CUSTOM_PROVIDER,
36
+ PROVIDER_CHOICES,
37
+ ProviderPreset,
38
+ identify_provider,
39
+ )
40
+ from ffai.state import SessionState, TaskChange
41
+ from ffai.tools import TOOL_SPECS, missing_tools
42
+ from ffai.utils import (
43
+ format_command,
44
+ format_file_size,
45
+ probe_duration,
46
+ )
47
+
48
+ PROMPT = "[bold cyan]ffai[/bold cyan][bright_black]>[/bright_black] "
49
+
50
+ _FIELD_LABEL_KEYS = {
51
+ "tool": "field.tool",
52
+ "video_filters": "field.video_filters",
53
+ "audio_filters": "field.audio_filters",
54
+ "filter_complex": "field.filter_complex",
55
+ "maps": "field.maps",
56
+ "video_codec": "field.video_codec",
57
+ "audio_codec": "field.audio_codec",
58
+ "crf": "field.crf",
59
+ "preset": "field.preset",
60
+ "video_bitrate": "field.video_bitrate",
61
+ "audio_bitrate": "field.audio_bitrate",
62
+ "extra_options": "field.extra_options",
63
+ "description": "field.description",
64
+ "from_format": "field.from_format",
65
+ "to_format": "field.to_format",
66
+ "standalone": "field.standalone",
67
+ "toc": "field.toc",
68
+ "toc_depth": "field.toc_depth",
69
+ "number_sections": "field.number_sections",
70
+ "shift_heading_level_by": "field.shift_heading_level_by",
71
+ "metadata": "field.metadata",
72
+ "template": "field.template",
73
+ "reference_doc": "field.reference_doc",
74
+ "pdf_engine": "field.pdf_engine",
75
+ "highlight_style": "field.highlight_style",
76
+ "embed_resources": "field.embed_resources",
77
+ "citeproc": "field.citeproc",
78
+ "bibliography": "field.bibliography",
79
+ "csl": "field.csl",
80
+ "resource_path": "field.resource_path",
81
+ }
82
+
83
+ _HELP_ROWS = [
84
+ ("add <file> [file ...]", "help.add"),
85
+ ("tool <ffmpeg|pandoc>", "help.tool"),
86
+ ("setoutput <file>", "help.setoutput"),
87
+ ("show", "help.show"),
88
+ ("preview", "help.preview"),
89
+ ("exec", "help.exec"),
90
+ ("reset", "help.reset"),
91
+ ("config [show|set]", "help.config"),
92
+ ("help", "help.help"),
93
+ ("exit / quit", "help.exit"),
94
+ ]
95
+
96
+
97
+ class FFaiRepl:
98
+ def __init__(
99
+ self,
100
+ settings: Settings,
101
+ console: Console | None = None,
102
+ translator: Translator | None = None,
103
+ ) -> None:
104
+ self.settings = settings
105
+ self.console = console or Console(highlight=False)
106
+ self.t = translator or Translator(resolve_language())
107
+ self.state = SessionState()
108
+ self._llm_client: LLMClient | None = None
109
+ self._running = True
110
+
111
+ def run(self) -> None:
112
+ self._render_banner()
113
+ self._require_llm_configuration()
114
+ self._render_startup_notes()
115
+ first_prompt = True
116
+ while self._running:
117
+ if not first_prompt:
118
+ # Keep the prompt visually distinct from every command's output.
119
+ self.console.print()
120
+ first_prompt = False
121
+ try:
122
+ raw = self.console.input(PROMPT)
123
+ except EOFError:
124
+ self.console.print()
125
+ break
126
+ except KeyboardInterrupt:
127
+ self.console.print()
128
+ self.console.print(Text(self.t("loop.quit_hint"), style="dim"))
129
+ continue
130
+
131
+ if not raw.strip():
132
+ continue
133
+ try:
134
+ parsed = parse_input(raw)
135
+ except ParseError as exc:
136
+ self._show_error(self.t("error.input_title"), exc)
137
+ continue
138
+
139
+ try:
140
+ self._dispatch(parsed)
141
+ except FFAIError as exc:
142
+ self._show_error(self.t("error.title"), exc)
143
+ except KeyboardInterrupt:
144
+ self.console.print()
145
+ self._show_warning(self.t("loop.interrupt_kept"))
146
+ except BrokenPipeError:
147
+ break
148
+
149
+ self.console.print(Text(self.t("loop.goodbye"), style="dim"))
150
+
151
+ def _dispatch(self, parsed: ParsedInput) -> None:
152
+ if isinstance(parsed, NaturalLanguage):
153
+ self._handle_natural_language(parsed)
154
+ return
155
+
156
+ if not isinstance(parsed, BuiltinCommand):
157
+ assert_never(parsed)
158
+
159
+ match parsed.name:
160
+ case "add":
161
+ self._cmd_add(parsed.args)
162
+ case "tool":
163
+ self._cmd_tool(cast(ToolName, parsed.args[0]))
164
+ case "setoutput":
165
+ self._cmd_setoutput(parsed.args[0])
166
+ case "show":
167
+ self._cmd_show()
168
+ case "preview":
169
+ self._cmd_preview()
170
+ case "exec":
171
+ self._cmd_exec()
172
+ case "reset":
173
+ self._cmd_reset()
174
+ case "config":
175
+ self._cmd_config(parsed.args)
176
+ case "help":
177
+ self._cmd_help()
178
+ case "exit":
179
+ self._running = False
180
+ case _ as unreachable:
181
+ raise AssertionError(f"Unhandled built-in command: {unreachable}")
182
+
183
+ def _cmd_add(self, paths: Sequence[str]) -> None:
184
+ result = self.state.add_inputs(paths)
185
+ for path in result.added:
186
+ self._success(self.t("action.added", path=path))
187
+ for path in result.duplicates:
188
+ self._show_warning(self.t("action.duplicate", path=path))
189
+ if result.switched_tool is not None:
190
+ self.console.print(
191
+ Text(
192
+ self.t(
193
+ "action.tool_detected",
194
+ tool=TOOL_SPECS[result.switched_tool].label,
195
+ ),
196
+ style="cyan",
197
+ )
198
+ )
199
+ if result.added:
200
+ self.console.print(Text(self.t("action.add_tip"), style="dim"))
201
+ elif not result.duplicates:
202
+ self._show_warning(self.t("action.no_files"))
203
+
204
+ def _cmd_tool(self, tool: ToolName) -> None:
205
+ label = TOOL_SPECS[tool].label
206
+ if self.state.select_tool(tool):
207
+ self._success(self.t("action.tool_set", tool=label))
208
+ else:
209
+ self.console.print(Text(self.t("action.tool_unchanged", tool=label), style="dim"))
210
+
211
+ def _cmd_setoutput(self, path: str) -> None:
212
+ output = self.state.set_output(path)
213
+ self._success(self.t("action.output_set", path=output))
214
+
215
+ def _cmd_show(self) -> None:
216
+ self.console.print(self._task_panel())
217
+
218
+ def _cmd_preview(self) -> None:
219
+ spec = self.state.spec
220
+ task = self.state.task
221
+ try:
222
+ command = spec.builder(task)
223
+ except CommandBuildError as exc:
224
+ self._show_error(self.t("error.cannot_build_title"), exc)
225
+ return
226
+ self.console.print(self._command_panel(command, preview=True))
227
+ self._show_overwrite_notice(task.output)
228
+
229
+ def _cmd_exec(self) -> None:
230
+ spec = self.state.spec
231
+ task = self.state.task
232
+ try:
233
+ command = spec.builder(task)
234
+ except CommandBuildError as exc:
235
+ self._show_error(self.t("error.cannot_build_title"), exc)
236
+ return
237
+
238
+ self.console.print(self._command_panel(command, preview=False))
239
+ self._show_overwrite_notice(task.output)
240
+
241
+ try:
242
+ confirmed = Confirm.ask(
243
+ self.t("panel.confirm_execute"),
244
+ console=self.console,
245
+ default=False,
246
+ )
247
+ except (EOFError, KeyboardInterrupt):
248
+ self.console.print()
249
+ self._show_warning(self.t("execution.cancelled"))
250
+ return
251
+ if not confirmed:
252
+ self._show_warning(self.t("execution.cancelled"))
253
+ return
254
+
255
+ assert task.output is not None
256
+ duration = (
257
+ probe_duration(task.inputs[0])
258
+ if spec.supports_progress and task.inputs
259
+ else None
260
+ )
261
+ try:
262
+ result = execute_tool(
263
+ command,
264
+ expected_output=task.output,
265
+ console=self.console,
266
+ tool_label=spec.label,
267
+ total_duration=duration,
268
+ show_progress=spec.supports_progress,
269
+ )
270
+ except KeyboardInterrupt:
271
+ self.console.print()
272
+ self._show_warning(self.t("execution.interrupted"))
273
+ return
274
+
275
+ self._render_execution_result(result, tool_label=spec.label)
276
+
277
+ def _cmd_reset(self) -> None:
278
+ self.state.reset()
279
+ self._success(self.t("action.reset"))
280
+
281
+ def _cmd_config(self, args: Sequence[str]) -> None:
282
+ action = args[0] if args else "show"
283
+ if action == "set":
284
+ if self._run_config_wizard():
285
+ self.console.print(self._config_panel())
286
+ return
287
+
288
+ self.console.print(self._config_panel())
289
+ self.console.print(Text(self.t("config.show_hint"), style="dim"))
290
+
291
+ def _can_prompt(self) -> bool:
292
+ return self.console.is_terminal and sys.stdin.isatty()
293
+
294
+ def _require_llm_configuration(self) -> None:
295
+ if self.settings.llm_configured:
296
+ return
297
+
298
+ missing = []
299
+ if self.settings.api_key is None:
300
+ missing.append(API_KEY_ENV)
301
+ if self.settings.model is None:
302
+ missing.append(MODEL_ENV)
303
+
304
+ if not self._can_prompt():
305
+ raise ConfigError(self.t("startup.noninteractive", missing=", ".join(missing)))
306
+
307
+ intro = Text()
308
+ intro.append(self.t("startup.setup_required_intro"), style="bold")
309
+ intro.append(self.t("startup.missing_label"), style="bold")
310
+ intro.append(", ".join(missing))
311
+ intro.append(self.t("startup.setup_hint"), style="dim")
312
+ self.console.print(
313
+ Panel(
314
+ intro,
315
+ title=self.t("config.setup_required_title"),
316
+ border_style="yellow",
317
+ box=box.ROUNDED,
318
+ padding=(1, 2),
319
+ )
320
+ )
321
+ if not self._run_config_wizard():
322
+ raise ConfigError(self.t("startup.setup_cancelled"))
323
+
324
+ def _run_config_wizard(self) -> bool:
325
+ if not self._can_prompt():
326
+ self._show_error(
327
+ self.t("error.config_title"),
328
+ ConfigError(self.t("config.interactive_required")),
329
+ )
330
+ return False
331
+
332
+ instructions = Text()
333
+ instructions.append(self.t("config.wizard_intro"), style="bold")
334
+ instructions.append(self.t("config.wizard_hint"), style="dim")
335
+ instructions.append(self.t("config.save_location", path=env_file_path()), style="dim")
336
+ self.console.print(
337
+ Panel(
338
+ instructions,
339
+ title=self.t("config.title"),
340
+ border_style="cyan",
341
+ box=box.ROUNDED,
342
+ padding=(1, 2),
343
+ )
344
+ )
345
+
346
+ has_existing_settings = bool(
347
+ self.settings.api_key or self.settings.base_url or self.settings.model
348
+ )
349
+ current_provider = (
350
+ (identify_provider(self.settings.base_url) or CUSTOM_PROVIDER)
351
+ if has_existing_settings
352
+ else None
353
+ )
354
+ self._render_provider_menu(current_provider)
355
+ current_key = self.settings.api_key.get_secret_value() if self.settings.api_key else None
356
+ current_base_url = self.settings.base_url
357
+ current_model = self.settings.model
358
+
359
+ try:
360
+ provider = self._ask_provider(current_provider)
361
+ provider_changed = current_provider is None or provider.id != current_provider.id
362
+ self._render_provider_details(provider)
363
+ if provider is CUSTOM_PROVIDER:
364
+ base_url = self._ask_custom_base_url(
365
+ current_base_url if not provider_changed else None
366
+ )
367
+ else:
368
+ base_url = provider.base_url
369
+ api_key = self._ask_required_secret(
370
+ self._provider_name(provider),
371
+ current_key if not provider_changed else None,
372
+ )
373
+ model = self._ask_model(
374
+ current_model if not provider_changed else None,
375
+ provider.default_model,
376
+ )
377
+ except (EOFError, KeyboardInterrupt):
378
+ self.console.print()
379
+ self._show_warning(self.t("config.setup_cancelled"))
380
+ return False
381
+
382
+ try:
383
+ candidate = Settings(api_key=api_key, base_url=base_url, model=model)
384
+ candidate.require_llm()
385
+ except (ValidationError, ConfigError) as exc:
386
+ self._show_error(
387
+ self.t("error.invalid_config_title"), ConfigError(_validation_message(exc))
388
+ )
389
+ return False
390
+
391
+ try:
392
+ saved = save_llm_settings(candidate)
393
+ except ConfigError as exc:
394
+ self._show_error(self.t("error.config_title"), exc)
395
+ return False
396
+
397
+ self.settings = candidate
398
+ self._llm_client = None
399
+ self._success(self.t("action.config_saved", path=saved))
400
+ return True
401
+
402
+ def _provider_name(self, provider: ProviderPreset) -> str:
403
+ if self.t.language == "zh" and provider.name_zh:
404
+ return provider.name_zh
405
+ return provider.name
406
+
407
+ def _provider_note(self, provider: ProviderPreset) -> str | None:
408
+ if self.t.language == "zh" and provider.note_zh:
409
+ return provider.note_zh
410
+ return provider.note
411
+
412
+ def _current_provider(self) -> ProviderPreset:
413
+ return identify_provider(self.settings.base_url) or CUSTOM_PROVIDER
414
+
415
+ def _render_provider_menu(self, current: ProviderPreset | None) -> None:
416
+ table = Table(
417
+ box=box.SIMPLE_HEAD,
418
+ show_edge=False,
419
+ pad_edge=False,
420
+ expand=True,
421
+ padding=(0, 1),
422
+ )
423
+ table.add_column(self.t("config.column_number"), style="bold cyan", no_wrap=True)
424
+ table.add_column(self.t("config.column_provider"), no_wrap=True)
425
+ table.add_column(self.t("config.column_base_url"))
426
+ table.add_column(self.t("config.column_model"), no_wrap=True)
427
+ for index, provider in enumerate(PROVIDER_CHOICES, start=1):
428
+ label = self._provider_name(provider)
429
+ if current is not None and provider.id == current.id:
430
+ label = self.t("config.current_marker", name=label)
431
+ table.add_row(
432
+ str(index),
433
+ label,
434
+ provider.base_url or self.t("config.custom_url_placeholder"),
435
+ provider.default_model or self.t("config.custom_model_placeholder"),
436
+ )
437
+ self.console.print(table)
438
+
439
+ def _render_provider_details(self, provider: ProviderPreset) -> None:
440
+ details = Text()
441
+ details.append(self._provider_name(provider), style="bold")
442
+ note = self._provider_note(provider)
443
+ if note:
444
+ details.append(f" — {note}", style="dim")
445
+ self.console.print(details)
446
+ if provider.docs_url:
447
+ docs = Text(self.t("config.docs_label"), style="dim")
448
+ docs.append(provider.docs_url, style="dim")
449
+ self.console.print(docs)
450
+ if provider.api_key_url:
451
+ keys = Text(self.t("config.api_keys_label"), style="dim")
452
+ keys.append(provider.api_key_url, style="dim")
453
+ self.console.print(keys)
454
+
455
+ def _ask_provider(self, current: ProviderPreset | None) -> ProviderPreset:
456
+ aliases = {
457
+ "openai": "openai",
458
+ "openrouter": "openrouter",
459
+ "deepseek": "deepseek",
460
+ "glm": "glm",
461
+ "zhipu": "glm",
462
+ "kimi": "kimi",
463
+ "moonshot": "kimi",
464
+ "qwen": "qwen",
465
+ "dashscope": "qwen",
466
+ "custom": "custom",
467
+ }
468
+ prompt = (
469
+ self.t("config.provider_prompt_keep", current=self._provider_name(current))
470
+ if current is not None
471
+ else self.t("config.provider_prompt_new", count=len(PROVIDER_CHOICES))
472
+ )
473
+ while True:
474
+ raw = self.console.input(prompt).strip()
475
+ if not raw:
476
+ if current:
477
+ return current
478
+ self._show_warning(self.t("config.provider_required"))
479
+ continue
480
+ if raw.isdigit():
481
+ index = int(raw) - 1
482
+ if 0 <= index < len(PROVIDER_CHOICES):
483
+ return PROVIDER_CHOICES[index]
484
+ provider = next(
485
+ (
486
+ item
487
+ for item in PROVIDER_CHOICES
488
+ if item.id == aliases.get(raw.lower(), raw.lower())
489
+ ),
490
+ None,
491
+ )
492
+ if provider is not None:
493
+ return provider
494
+ self._show_warning(self.t("config.provider_unknown"))
495
+
496
+ def _ask_required_secret(self, provider: str, current: str | None) -> str:
497
+ if current:
498
+ prompt = self.t("config.api_key_prompt_keep", provider=provider)
499
+ else:
500
+ prompt = self.t("config.api_key_prompt_new", provider=provider)
501
+ while True:
502
+ value = self.console.input(prompt, password=True).strip()
503
+ if value:
504
+ return value
505
+ if current:
506
+ return current
507
+ self._show_warning(self.t("config.api_key_required", provider=provider))
508
+
509
+ def _ask_model(self, current: str | None, suggested: str | None) -> str:
510
+ if current:
511
+ prompt = self.t("config.model_prompt_keep")
512
+ elif suggested:
513
+ prompt = self.t("config.model_prompt_default", model=suggested)
514
+ else:
515
+ prompt = self.t("config.model_prompt_new")
516
+ while True:
517
+ value = self.console.input(prompt).strip()
518
+ if value:
519
+ return value
520
+ if current:
521
+ return current
522
+ if suggested:
523
+ return suggested
524
+ self._show_warning(self.t("config.model_required"))
525
+
526
+ def _ask_custom_base_url(self, current: str | None) -> str:
527
+ prompt = (
528
+ self.t("config.base_url_prompt_keep")
529
+ if current
530
+ else self.t("config.base_url_prompt_new")
531
+ )
532
+ while True:
533
+ value = self.console.input(prompt).strip()
534
+ if not value:
535
+ if current:
536
+ return current
537
+ self._show_warning(self.t("config.base_url_required"))
538
+ continue
539
+ try:
540
+ validated = Settings(base_url=value).base_url
541
+ except ValidationError as exc:
542
+ self._show_error(
543
+ self.t("error.invalid_base_url_title"),
544
+ ConfigError(_validation_message(exc)),
545
+ )
546
+ continue
547
+ if validated is None:
548
+ self._show_warning(self.t("config.base_url_required"))
549
+ continue
550
+ return validated
551
+
552
+ def _config_panel(self) -> Panel:
553
+ table = Table(
554
+ box=box.SIMPLE,
555
+ show_header=False,
556
+ show_edge=False,
557
+ pad_edge=False,
558
+ expand=True,
559
+ padding=(0, 1),
560
+ )
561
+ table.add_column("Field", style="bold cyan", no_wrap=True, width=18)
562
+ table.add_column("Value")
563
+ table.add_row(
564
+ self.t("config.provider"),
565
+ Text(self._provider_name(self._current_provider())),
566
+ )
567
+ table.add_row(
568
+ self.t("config.api_key"),
569
+ Text(_mask_secret(self.settings.api_key, self.t("config.not_set"))),
570
+ )
571
+ table.add_row(
572
+ self.t("config.model"),
573
+ Text(self.settings.model or self.t("config.not_set")),
574
+ )
575
+ table.add_row(
576
+ self.t("config.base_url"),
577
+ Text(self.settings.base_url or self.t("config.openai_default")),
578
+ )
579
+ config_path = env_file_path()
580
+ path_label = (
581
+ str(config_path)
582
+ if config_path.exists()
583
+ else self.t("config.file_not_created", path=config_path)
584
+ )
585
+ table.add_row(self.t("config.config_file"), Text(path_label))
586
+
587
+ return Panel(
588
+ table,
589
+ title=self.t("config.title"),
590
+ border_style="cyan",
591
+ box=box.ROUNDED,
592
+ padding=(1, 2),
593
+ )
594
+
595
+ def _cmd_help(self) -> None:
596
+ table = Table(
597
+ box=box.SIMPLE_HEAD,
598
+ show_edge=False,
599
+ pad_edge=False,
600
+ expand=True,
601
+ padding=(0, 1),
602
+ )
603
+ table.add_column(self.t("help.command"), style="bold cyan", no_wrap=True)
604
+ table.add_column(self.t("help.description"), style="white")
605
+ for command, description_key in _HELP_ROWS:
606
+ table.add_row(command, self.t(description_key))
607
+
608
+ examples = Text()
609
+ examples.append(self.t("help.examples_title"), style="bold")
610
+ examples.append(self.t("help.example_crop"), style="dim")
611
+ examples.append(self.t("help.example_codec"), style="dim")
612
+ examples.append(self.t("help.example_audio"), style="dim")
613
+ examples.append(self.t("help.example_doc"), style="dim")
614
+ examples.append(self.t("help.example_pdf"), style="dim")
615
+ examples.append(self.t("help.llm_note"), style="dim")
616
+ examples.append("\n")
617
+ examples.append(self.t("help.aliases"), style="dim")
618
+
619
+ self.console.print(
620
+ Panel(
621
+ Group(table, examples),
622
+ title=self.t("help.title"),
623
+ border_style="cyan",
624
+ box=box.ROUNDED,
625
+ padding=(1, 2),
626
+ )
627
+ )
628
+
629
+ def _handle_natural_language(self, request: NaturalLanguage) -> None:
630
+ config = self.settings.require_llm()
631
+ client = self._llm_client
632
+ if client is None:
633
+ client = LLMClient(config)
634
+ self._llm_client = client
635
+ with self.console.status(f"[cyan]{self.t('status.thinking')}[/cyan]", spinner="dots"):
636
+ decision = client.analyze(self.state.llm_context(), request.text)
637
+ self._render_decision(decision)
638
+
639
+ def _render_decision(self, decision: LLMDecision) -> None:
640
+ if decision.intent == "apply":
641
+ changes = self.state.apply_patch(decision.patch)
642
+ self.console.print(Panel(Text(decision.explanation), title="ffai", border_style="cyan"))
643
+ if changes:
644
+ self._render_changes(changes)
645
+ self.console.print(Text(self.t("changes.hint"), style="dim"))
646
+ else:
647
+ self._show_warning(self.t("changes.none"))
648
+ if decision.question:
649
+ self.console.print()
650
+ self.console.print(
651
+ Panel(
652
+ Text(decision.question),
653
+ title=self.t("decision.question_title"),
654
+ border_style="yellow",
655
+ )
656
+ )
657
+ return
658
+
659
+ if decision.intent == "clarify":
660
+ self.console.print(Panel(Text(decision.explanation), title="ffai", border_style="cyan"))
661
+ if decision.question:
662
+ self.console.print(
663
+ Panel(
664
+ Text(decision.question),
665
+ title=self.t("decision.clarification_title"),
666
+ border_style="yellow",
667
+ )
668
+ )
669
+ return
670
+
671
+ if decision.intent == "noop":
672
+ self.console.print(Panel(Text(decision.explanation), title="ffai", border_style="cyan"))
673
+ return
674
+
675
+ assert_never(decision.intent)
676
+
677
+ def _render_execution_result(self, result: ExecutionResult, *, tool_label: str) -> None:
678
+ if result.interrupted:
679
+ self._show_warning(self.t("execution.interrupted_partial"))
680
+ return
681
+ if result.succeeded:
682
+ text = Text()
683
+ text.append(self.t("execution.success"), style="bold green")
684
+ text.append(self.t("execution.output_label"), style="dim")
685
+ text.append(result.output_path)
686
+ size = format_file_size(result.output_path)
687
+ if size:
688
+ text.append(f" ({size})", style="dim")
689
+ text.append(
690
+ self.t("execution.elapsed", seconds=result.elapsed_seconds),
691
+ style="dim",
692
+ )
693
+ self.console.print(
694
+ Panel(text, title=self.t("execution.done_title"), border_style="green")
695
+ )
696
+ return
697
+
698
+ details = Text()
699
+ details.append(
700
+ self.t("execution.exit_code", tool=tool_label, code=result.exit_code),
701
+ style="bold red",
702
+ )
703
+ if result.stderr_tail:
704
+ details.append("\n\n")
705
+ details.append(result.stderr_tail[-6_000:], style="dim")
706
+ self.console.print(
707
+ Panel(
708
+ details,
709
+ title=self.t("execution.failed_title", tool=tool_label),
710
+ border_style="red",
711
+ )
712
+ )
713
+
714
+ def _task_panel(self) -> Panel:
715
+ task = self.state.task
716
+ spec = self.state.spec
717
+ table = Table(
718
+ box=box.SIMPLE,
719
+ show_header=False,
720
+ show_edge=False,
721
+ pad_edge=False,
722
+ expand=True,
723
+ padding=(0, 1),
724
+ )
725
+ table.add_column("Field", style="bold cyan", no_wrap=True, width=18)
726
+ table.add_column("Value")
727
+
728
+ if task.inputs:
729
+ inputs = Text()
730
+ for index, media in enumerate(task.inputs):
731
+ if index:
732
+ inputs.append("\n")
733
+ inputs.append(f"{index + 1}. ", style="dim")
734
+ inputs.append(media)
735
+ table.add_row(self.t("task.inputs"), inputs)
736
+ else:
737
+ table.add_row(self.t("task.inputs"), Text(self.t("task.none"), style="dim"))
738
+
739
+ table.add_row(
740
+ self.t("task.output"),
741
+ Text(task.output) if task.output else Text(self.t("task.none"), style="dim"),
742
+ )
743
+ table.add_row(self.t("task.tool"), Text(spec.label))
744
+ description = task.description
745
+ if description == spec.default_description:
746
+ description = self.t("task.default_description", tool=spec.label)
747
+ table.add_row(
748
+ self.t("task.description"),
749
+ Text(description or self.t("task.none")),
750
+ )
751
+
752
+ for field in spec.display_fields:
753
+ if field == "description":
754
+ continue
755
+ value = getattr(task, field)
756
+ table.add_row(
757
+ self.t(_FIELD_LABEL_KEYS[field]),
758
+ _format_field(
759
+ field,
760
+ value,
761
+ self.t("task.not_set"),
762
+ yes=self.t("task.yes"),
763
+ no=self.t("task.no"),
764
+ ),
765
+ )
766
+
767
+ return Panel(
768
+ table,
769
+ title=self.t("task.title", tool=spec.label, revision=self.state.revision),
770
+ border_style="cyan",
771
+ box=box.ROUNDED,
772
+ padding=(1, 2),
773
+ )
774
+
775
+ def _command_panel(self, command: Sequence[str], *, preview: bool) -> Panel:
776
+ title = self.t("panel.preview_title") if preview else self.t("panel.exec_title")
777
+ syntax = Syntax(
778
+ format_command(command),
779
+ "bash",
780
+ theme="monokai",
781
+ word_wrap=True,
782
+ padding=0,
783
+ background_color="default",
784
+ )
785
+ return Panel(
786
+ syntax,
787
+ title=title,
788
+ border_style="bright_blue" if preview else "yellow",
789
+ box=box.ROUNDED,
790
+ padding=(1, 2),
791
+ )
792
+
793
+ def _render_changes(self, changes: Sequence[TaskChange]) -> None:
794
+ table = Table(
795
+ title=self.t("changes.title"),
796
+ box=box.SIMPLE_HEAD,
797
+ show_edge=False,
798
+ header_style="bold",
799
+ padding=(0, 1),
800
+ )
801
+ table.add_column(self.t("changes.field"), style="cyan", no_wrap=True)
802
+ table.add_column(self.t("changes.before"), style="dim")
803
+ table.add_column(self.t("changes.after"), style="white")
804
+ not_set = self.t("task.not_set")
805
+ for change in changes:
806
+ label = self.t(_FIELD_LABEL_KEYS.get(change.field, "changes.field"))
807
+ if change.field == "tool":
808
+ before = _tool_label(change.before)
809
+ after = _tool_label(change.after)
810
+ else:
811
+ before = _format_change_value(
812
+ change.field,
813
+ change.before,
814
+ not_set,
815
+ yes=self.t("task.yes"),
816
+ no=self.t("task.no"),
817
+ )
818
+ after = _format_change_value(
819
+ change.field,
820
+ change.after,
821
+ not_set,
822
+ yes=self.t("task.yes"),
823
+ no=self.t("task.no"),
824
+ )
825
+ table.add_row(label, Text(before, style="dim"), Text(after))
826
+ self.console.print(table)
827
+
828
+ def _show_overwrite_notice(self, output: str | None) -> None:
829
+ if output and Path(output).exists():
830
+ self.console.print(Text(self.t("panel.overwrite_warning"), style="yellow"))
831
+
832
+ def _render_banner(self) -> None:
833
+ self.console.print(f"[bold cyan]ffai[/bold cyan] [dim]v{__version__}[/dim]")
834
+ self.console.print(Text(self.t("banner.tagline"), style="dim"))
835
+ self.console.print()
836
+
837
+ def _render_startup_notes(self) -> None:
838
+ provider = self._provider_name(self._current_provider())
839
+ endpoint = self.settings.base_url or self.t("config.openai_default")
840
+ api_line = Text()
841
+ api_line.append(self.t("startup.api_label"), style="dim")
842
+ api_line.append(
843
+ self.t(
844
+ "startup.api_value",
845
+ provider=provider,
846
+ model=self.settings.model,
847
+ endpoint=endpoint,
848
+ ),
849
+ style="dim",
850
+ )
851
+ self.console.print(api_line)
852
+ for tool in missing_tools():
853
+ self.console.print(
854
+ Text(
855
+ self.t("startup.tool_missing", tool=TOOL_SPECS[tool].label),
856
+ style="yellow",
857
+ )
858
+ )
859
+ self.console.print(Text(self.t("startup.hint"), style="dim"))
860
+ self.console.print()
861
+
862
+ def _success(self, message: str) -> None:
863
+ text = Text()
864
+ text.append("✓ ", style="green")
865
+ text.append(message)
866
+ self.console.print(text)
867
+
868
+ def _show_warning(self, message: str) -> None:
869
+ text = Text()
870
+ text.append("! ", style="yellow")
871
+ text.append(message, style="yellow")
872
+ self.console.print(text)
873
+
874
+ def _show_error(self, title: str, exc: FFAIError) -> None:
875
+ self.console.print(
876
+ Panel(
877
+ Text(str(exc), style="red"),
878
+ title=f"[bold red]{title}[/bold red]",
879
+ border_style="red",
880
+ box=box.ROUNDED,
881
+ )
882
+ )
883
+
884
+
885
+ def _tool_label(name: object) -> str:
886
+ spec = TOOL_SPECS.get(name) if isinstance(name, str) else None
887
+ return spec.label if spec is not None else str(name)
888
+
889
+
890
+ def _format_field(field: str, value: object, not_set: str, *, yes: str, no: str) -> Text:
891
+ if isinstance(value, bool):
892
+ return Text(yes if value else no)
893
+ if value is None or value == [] or value == "":
894
+ return Text(not_set, style="dim")
895
+ if isinstance(value, list):
896
+ if field == "extra_options":
897
+ return Text(shlex.join(str(item) for item in value))
898
+ return Text(", ".join(str(item) for item in value))
899
+ return Text(str(value))
900
+
901
+
902
+ def _format_change_value(
903
+ field: str,
904
+ value: object,
905
+ not_set: str,
906
+ *,
907
+ yes: str,
908
+ no: str,
909
+ ) -> str:
910
+ if isinstance(value, bool):
911
+ return yes if value else no
912
+ if value is None or value == [] or value == "":
913
+ return not_set
914
+ if isinstance(value, list):
915
+ if field == "extra_options":
916
+ return shlex.join(str(item) for item in value)
917
+ return ", ".join(str(item) for item in value)
918
+ return str(value)
919
+
920
+
921
+ def _mask_secret(secret: SecretStr | None, not_set: str) -> str:
922
+ if secret is None:
923
+ return not_set
924
+ value = secret.get_secret_value()
925
+ if len(value) <= 8:
926
+ return "********"
927
+ return f"********{value[-4:]}"
928
+
929
+
930
+ def _validation_message(exc: ValidationError | ConfigError) -> str:
931
+ if isinstance(exc, ConfigError):
932
+ return str(exc)
933
+ return "; ".join(error["msg"] for error in exc.errors())