TypeDialog/docs/architecture.md
Jesús Pérez baff1c42ec
Some checks failed
CI / Lint (bash) (push) Has been cancelled
CI / Lint (markdown) (push) Has been cancelled
CI / Lint (nickel) (push) Has been cancelled
CI / Lint (nushell) (push) Has been cancelled
CI / Lint (rust) (push) Has been cancelled
CI / Benchmark (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / License Compliance (push) Has been cancelled
CI / Code Coverage (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
refactor(core): eliminate field execution duplication, fix stack-unsafe retry, O(1) context passing
Deduplication
  - Consolidate three copies of match field.field_type into InquireBackend::execute_field_sync
    as single canonical implementation (backends/cli.rs)
  - Add previous_results param to execute_field_sync (pub(crate)); propagate options_from
    filtering previously absent from the cli.rs copy
  - Move filter_options_from to backends/cli.rs pub(crate); remove duplicate copies from
    executor.rs and nickel/roundtrip.rs (-346 lines, 0 behavior regressions)
  - Gate legacy sync path (execute_with_base_dir, execute, load_and_execute_from_file)
    on #[cfg(feature = "cli")]

  Retry fix
  - Replace unbounded recursive retry in execute_field_sync with loop+continue (O(1) stack)

  RenderContext clone cost
  - Change RenderContext.results from HashMap<String, Value> to Arc<HashMap<String, Value>>
  - Change executor accumulators to Arc in execute_with_backend_complete,
    execute_with_backend_tw    execute_with_backend_tw    execute_with_backend_tw    execute_with_backend_tw    execute_with_bante    execute_won:    execute_with_backend_tw    execute_with_backend_tw    execute_with_backend_tw    execute_count==1 (guaranteed at each insert site)
  - typedialog-ai/backend.rs: &context.results -> context.results.iter() for Arc deref

  Doctests
  - Fix five broken doctests: FormDefinition fields renamed, confirm() arity, FormBackend
    methods replaced, cli_loader ignore -> no_run with concr    methods replaced, cli_loader ignore -> no_run with concr    methods reken    methods replaced, cli_loader ignore -> no_run with concr    methods replaced, clsync path
  - docs/architecture.md Known Technical Debt: all items resolved, section closed
  EOF
2026-02-17 15:49:28 +00:00

7.8 KiB

Architecture

Technical positioning and internal architecture of TypeDialog.

Positioning

TypeDialog is not a terminal prompt library — it is a form orchestration layer that sits two abstraction levels above tools like Inquire, Ratatui, or Axum.

The comparison point is relevant: Inquire (inquire = "0.9") is one of TypeDialog's dependencies, consumed as the render engine for the CLI backend. A user of TypeDialog never calls Text::new("Name?").prompt() directly — that call happens inside InquireBackend::execute_field_sync, invisible to the form author.

Abstraction Stack

┌─────────────────────────────────────────────────────┐
│                   typedialog                        │
│   Declarative TOML forms + i18n + templates + AI    │
│   BackendFactory → dispatch by feature              │
├──────────────┬──────────────┬──────────────────────-┤
│  CLI backend │  TUI backend │  Web backend          │
│  (Inquire)   │  (Ratatui)   │  (Axum)               │
└──────────────┴──────────────┴───────────────────────┘
                      │
            Nushell plugin (nu-protocol)

What TypeDialog Is Not

What the primitive does TypeDialog
Library for use in your CLI app Is the app — or the library others embed
Terminal input tool Multi-surface entry point (CLI, TUI, Web, AI)
Comparable to Rhai or Nushell Integrates Nushell via nu-plugin/nu-protocol as an output channel

Differentiators

Unified schema — a single TOML file describes the same form for CLI, TUI, and HTTP. No per-backend code.

Nushell as integration runtime — outputs flow as structured data into Nu pipelines via the Nushell plugin protocol (nu-plugin = "0.110.0"), not as text.

AI backend (typedialog-ai + typedialog-agent) — forms can be generated or processed by agents backed by SurrealDB (kv-mem), Tantivy full-text search, and HNSW vector search (instant-distance).

Native i18n — Fluent bundles with system locale detection, no external tooling required.


BackendFactory

The FormBackend Trait

All backends implement a single async trait (backends/mod.rs:27):

#[async_trait]
pub trait FormBackend: Send + Sync {
    async fn initialize(&mut self) -> Result<()>;
    async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()>;
    async fn execute_field(&self, field: &FieldDefinition, context: &RenderContext) -> Result<Value>;

    // Default impl: falls back to field-by-field via execute_field
    // TUI and Web override this for complete-form rendering
    async fn execute_form_complete(...) -> Result<HashMap<String, Value>>;

    async fn shutdown(&mut self) -> Result<()>;
    fn as_any(&self) -> &dyn std::any::Any;
    fn is_available() -> bool where Self: Sized;
    fn name(&self) -> &str;
}

execute_form_complete has a default implementation that delegates to execute_field sequentially. Backends that need full-form rendering (TUI, Web) override it.

Factory Dispatch

BackendFactory::create returns Box<dyn FormBackend>. The selection is a compile-time #[cfg(feature)] gate combined with runtime match (backends/mod.rs:109):

pub fn create(backend_type: BackendType) -> Result<Box<dyn FormBackend>> {
    match backend_type {
        BackendType::Cli => {
            #[cfg(feature = "cli")]
            { Ok(Box::new(cli::InquireBackend::new())) }
            #[cfg(not(feature = "cli"))]
            { Err(/* clear message */) }
        }
        #[cfg(feature = "tui")]
        BackendType::Tui => Ok(Box::new(tui::RatatuiBackend::new())),
        #[cfg(feature = "web")]
        BackendType::Web { port } => Ok(Box::new(web::WebBackend::new(port))),
    }
}

The backend code for a disabled feature is not compiled into the binary. The trait object provides runtime polymorphism; the feature gate provides compile-time dead-code elimination.

Auto-Detection

BackendFactory::auto_detect follows a fallback chain (backends/mod.rs:132):

TYPEDIALOG_BACKEND=tui  → TUI  (if feature active)
TYPEDIALOG_BACKEND=web  → Web  (port from TYPEDIALOG_PORT, default 9000)
default                 → Cli

RenderContext

Passed to every render and field execution call:

pub struct RenderContext {
    pub results: HashMap<String, Value>,  // accumulated field values
    pub locale: Option<String>,           // locale override
}

results carries the accumulated answers from previous fields. InquireBackend::execute_field_sync receives this as previous_results and uses it for options_from filtering in Select fields.


Form Execution

Three-Phase Execution

execute_with_backend_two_phase_with_defaults (form_parser/executor.rs) is the primary execution path:

Phase 1 — Selector fields first

Fields that control when: conditionals are identified and executed before loading any fragments. This ensures conditional branches are known before lazy-loading dependent content.

let selector_field_names = identify_selector_fields(&form);
// execute only selectors → populate results

Phase 2 — Build element list with lazy loading

With Phase 1 results known, fragments (includes:) are loaded only if their controlling condition is met.

let element_list = build_element_list(&form, base_dir, &results)?;

Phase 3 — Execute remaining fields

Iterates the element list, evaluates when: conditions per-element, and dispatches to the backend. Two sub-modes:

  • DisplayMode::Complete — passes all items and fields to execute_form_complete at once (used by TUI and Web for reactive rendering)
  • Field-by-field — sequential execution with condition evaluation per step (used by CLI)

Full Execution Flow

typedialog binary
│
├── parse TOML → FormDefinition
├── BackendFactory::create(BackendType::Cli)   → Box<dyn FormBackend>
├── execute_with_backend_two_phase_with_defaults(form, backend, i18n, base_dir, defaults)
│       │
│       ├── Phase 1: selector fields → backend.execute_field()
│       ├── Phase 2: build_element_list() with lazy fragment loading
│       └── Phase 3: iterate → render_display_item() / execute_field()
│
└── HashMap<String, Value> → JSON / YAML / TOML output

CLI Field Execution

The InquireBackend implements field execution as a synchronous function (backends/cli.rs):

pub(crate) fn execute_field_sync(
    &self,
    field: &FieldDefinition,
    previous_results: &HashMap<String, Value>,
) -> Result<Value>

The FormBackend::execute_field trait impl wraps it:

async fn execute_field(&self, field: &FieldDefinition, context: &RenderContext) -> Result<Value> {
    self.execute_field_sync(field, &context.results)
}

filter_options_from lives in backends/cli.rs as pub(crate) and is called from within execute_field_sync for Select fields. It filters the declared options to only those present in a referenced prior field's value — enabling dependent selects.

Legacy Sync Path

execute_with_base_dir, execute, and load_and_execute_from_file are a pre-BackendFactory sync path gated on #[cfg(feature = "cli")]. They call InquireBackend::execute_field_sync directly and bypass the async executor. Available for backward compatibility; the canonical path for new code is execute_with_backend_two_phase_with_defaults.


Known Technical Debt

No unresolved items.