Raxol.UI.Components.Harness.Composer (Raxol v2.6.1)

View Source

Harness prompt composer: wraps Raxol.UI.Components.Input.MultiLineInput with harness-specific submit semantics, input history recall, bracketed paste fidelity, and a queued-steer banner. Does not reimplement multi-line editing -- cursor movement, selection, and text mutation stay entirely in MultiLineInput; this module only decides when those primitives fire and adds one banner above the prompt.

Submit semantics

Enter with no modifiers submits ({:component_event, id, {:submit, text}}) when the buffer is a single logical line (no embedded \n, regardless of visual wrap) and non-empty after trimming. Enter with :shift or :alt held, or Enter while the buffer already spans multiple logical lines (continuation), inserts a literal newline instead via MultiLineInput's own {:enter} message. force_submit/1 is exposed for a consumer (T12 keybinds) to wire an unconditional submit key (e.g. Ctrl+Enter) that bypasses the single-line gate.

Legacy terminals and the degraded-mode inlet: many terminals do not encode Shift+Enter or Alt+Enter distinctly (Alt+Enter arrives as ESC CR and the ESC is dropped in parsing -- a bare Enter is all this component sees), so the modifier branches are unreachable there. The modifier-independent primary inlet to a first newline is backslash continuation: a line ending in \ + Enter consumes the backslash and inserts a newline instead of submitting. This works over tmux/SSH/mosh at any point, including creating the very first newline. Escape rule (simplest that round-trips a literal trailing backslash): count the trailing backslash run -- an odd run means continuation (the final backslash is consumed, the rest stay as typed); an even run means submit with the run halved (\\ submits as \). The rule applies only on the Enter-submit path; force_submit/1 and paste submit/insert content verbatim. Bracketed paste and a consumer-wired force_submit/1 remain alternate inlets, and a dim hint line (\ continue · paste multiline · ↵ submit) renders below the input while it is focused, empty, and has no history yet. The modifier branches activate automatically when F1b (kitty keyboard protocol) lands -- zero logic change here.

Input-shape normalization

handle_event/3 normalizes every incoming event through Raxol.UI.Harness.InputEvent.normalize/1 before deciding anything. Before T27's review round this component matched key events by pattern (%{key: :enter, modifiers: modifiers}), which only exists on Event.key_event/3's test-API shape -- the real terminal driver's two shapes (event_translator.ex's boolean shift:/ctrl:/... fields, input_parser.ex's optional fields) have no :modifiers key at all, so the pattern never matched a REAL keypress. Enter never submitted, printable characters never inserted, and everything silently fell through to raw MultiLineInput delegation for actual terminal input -- this unit only ever worked in tests that construct Event.key_event/3 directly. InputEvent.normalize/1 erases that shape difference: Enter from a real termbox driver, a real ANSI parser, or Event.key_event/3 all normalize to the same kind: :key, key: :enter and reach the same handle_enter_key/2.

Printable characters arriving with compound modifiers (e.g. Shift+Alt) are InputEvent.shortcut?/1 (alt held) and fall through to MultiLineInput delegation, same as before -- canonical compound- modifier handling belongs to F1a input canon, not this unit.

Bracketed paste

The terminal driver already parses ESC[200~...ESC[201~ into %Raxol.Core.Events.Event{type: :paste, data: %{text: text}} (Raxol.Terminal.Ansi.InputParser). This component routes that event straight into MultiLineInput's {:clipboard_content, text} message -- the same path Ctrl+V system-clipboard paste already uses (MultiLineInput.SelectionOps.handle_clipboard_content/2) -- so pasted newlines are inserted verbatim in one edit and never pass through the Enter/submit decision above, no matter how many lines the paste contains.

History recall

Up at the first (visual) line, or Down at the last (visual) line, walks a bounded ring (default 100) of past submissions, newest first. The in-progress draft is saved on the first Up so Down can restore it. Any other cursor position forwards Up/Down to MultiLineInput unchanged (normal cursor movement / shift-selection).

Queued-steer banner

queued_steer: nil | %{text: String.t(), queued_at: term()} renders (when set) one dim, truncated line above the prompt, prefixed with "⏸ " -- ⏸ steer queued for next boundary: <text> -- visually distinct from editable content. This is AD-2's two-signal model (interrupt vs. steer) made visible.

Resize safety

No absolute width is persisted on this struct. render/2 derives MultiLineInput's width from context[:available_width] on every call and re-wraps the cached lines for that width, so a resized context produces a correctly-wrapped prompt without any stored width going stale.

Summary

Functions

Unconditional submit, bypassing the single-line gate -- for a consumer keybind (e.g. Ctrl+Enter) that must submit multi-line content directly. Returns {state, []} unchanged if the trimmed buffer is empty.

Bounded, newest-first list of past submissions.

Replace the composer's content programmatically (e.g. after $EDITOR).

Current composer text (delegates to the wrapped MultiLineInput).

Types

queued_steer()

@type queued_steer() :: %{text: String.t(), queued_at: term()} | nil

t()

@type t() :: %{
  id: String.t() | atom(),
  mli: Raxol.UI.Components.Input.MultiLineInput.t(),
  history: [String.t()],
  history_index: non_neg_integer() | nil,
  draft: String.t() | nil,
  queued_steer: queued_steer(),
  max_history: pos_integer(),
  style: map(),
  theme: map()
}

Functions

broadcast(msg)

command(cmd)

force_submit(state)

@spec force_submit(t()) :: {t(), [term()]}

Unconditional submit, bypassing the single-line gate -- for a consumer keybind (e.g. Ctrl+Enter) that must submit multi-line content directly. Returns {state, []} unchanged if the trimmed buffer is empty.

history(map)

@spec history(t()) :: [String.t()]

Bounded, newest-first list of past submissions.

mount(state)

Callback implementation for Raxol.UI.Components.Base.Component.mount/1.

schedule(msg, delay)

set_value(state, text)

@spec set_value(t(), String.t()) :: t()

Replace the composer's content programmatically (e.g. after $EDITOR).

unmount(state)

Callback implementation for Raxol.UI.Components.Base.Component.unmount/1.

value(map)

@spec value(t()) :: String.t()

Current composer text (delegates to the wrapped MultiLineInput).