Skip to content

Dev#15

Merged
hakrichTech merged 16 commits into
mainfrom
dev
Jun 17, 2026
Merged

Dev#15
hakrichTech merged 16 commits into
mainfrom
dev

Conversation

@hakrichTech

@hakrichTech hakrichTech commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Description

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring / internal improvement
  • Test / CI improvement

Related Issues

Checklist

  • My code follows the project's coding style
  • I have added / updated tests that cover my changes
  • All existing tests still pass (composer test)
  • PHPStan passes at level 8 (composer phpstan)
  • I have updated the documentation / README if needed
  • The CHANGELOG.md entry is written (if user-facing)

Testing

# Example test steps
composer install
composer test

Screenshots / Terminal Output

Summary by CodeRabbit

  • New Features

    • RadioGroup component: interactive multi-choice terminal selections supporting arrow-key and numeric-shortcut navigation with visual feedback.
  • Refactor

    • AbstractCommand constructors are now overridable by subclasses.
    • Terminal input handling abstraction introduced for improved flexibility.
  • Tests

    • Comprehensive test suite for key input handling and RadioGroup component behavior.

Alshatri and others added 14 commits May 3, 2026 18:32
…ode clarity

- Updated write, writeError, writeRaw, and writeErrorRaw methods in ConsoleIO to use mixed type for messages.
- Enhanced readability by adding new lines and consistent formatting in ConsoleIO, Colors, Fuzzy, Input, Key, RenderContext, Renderer, Shell, ShellResult, Spinner, SpinnerFrames, State, Terminal, Hooks, IOInterface, IPromptComponent, IRenderer, and NullIO.
- Introduced a new filtered method in State for case-insensitive substring matching.
- Added .editorconfig and .gitattributes for consistent coding standards and line endings.
- Created phpunit.xml.dist and rector.php for testing and code quality tools.
- Updated tests for consistency and clarity in various test files.
…Spinner, and their components

- Introduced BufferIOUserInputsTest to validate user input handling in commands.
- Added ShellTest to ensure shell command execution and output capture works correctly.
- Created AlertTest to verify alert rendering for success, error, warning, and info messages.
- Implemented RendererTest to check rendering logic and output for various states.
- Developed SpinnerFramesTest and SpinnerTest to validate spinner frame functionality and behavior.
…update CoversClass attributes

- Updated test classes (ColorsTest, FuzzyTest, HooksTest, InputTest, KeyTest, NullIOTest, RenderContextTest, RendererTest, ShellResultTest, SpinnerFramesTest, SpinnerTest, StateTest, TableTest) to use static function syntax for closures in various test methods.
- Changed CoversClass attributes to use the class name directly instead of the fully qualified name.
- Cleaned up formatting for better consistency across test files.
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@hakrichTech, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 50 minutes and 37 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b19a4457-2f09-4f03-b09e-8899eae6ab58

📥 Commits

Reviewing files that changed from the base of the PR and between dedfb10 and e7980df.

📒 Files selected for processing (3)
  • composer.json
  • src/Internal/MbTrim.php
  • src/mb_polyfill.php
📝 Walkthrough

Walkthrough

Introduces KeyReaderInterface with TerminalKeyReader and ArrayKeyReader implementations, then rewires AbstractPrompt to route all key I/O through an injected reader instead of calling Terminal directly. Adds a new RadioGroup prompt component with single/multi-column rendering and digit shortcuts. Removes final from AbstractCommand::__construct() and updates the TODO roadmap.

Changes

KeyReader Abstraction

Layer / File(s) Summary
KeyReaderInterface contract and implementations
src/KeyReaderInterface.php, src/Depends/TerminalKeyReader.php, src/Depends/ArrayKeyReader.php
KeyReaderInterface defines readKey(), setUp(), and tearDown(). TerminalKeyReader delegates to Terminal static methods. ArrayKeyReader replays a predetermined key queue, exposing exhausted() and reset() for test use.
AbstractPrompt wired to KeyReaderInterface
src/AbstractPrompt.php
Adds a private $keyReader field initialized to TerminalKeyReader, fluent withKeyReader()/getKeyReader() methods, and replaces direct Terminal raw-mode and readKey() calls with $this->keyReader lifecycle calls in run(). The loop also terminates on an empty-string return.
KeyReader tests, AbstractCommand unfinal, TODO update
tests/Unit/KeyReaderTest.php, src/AbstractCommand.php, TODO.md
KeyReaderTest covers interface conformance, ArrayKeyReader replay/exhaustion/reset, injection API chaining, and end-to-end run() integration for Confirm, Select, and RadioGroup via ArrayKeyReader. AbstractCommand::__construct() loses the final modifier. The TODO roadmap marks the abstraction done.

