diff --git a/README.md b/README.md
index 9ca8636..2e3b3a4 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,16 @@ Single-line text input with a movable caret. Type to insert, Left/Right move the
$p->text('name', 'Site name')->default('Acme Site')->required();
```
+Add `complete()` for inline ghost-text autocomplete: as you type, the best-matching candidate is shown dimmed after the caret and accepted with Tab or Right-arrow (at the end of the line). This is distinct from the `suggest` widget's dropdown - ghost-text keeps the eye on the input line. The source is a static list or a closure over the answers collected so far; the match is a case-insensitive prefix, with no match the field behaves as a plain input, and the ghost-text is suppressed when colour is off.
+
+```php
+// Static candidates.
+$p->text('timezone', 'Timezone')->complete(['UTC', 'Europe/London', 'Australia/Sydney']);
+
+// Candidates computed from prior answers.
+$p->text('repo', 'Repository')->complete(fn(array $answers): array => [$answers['owner'] . '/site']);
+```
+
|
diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php
index 1b381e3..16b5afa 100644
--- a/src/Builder/FieldBuilder.php
+++ b/src/Builder/FieldBuilder.php
@@ -74,6 +74,13 @@ final class FieldBuilder {
*/
protected ?\Closure $transform = NULL;
+ /**
+ * The inline ghost-text completion source (a list or a closure).
+ *
+ * @var list|\Closure
+ */
+ protected array|\Closure $completion = [];
+
/**
* The processing weight.
*/
@@ -453,6 +460,26 @@ public function transform(\Closure $transformer): self {
return $this;
}
+ /**
+ * Text only: set the inline ghost-text completion source.
+ *
+ * As the user types, the first candidate the input is a prefix of is shown
+ * dimmed after the caret and accepted with Tab or Right-arrow.
+ *
+ * @param list|\Closure $source
+ * A list of candidate strings, or a
+ * `fn (array $answers): list` closure computing
+ * candidates from the answers collected so far.
+ *
+ * @return $this
+ * The builder.
+ */
+ public function complete(array|\Closure $source): self {
+ $this->completion = $source;
+
+ return $this;
+ }
+
/**
* Add a single option.
*
@@ -562,6 +589,7 @@ public function build(): Field {
$this->pickerStart,
$this->pickerExtensions,
$this->pickerShowHidden,
+ $this->completion,
);
}
diff --git a/src/Config/Field.php b/src/Config/Field.php
index 5fec8b8..3f673a4 100644
--- a/src/Config/Field.php
+++ b/src/Config/Field.php
@@ -81,6 +81,11 @@
* (dot-less, case-insensitive); empty allows every extension.
* @param bool $pickerShowHidden
* File picker only: whether dot-entries are shown when the browser opens.
+ * @param list|\Closure $completion
+ * Text only: the inline ghost-text completion source - a list of candidate
+ * strings, or a `fn (array $answers): list` closure
+ * over the answers collected so far. Empty disables ghost-text; ignored by
+ * other types.
*/
public function __construct(
public string $id,
@@ -104,6 +109,7 @@ public function __construct(
public string $pickerStart = '',
public array $pickerExtensions = [],
public bool $pickerShowHidden = FALSE,
+ public array|\Closure $completion = [],
) {
$this->options = Option::list($options);
}
diff --git a/src/Input/Action.php b/src/Input/Action.php
index e92c05d..f5d3f2e 100644
--- a/src/Input/Action.php
+++ b/src/Input/Action.php
@@ -40,6 +40,7 @@ enum Action {
case InsertSpace;
case NewLine;
case ExternalEdit;
+ case Complete;
// List and multiselect.
case Toggle;
diff --git a/src/Input/DefaultKeyMap.php b/src/Input/DefaultKeyMap.php
index ab34005..dbe6343 100644
--- a/src/Input/DefaultKeyMap.php
+++ b/src/Input/DefaultKeyMap.php
@@ -46,6 +46,9 @@ public function bindings(): array {
new Binding(Scope::navigation(), Action::ScrollUp, KeyName::MouseWheelUp),
new Binding(Scope::navigation(), Action::ScrollDown, KeyName::MouseWheelDown),
+ // The text field accepts its inline ghost-text completion on Tab.
+ new Binding(Scope::field(FieldType::Text), Action::Complete, KeyName::Tab),
+
new Binding(Scope::field(FieldType::Textarea), Action::NewLine, KeyName::Enter),
new Binding(Scope::field(FieldType::Textarea), Action::Accept, KeyName::Tab),
// The textarea hands off to the user's $EDITOR on Ctrl-E.
diff --git a/src/Render/PanelController.php b/src/Render/PanelController.php
index 96a9bf8..3ea3e88 100644
--- a/src/Render/PanelController.php
+++ b/src/Render/PanelController.php
@@ -449,7 +449,7 @@ protected function activateButton(int $index): void {
*/
protected function openEditor(Field $field): void {
$this->editing = $field;
- $this->editor = $this->widgets->create($field, $this->values[$field->id] ?? $field->default);
+ $this->editor = $this->widgets->create($field, $this->values[$field->id] ?? $field->default, $this->values);
}
/**
diff --git a/src/Theme/DefaultTheme.php b/src/Theme/DefaultTheme.php
index cec1c07..ace70fe 100644
--- a/src/Theme/DefaultTheme.php
+++ b/src/Theme/DefaultTheme.php
@@ -475,6 +475,13 @@ public function caret(): string {
return $this->paint($this->isDark ? '1;36' : '1;34', $this->unicode ? '█' : '|');
}
+ /**
+ * {@inheritdoc}
+ */
+ public function ghost(string $text): string {
+ return $this->color ? $this->paint('90', $text) : '';
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/src/Theme/ThemeInterface.php b/src/Theme/ThemeInterface.php
index 7b2e633..97da920 100644
--- a/src/Theme/ThemeInterface.php
+++ b/src/Theme/ThemeInterface.php
@@ -227,6 +227,18 @@ public function check(bool $on): string;
*/
public function caret(): string;
+ /**
+ * Inline ghost-text: a dimmed completion suffix, empty without colour.
+ *
+ * @param string $text
+ * The completion suffix shown after the caret.
+ *
+ * @return string
+ * The dimmed suffix, or an empty string in no-colour mode - without ANSI it
+ * cannot be told apart from typed text, so it is suppressed.
+ */
+ public function ghost(string $text): string;
+
/**
* The masked-character symbol for secret values.
*/
diff --git a/src/Widget/TextWidget.php b/src/Widget/TextWidget.php
index 1e444b9..10be102 100644
--- a/src/Widget/TextWidget.php
+++ b/src/Widget/TextWidget.php
@@ -4,12 +4,14 @@
namespace DrevOps\Tui\Widget;
+use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\Key;
+use DrevOps\Tui\Input\Scope;
use DrevOps\Tui\Theme\ThemeInterface;
/**
- * Single-line text input with a movable cursor.
+ * Single-line text input with a movable cursor and optional ghost-text.
*
* @package DrevOps\Tui\Widget
*/
@@ -29,12 +31,23 @@ class TextWidget extends AbstractWidget {
* Optional validator (see AbstractWidget).
* @param \Closure|null $transform
* Optional transformer (see AbstractWidget).
+ * @param list $completions
+ * Inline ghost-text candidates: the buffer is completed to the first
+ * candidate it is a prefix of. Empty leaves a plain text field.
*/
- public function __construct(protected string $buffer = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL) {
+ public function __construct(protected string $buffer = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL, protected array $completions = []) {
parent::__construct($validate, $transform);
$this->cursor = strlen($this->buffer);
}
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function keyScope(): Scope {
+ return Scope::field(FieldType::Text);
+ }
+
/**
* {@inheritdoc}
*/
@@ -51,6 +64,12 @@ public function handle(Key $key): void {
return;
}
+ if ($keys->matches($key, Action::Complete)) {
+ $this->applyCompletion();
+
+ return;
+ }
+
if ($keys->matches($key, Action::DeleteBack)) {
$this->backspace();
@@ -64,7 +83,14 @@ public function handle(Key $key): void {
}
if ($keys->matches($key, Action::MoveRight)) {
- $this->cursor = min(strlen($this->buffer), $this->cursor + 1);
+ // At the line's end, Right accepts the ghost-text like Tab; elsewhere
+ // it just advances the caret.
+ if ($this->bestMatch() !== NULL) {
+ $this->applyCompletion();
+ }
+ else {
+ $this->cursor = min(strlen($this->buffer), $this->cursor + 1);
+ }
return;
}
@@ -101,6 +127,60 @@ protected function backspace(): void {
}
}
+ /**
+ * The best completion candidate for the current buffer, if any.
+ *
+ * A candidate qualifies only when the caret sits at the end of a non-empty
+ * buffer and the buffer is a case-insensitive prefix of a strictly longer
+ * candidate; the first such candidate in declared order wins. Returns NULL
+ * when nothing completes, so the field behaves as a plain text input.
+ *
+ * @return string|null
+ * The full candidate string, or NULL.
+ */
+ protected function bestMatch(): ?string {
+ if ($this->buffer === '' || $this->cursor !== strlen($this->buffer)) {
+ return NULL;
+ }
+
+ // Fold and measure by character, not byte, so non-ASCII candidates match
+ // case-insensitively and the suffix never splits mid-character.
+ $needle = mb_strtolower($this->buffer);
+ $length = mb_strlen($this->buffer);
+
+ foreach ($this->completions as $completion) {
+ if (mb_strlen($completion) > $length && str_starts_with(mb_strtolower($completion), $needle)) {
+ return $completion;
+ }
+ }
+
+ return NULL;
+ }
+
+ /**
+ * The ghost-text suffix shown after the caret, or an empty string when none.
+ *
+ * @return string
+ * The suffix of the best candidate beyond the typed buffer.
+ */
+ protected function ghostSuffix(): string {
+ $match = $this->bestMatch();
+
+ return $match === NULL ? '' : mb_substr($match, mb_strlen($this->buffer));
+ }
+
+ /**
+ * Fill the buffer with the current completion candidate, when one applies.
+ */
+ protected function applyCompletion(): void {
+ $match = $this->bestMatch();
+
+ if ($match !== NULL) {
+ $this->buffer = $match;
+ $this->cursor = strlen($match);
+ }
+ }
+
/**
* {@inheritdoc}
*/
@@ -118,16 +198,19 @@ public function view(ThemeInterface $theme): string {
}
/**
- * Render the input line with the caret at the cursor position.
+ * Render the input line with the caret and any inline ghost-text.
*
* @param \DrevOps\Tui\Theme\ThemeInterface $theme
- * The theme supplying the caret glyph.
+ * The theme supplying the caret glyph and the ghost styling.
*
* @return string
* The input line.
*/
protected function caretLine(ThemeInterface $theme): string {
- return substr($this->buffer, 0, $this->cursor) . $theme->caret() . substr($this->buffer, $this->cursor);
+ $suffix = $this->ghostSuffix();
+ $ghost = $suffix === '' ? '' : $theme->ghost($suffix);
+
+ return substr($this->buffer, 0, $this->cursor) . $theme->caret() . substr($this->buffer, $this->cursor) . $ghost;
}
}
diff --git a/src/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php
index fb5035c..54e3497 100644
--- a/src/Widget/WidgetFactory.php
+++ b/src/Widget/WidgetFactory.php
@@ -41,11 +41,13 @@ public function __construct(?KeyMap $keymap = NULL, protected bool $externalEdit
* The field.
* @param mixed $current
* The current value to seed the widget with.
+ * @param array $answers
+ * The answers collected so far, passed to a text completion closure.
*
* @return \DrevOps\Tui\Widget\WidgetInterface
* The widget.
*/
- public function create(Field $field, mixed $current): WidgetInterface {
+ public function create(Field $field, mixed $current, array $answers = []): WidgetInterface {
$widget = match ($field->type) {
FieldType::Confirm => new ConfirmWidget((bool) $current),
FieldType::Toggle => new ToggleWidget($this->labels($field), is_string($current) ? $current : ''),
@@ -60,12 +62,40 @@ public function create(Field $field, mixed $current): WidgetInterface {
FieldType::Textarea => new TextareaWidget(is_string($current) ? $current : '', externalEdit: $field->externalEditor && $this->externalEditorAvailable),
FieldType::Password => new PasswordWidget(is_string($current) ? $current : '', revealable: $field->revealable, confirm: $field->confirm),
FieldType::Pause => new PauseWidget(),
- default => new TextWidget(is_string($current) ? $current : ''),
+ default => new TextWidget(is_string($current) ? $current : '', completions: $this->completionsFor($field, $answers)),
};
return $widget->setKeys($this->keymap->forField($field->type));
}
+ /**
+ * Resolve a text field's completion source to a concrete candidate list.
+ *
+ * A closure source is called with the answers collected so far; the result is
+ * coerced to a list of strings, so a mistyped source degrades to no
+ * completion rather than erroring.
+ *
+ * @param \DrevOps\Tui\Config\Field $field
+ * The field.
+ * @param array $answers
+ * The answers collected so far.
+ *
+ * @return list
+ * The candidate strings; empty when the field declares no completion.
+ */
+ protected function completionsFor(Field $field, array $answers): array {
+ $source = $field->completion instanceof \Closure ? ($field->completion)($answers) : $field->completion;
+
+ $out = [];
+ foreach (is_array($source) ? $source : [] as $item) {
+ if (is_string($item)) {
+ $out[] = $item;
+ }
+ }
+
+ return $out;
+ }
+
/**
* The selectable value => label map for a field's options.
*
diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php
index 388802b..3b6e38a 100644
--- a/tests/phpunit/Unit/Builder/FormTest.php
+++ b/tests/phpunit/Unit/Builder/FormTest.php
@@ -222,6 +222,24 @@ public function testValidateAndTransformStored(): void {
$this->assertSame($transformer, $field->transform);
}
+ public function testCompletionSourceStored(): void {
+ $list = ['acme-site', 'acme-app'];
+ $closure = fn (array $answers): array => [];
+
+ $config = Form::create('T')
+ ->panel('p', 'P', function (PanelBuilder $panel) use ($list, $closure): void {
+ $panel->text('name', 'Name')->complete($list);
+ $panel->text('repo', 'Repo')->complete($closure);
+ $panel->text('plain', 'Plain');
+ })
+ ->build();
+
+ $this->assertSame($list, $config->field('name')?->completion);
+ $this->assertSame($closure, $config->field('repo')?->completion);
+ // A field with no completion source defaults to an empty list.
+ $this->assertSame([], $config->field('plain')?->completion);
+ }
+
public function testNumberBoundsAssembled(): void {
$config = Form::create('T')
->panel('p', 'P', function (PanelBuilder $panel): void {
diff --git a/tests/phpunit/Unit/Input/KeyMapTest.php b/tests/phpunit/Unit/Input/KeyMapTest.php
index ff09a23..b66d518 100644
--- a/tests/phpunit/Unit/Input/KeyMapTest.php
+++ b/tests/phpunit/Unit/Input/KeyMapTest.php
@@ -53,6 +53,7 @@ public static function dataProviderDefaultBindings(): \Iterator {
yield 'text enter accepts' => [Scope::field(FieldType::Text), Key::named(KeyName::Enter), Action::Accept, TRUE];
yield 'text space inserts' => [Scope::field(FieldType::Text), Key::named(KeyName::Space), Action::InsertSpace, TRUE];
yield 'text backspace deletes' => [Scope::field(FieldType::Text), Key::named(KeyName::Backspace), Action::DeleteBack, TRUE];
+ yield 'text tab completes' => [Scope::field(FieldType::Text), Key::named(KeyName::Tab), Action::Complete, TRUE];
// Textarea overrides the base.
yield 'textarea enter is newline' => [Scope::field(FieldType::Textarea), Key::named(KeyName::Enter), Action::NewLine, TRUE];
yield 'textarea enter is not accept' => [Scope::field(FieldType::Textarea), Key::named(KeyName::Enter), Action::Accept, FALSE];
diff --git a/tests/phpunit/Unit/Theme/ThemeTest.php b/tests/phpunit/Unit/Theme/ThemeTest.php
index 0872e72..2c96bbc 100644
--- a/tests/phpunit/Unit/Theme/ThemeTest.php
+++ b/tests/phpunit/Unit/Theme/ThemeTest.php
@@ -40,6 +40,15 @@ public static function dataProviderStyler(): \Iterator {
// Option-list roles: a bold-gray heading and a gray disabled option.
yield 'heading' => [static fn(): string => (new DefaultTheme())->heading('X'), '1;90'];
yield 'disabled' => [static fn(): string => (new DefaultTheme())->disabled('X'), '90'];
+ // Inline ghost-text is dimmed gray, the same as the other dimmed chrome.
+ yield 'ghost' => [static fn(): string => (new DefaultTheme())->ghost('X'), '90'];
+ }
+
+ public function testGhostSuppressedWithoutColour(): void {
+ // Ghost-text cannot be dimmed without ANSI, so it is suppressed entirely
+ // rather than rendered as indistinguishable plain text.
+ $this->assertSame('', (new DefaultTheme(76, ['color' => FALSE]))->ghost('X'));
+ $this->assertStringContainsString("\033[90m", (new DefaultTheme())->ghost('X'));
}
public function testDivider(): void {
diff --git a/tests/phpunit/Unit/Widget/TextWidgetTest.php b/tests/phpunit/Unit/Widget/TextWidgetTest.php
index a95d0d5..3b6b907 100644
--- a/tests/phpunit/Unit/Widget/TextWidgetTest.php
+++ b/tests/phpunit/Unit/Widget/TextWidgetTest.php
@@ -95,4 +95,114 @@ public function testDoesNotRenderOwnHint(): void {
$this->assertFalse((new TextWidget())->rendersHint());
}
+ public function testGhostTextRendersDimmedSuffix(): void {
+ // The first candidate is skipped (no prefix match); the second completes.
+ $widget = new TextWidget('', NULL, NULL, ['other', 'acme-site']);
+
+ $widget->handle(Key::char('a'));
+ $widget->handle(Key::char('c'));
+
+ // The typed prefix stays put and the remaining suffix is dimmed (SGR 90).
+ $view = $widget->view(new DefaultTheme());
+ $this->assertStringContainsString('me-site', $view);
+ $this->assertStringContainsString("\033[90m", $view);
+
+ // The ghost is a preview: the value stays the typed text until accepted.
+ $this->assertSame('ac', $widget->value());
+ }
+
+ public function testTabAcceptsCompletion(): void {
+ $widget = new TextWidget('', NULL, NULL, ['acme-site']);
+
+ $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Tab), Key::named(KeyName::Enter)));
+
+ $this->assertSame('acme-site', $value);
+ }
+
+ public function testRightAtEndAcceptsCompletion(): void {
+ $widget = new TextWidget('', NULL, NULL, ['acme-site']);
+
+ $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Right), Key::named(KeyName::Enter)));
+
+ $this->assertSame('acme-site', $value);
+ }
+
+ public function testRightMidBufferMovesCaretWithoutCompleting(): void {
+ $widget = new TextWidget('ab', NULL, NULL, ['abcdef']);
+
+ // With the caret off the end there is no ghost, so Right advances the caret
+ // rather than accepting a completion.
+ $widget->handle(Key::named(KeyName::Left));
+ $widget->handle(Key::named(KeyName::Right));
+
+ $this->assertSame('ab', $widget->value());
+ }
+
+ public function testCaseInsensitiveMatchCanonicalisesOnAccept(): void {
+ $widget = new TextWidget('', NULL, NULL, ['GitHub']);
+
+ $widget->handle(Key::char('g'));
+ $widget->handle(Key::char('i'));
+ $widget->handle(Key::named(KeyName::Tab));
+
+ // A lower-case prefix matches and accepting adopts the candidate's case.
+ $this->assertSame('GitHub', $widget->value());
+ }
+
+ public function testGhostTextIsUnicodeAware(): void {
+ // strtolower() folds only ASCII, so a non-ASCII prefix must fold with
+ // mbstring; the multibyte suffix must render whole, not split mid-byte.
+ $widget = new TextWidget('', NULL, NULL, ['Éclair']);
+
+ $widget->handle(Key::char('é'));
+ $this->assertStringContainsString('clair', $widget->view(new DefaultTheme()));
+
+ $widget->handle(Key::named(KeyName::Tab));
+ $this->assertSame('Éclair', $widget->value());
+ }
+
+ public function testNoMatchLeavesPlainField(): void {
+ $widget = new TextWidget('', NULL, NULL, ['acme-site']);
+
+ $widget->handle(Key::char('z'));
+
+ // No candidate starts with "z": no dimmed ghost, and Tab is inert.
+ $view = $widget->view(new DefaultTheme());
+ $this->assertStringNotContainsString("\033[90m", $view);
+
+ $widget->handle(Key::named(KeyName::Tab));
+ $this->assertSame('z', $widget->value());
+ }
+
+ public function testFullyTypedCandidateHasNoGhost(): void {
+ $widget = new TextWidget('', NULL, NULL, ['php']);
+
+ $widget->handle(Key::char('p'));
+ $widget->handle(Key::char('h'));
+ $widget->handle(Key::char('p'));
+
+ // The buffer already equals the only candidate; nothing is left to ghost.
+ $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme()));
+ }
+
+ public function testEmptyBufferShowsNoGhost(): void {
+ // With nothing typed there is no prefix to complete, so no ghost renders.
+ $widget = new TextWidget('', NULL, NULL, ['acme-site']);
+
+ $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme()));
+ }
+
+ public function testGhostSuppressedInNoAnsiMode(): void {
+ $widget = new TextWidget('', NULL, NULL, ['acme-site']);
+
+ $widget->handle(Key::char('a'));
+ $widget->handle(Key::char('c'));
+
+ // Without colour the ghost cannot be dimmed, so it is suppressed and no
+ // escape sequences leak into the plain-text line.
+ $view = $widget->view(new DefaultTheme(76, ['color' => FALSE]));
+ $this->assertStringNotContainsString('me-site', $view);
+ $this->assertStringNotContainsString("\033", $view);
+ }
+
}
diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
index 5a992a0..2a282af 100644
--- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
+++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
@@ -170,6 +170,41 @@ public function testSuggestReceivesSelectableValuesOnly(): void {
$this->assertStringNotContainsString('gmt', $view);
}
+ public function testTextCompletionStaticListReachesWidget(): void {
+ $field = new Field('name', 'Name', '', FieldType::Text, '', completion: ['acme-site']);
+
+ $view = (new WidgetFactory())->create($field, 'ac')->view(new DefaultTheme());
+
+ // The matching candidate's remaining suffix shows as dimmed ghost-text.
+ $this->assertStringContainsString('me-site', $view);
+ }
+
+ public function testTextCompletionClosureReceivesAnswers(): void {
+ $seen = [];
+ $field = new Field('repo', 'Repo', '', FieldType::Text, '', completion: function (array $answers) use (&$seen): array {
+ $seen = $answers;
+
+ return ['acme-site'];
+ });
+
+ $view = (new WidgetFactory())->create($field, 'ac', ['owner' => 'acme'])->view(new DefaultTheme());
+
+ // The closure is handed the answers collected so far and its result reaches
+ // the widget as ghost-text.
+ $this->assertSame(['owner' => 'acme'], $seen);
+ $this->assertStringContainsString('me-site', $view);
+ }
+
+ public function testTextCompletionCoercesInvalidResult(): void {
+ // A mistyped source degrades to no completion rather than erroring: a list
+ // with non-strings is filtered, and a non-list result is ignored.
+ $items = new Field('a', 'A', '', FieldType::Text, '', completion: fn (array $answers): array => [123, NULL]);
+ $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($items, 'ac')->view(new DefaultTheme()));
+
+ $scalar = new Field('b', 'B', '', FieldType::Text, '', completion: fn (array $answers): string => 'oops');
+ $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($scalar, 'ac')->view(new DefaultTheme()));
+ }
+
/**
* A field of the given type.
*