RadioGroup Component

Layer / File(s) Summary
RadioGroup component implementation
src/Components/RadioGroup.php
New RadioGroup extends Component with default()/columns() fluent setters, setup() binding UP/DOWN/LEFT/RIGHT/digit/ENTER keys, render() overwriting terminal lines with radio indicators (pre- and post-confirmation), resolve() returning the selected value, and single/multi-column layout helpers with aligned cell padding.
RadioGroup tests
tests/Unit/RadioGroupTest.php
RadioGroupTest validates fluent API return values, default selection behavior and fallbacks, wraparound navigation, digit shortcuts, rendered output content (question, choices, glyphs, hints, done-state collapse, multi-column, ANSI-stripped output), and single-choice edge cases using reflection-based helpers that simulate keypresses without a TTY.

Sequence Diagram(s)

sequenceDiagram
  participant Test as Test / Caller
  participant AbstractPrompt
  participant KeyReaderInterface
  participant Component as Prompt Component

  Test->>AbstractPrompt: withKeyReader(ArrayKeyReader)
  Test->>AbstractPrompt: run()
  AbstractPrompt->>KeyReaderInterface: setUp()
  AbstractPrompt->>Component: setup()
  loop engine loop
    AbstractPrompt->>KeyReaderInterface: readKey()
    KeyReaderInterface-->>AbstractPrompt: key string (or '' when exhausted)
    alt key is ''
      AbstractPrompt->>AbstractPrompt: break loop
    else key is valid
      AbstractPrompt->>Component: dispatch(key)
      Component->>Component: update state
    end
    AbstractPrompt->>Component: render()
  end
  AbstractPrompt->>KeyReaderInterface: tearDown() [finally]
  AbstractPrompt->>Component: resolve()
  Component-->>AbstractPrompt: selected value
  AbstractPrompt-->>Test: resolved value
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 A rabbit hops through keys one by one,
No more raw terminal—abstraction's begun!
The reader is injected, the interface clean,
RadioGroup glowing with options to glean.
✨ Digit shortcuts hop left and right with glee,
The tests all pass—hooray, it's TTY-free! 🎉

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The pull request title 'Dev' is vague and generic, providing no meaningful information about the changeset despite substantial refactoring, new components, and architecture improvements. Use a more descriptive title that captures the main purpose, such as 'Decouple Terminal key reading via KeyReaderInterface' or 'Add RadioGroup component and refactor key input handling'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/AbstractPrompt.php`:
- Around line 72-75: The run() method in AbstractPrompt calls setUp() outside
the try block and tearDown() in the finally block after destroy()/dispatch(),
which means if setUp() or destroy()/dispatch() throws an exception, tearDown()
might not execute properly or at all. Restructure the run() method to ensure
tearDown() is always called as the final cleanup action regardless of where an
exception occurs, by moving setUp() inside the try block or wrapping the entire
operation with a try-finally that guarantees tearDown() executes after all other
operations (including destroy/dispatch).

In `@src/Components/RadioGroup.php`:
- Around line 40-43: The __construct method in the RadioGroup class does not
validate that the $choices array is non-empty, which causes runtime failures in
the UP/DOWN handlers that perform modulo operations and in range functions that
expect valid array sizes. Add validation in the constructor to ensure the
$choices array is not empty, throwing an appropriate exception if an empty array
is provided during construction.
- Around line 125-155: The render method in the RadioGroup class moves the
cursor up by the previous line count but only clears the lines as it writes new
content. When transitioning from a full options view to the done/collapsed view,
if the new frame has fewer lines than the previous one, leftover rows remain
visible on screen. After moving the cursor up by $this->lastLines, clear all
those previous rows before rendering the new content. Ensure that all
$this->lastLines rows are cleared regardless of how many lines the new frame
will contain, not just the lines being written.

In `@src/KeyReaderInterface.php`:
- Around line 26-29: The docblock comment in the KeyReaderInterface states that
returning an empty string is treated as a no-op key, but the actual
implementation in AbstractPrompt::run() treats an empty string as a stop signal
that exits the loop. Update the documentation comment in the KeyReaderInterface
to clarify that an empty string signals end-of-input and will terminate the
prompt loop, not act as a no-op. Also review and update any related
implementations such as ArrayKeyReader to ensure they align with this corrected
contract and any related documentation reflects the same behavior.

In `@tests/Unit/RadioGroupTest.php`:
- Around line 298-313: The test_render_no_ansi_when_colors_disabled() method
uses capture() which automatically strips ANSI codes, making it impossible to
detect if render() is still emitting color sequences when colors are disabled.
Instead of using capture(), call render() directly to get the raw output without
ANSI stripping, then assert that the output contains no SGR (Select Graphic
Rendition) color codes while keeping the existing assertions for the option text
content (Pick, small, medium, large, x-large).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 604a24f6-4535-4ca3-8f54-3ccf3e13af9a

📥 Commits

Reviewing files that changed from the base of the PR and between 0ec825a and dedfb10.

📒 Files selected for processing (9)
  • TODO.md
  • src/AbstractCommand.php
  • src/AbstractPrompt.php
  • src/Components/RadioGroup.php
  • src/Depends/ArrayKeyReader.php
  • src/Depends/TerminalKeyReader.php
  • src/KeyReaderInterface.php
  • tests/Unit/KeyReaderTest.php
  • tests/Unit/RadioGroupTest.php

Comment thread src/AbstractPrompt.php
Comment on lines 72 to 75
public function run(): mixed
{
Terminal::enableRaw();
$this->keyReader->setUp();
$this->running = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Guarantee tearDown() execution on all failure paths.

setUp() is outside the try, and tearDown() runs after destroy()/dispatch() in the same finally. If setup or destroy throws, terminal cleanup can be skipped.

Suggested restructuring
 public function run(): mixed
 {
-    $this->keyReader->setUp();
-    $this->running = true;
-
     try {
+        $this->keyReader->setUp();
+        $this->running = true;
+
         $this->mount();
         $this->dispatch('mount');
@@
-    } catch (\Exception $e) {
+    } catch (\Exception $e) {
         $this->handleError($e);
         throw $e;
     } finally {
-        $this->destroy();
-        $this->dispatch('destroy');
-        $this->keyReader->tearDown();
+        try {
+            $this->destroy();
+            $this->dispatch('destroy');
+        } finally {
+            $this->keyReader->tearDown();
+        }
     }
 }

Also applies to: 120-124

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/AbstractPrompt.php` around lines 72 - 75, The run() method in
AbstractPrompt calls setUp() outside the try block and tearDown() in the finally
block after destroy()/dispatch(), which means if setUp() or destroy()/dispatch()
throws an exception, tearDown() might not execute properly or at all.
Restructure the run() method to ensure tearDown() is always called as the final
cleanup action regardless of where an exception occurs, by moving setUp() inside
the try block or wrapping the entire operation with a try-finally that
guarantees tearDown() executes after all other operations (including
destroy/dispatch).

Comment on lines +40 to +43
public function __construct(
private string $question,
private array $choices,
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against empty choice lists to avoid runtime crashes.

With [] choices, the UP/DOWN handlers do modulo by 0, which can throw at runtime; range(1, min(9, 0)) also produces unintended bindings. Enforce a non-empty invariant at construction.

💡 Suggested fix
 public function __construct(
     private string $question,
     private array $choices,
 ) {
+    if ($choices === []) {
+        throw new \InvalidArgumentException('RadioGroup requires at least one choice.');
+    }
+
+    $this->choices = array_values($choices);
     parent::__construct();
 }

Also applies to: 92-107

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Components/RadioGroup.php` around lines 40 - 43, The __construct method
in the RadioGroup class does not validate that the $choices array is non-empty,
which causes runtime failures in the UP/DOWN handlers that perform modulo
operations and in range functions that expect valid array sizes. Add validation
in the constructor to ensure the $choices array is not empty, throwing an
appropriate exception if an empty array is provided during construction.

Comment on lines +125 to +155
if ($this->lastLines > 0) {
Terminal::moveCursorUp($this->lastLines);
}

Terminal::hideCursor();

$index = (int) $this->state->index;
$done = (bool) $this->state->done;
$lines = [];

// ── Line 1: question ──────────────────────────────────
$mark = $done
? Colors::success('')
: Colors::wrap('? ', Colors::CYAN);
$lines[] = $mark . Colors::wrap($this->question, Colors::BOLD);

if (!$done) {
$lines = array_merge($lines, $this->renderOptions($index));
$lines[] = Colors::muted(' ↑↓ move • 1-9 jump • ENTER confirm');
} else {
$selected = $this->choices[$index] ?? '';
$lines[] = Colors::wrap(' › ', Colors::GRAY) . Colors::wrap($selected, Colors::GREEN);
}

foreach ($lines as $line) {
Terminal::clearLine();
echo $line . PHP_EOL;
}

$this->lastLines = count($lines);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear leftover rows when the rendered frame gets shorter.

When transitioning from the full options list to the done/collapsed view, old rows can remain visible because only the new line count is cleared/redrawn.

💡 Suggested fix
-        foreach ($lines as $line) {
-            Terminal::clearLine();
-            echo $line . PHP_EOL;
-        }
-
-        $this->lastLines = count($lines);
+        $newLineCount = count($lines);
+        $rowsToRedraw = max($this->lastLines, $newLineCount);
+
+        for ($i = 0; $i < $rowsToRedraw; $i++) {
+            Terminal::clearLine();
+            echo ($lines[$i] ?? '') . PHP_EOL;
+        }
+
+        $this->lastLines = $newLineCount;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Components/RadioGroup.php` around lines 125 - 155, The render method in
the RadioGroup class moves the cursor up by the previous line count but only
clears the lines as it writes new content. When transitioning from a full
options view to the done/collapsed view, if the new frame has fewer lines than
the previous one, leftover rows remain visible on screen. After moving the
cursor up by $this->lastLines, clear all those previous rows before rendering
the new content. Ensure that all $this->lastLines rows are cleared regardless of
how many lines the new frame will contain, not just the lines being written.

Comment on lines +26 to +29
* The reader MUST block until a key is available, just as a real TTY does,
* so the event loop in AbstractPrompt::run() can yield correctly.
* Returning an empty string is treated as a no-op key (no bindings fire).
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Empty-string semantics in the public contract conflict with runtime behavior.

The interface says '' is a no-op, but AbstractPrompt::run() currently treats '' as end-of-input and exits the loop. Custom readers implemented against this contract can terminate prompts unexpectedly.

Please align the contract and implementation to one behavior (recommended: document '' as stop signal and update related docs, including ArrayKeyReader).

Proposed contract wording
- * Returning an empty string is treated as a no-op key (no bindings fire).
+ * Returning an empty string signals end-of-input and causes the prompt loop to stop.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* The reader MUST block until a key is available, just as a real TTY does,
* so the event loop in AbstractPrompt::run() can yield correctly.
* Returning an empty string is treated as a no-op key (no bindings fire).
*/
* The reader MUST block until a key is available, just as a real TTY does,
* so the event loop in AbstractPrompt::run() can yield correctly.
* Returning an empty string signals end-of-input and causes the prompt loop to stop.
*/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/KeyReaderInterface.php` around lines 26 - 29, The docblock comment in the
KeyReaderInterface states that returning an empty string is treated as a no-op
key, but the actual implementation in AbstractPrompt::run() treats an empty
string as a stop signal that exits the loop. Update the documentation comment in
the KeyReaderInterface to clarify that an empty string signals end-of-input and
will terminate the prompt loop, not act as a no-op. Also review and update any
related implementations such as ArrayKeyReader to ensure they align with this
corrected contract and any related documentation reflects the same behavior.

Comment on lines +298 to +313
public function test_render_no_ansi_when_colors_disabled(): void
{
Colors::disable();
$rg = new RadioGroup('Pick', self::CHOICES);
$this->mount($rg);

$output = $this->capture(fn() => $rg->render());

$this->assertStringContainsString('Pick', $output);
$this->assertStringContainsString('small', $output);
$this->assertStringContainsString('medium', $output);
$this->assertStringContainsString('large', $output);
$this->assertStringContainsString('x-large', $output);


}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

test_render_no_ansi_when_colors_disabled() currently can’t detect ANSI regressions.

capture() always strips ANSI, so this test will pass even if render still emits color sequences. Capture raw output for this case and assert no SGR color codes.

💡 Suggested fix
-        $output = $this->capture(fn() => $rg->render());
+        $output = $this->capture(fn() => $rg->render(), stripAnsi: false);

         $this->assertStringContainsString('Pick', $output);
         $this->assertStringContainsString('small', $output);
         $this->assertStringContainsString('medium', $output);
         $this->assertStringContainsString('large', $output);
         $this->assertStringContainsString('x-large', $output);
+        $this->assertDoesNotMatchRegularExpression('/\e\[[0-9;]*m/', $output);
@@
-    private function capture(callable $fn): string
+    private function capture(callable $fn, bool $stripAnsi = true): string
     {
         ob_start();
         $fn();
-
-        return Colors::strip((string) ob_get_clean());
+        $output = (string) ob_get_clean();
+
+        return $stripAnsi ? Colors::strip($output) : $output;
     }

Also applies to: 377-383

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Unit/RadioGroupTest.php` around lines 298 - 313, The
test_render_no_ansi_when_colors_disabled() method uses capture() which
automatically strips ANSI codes, making it impossible to detect if render() is
still emitting color sequences when colors are disabled. Instead of using
capture(), call render() directly to get the raw output without ANSI stripping,
then assert that the output contains no SGR (Select Graphic Rendition) color
codes while keeping the existing assertions for the option text content (Pick,
small, medium, large, x-large).

@hakrichTech hakrichTech merged commit 64541e0 into main Jun 17, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants