Hacking Laravel Prompts for Fun and Profit

Laravel Prompts, the first-party library for creating beautiful CLI apps using PHP, is a huge leap forward in the terminal experience. Can we use it to create full interactive TUIs? Yes. We can.

Joe Tannenbaum

This is part of a series of posts I'm writing on building TUIs using Laravel Prompts.

Prompts is an phenomenal first-party library from Laravel written by the mad genius Jess Archer. It elevates the PHP command line experience by offering a beautiful display, inline validation, and user-friendly interactions. The best part is, it doesn't even require Laravel! You can integrate it into any PHP CLI app.

Digging into the Prompts code made me realize how intelligently Jess structured everything, the rendering logic she implemented was really smart and reusable, abstracting away a lot of the more complicated bits.

It got me thinking: Can we create full interactive text-based user interfaces (TUIs) by latching onto the Prompts rendering process?

Good news: We sure can. Let's dive in.

Prompts Program Flow

You will always have two classes you need to create for each program:

  • A model class that tracks the state of your app and listens for keypresses, this extends Laravel\Prompts\Prompt

  • A renderer class that renders your application based on the current state, this extends Laravel\Prompts\Themes\Default\Renderer

An important concept to understand here is that your renderer class will be spun up fresh for every render and re-render the entire screen as the user should see it based on the model state, every time.

When the Prompts render method is called from your model, it runs some internal logic that determines how many lines are different from the previous render and how many need to be replaced in order to render your new screen, so you don't need to worry about that.

To simplify: Just create the screen in its entirety based on the current state of the app in your renderer class. Hold that concept in your mind as we move forward.

Editor's Note: I think I just set a record for the word "render" written per sentence. Onwards.

Ok, Let's Build Something

Yes, let's! If you want to follow along and don't already have Prompts installed, I would throw a composer require laravel/prompts in the terminal before we continue.

We're going to be building a simple Kanban board, here's the end result:

If you want to see the full source code right away:

Setting Up Our Basic Classes

First things first, we need our model class that extends the Prompt class. This will keep track of our state as we interact with our app:

1<?php
2 
3namespace App;
4 
5use Laravel\Prompts\Prompt;
6 
7class Kanban extends Prompt
8{
9 public function value(): mixed
10 {
11 // Must implement a `value` method when extending Prompts,
12 // although it's not actually needed in our case
13 return true;
14 }
15}

And also our renderer class, which extends the Prompts Renderer class. It's an invokable class that receives an instance of our Kanban class:

1<?php
2 
3namespace App\Themes\Default;
4 
5use App\Kanban;
6use Laravel\Prompts\Themes\Default\Renderer;
7 
8class KanbanRenderer extends Renderer
9{
10 public function __invoke(Kanban $kanban): string
11 {
12 return $this;
13 }
14}

Great, we're on our way. Let's tell Prompts how to render our TUI by registering our KanbanRenderer in the constructor:

1<?php
2 
3namespace App;
4 
5use App\Themes\Default\KanbanRenderer;
6use Laravel\Prompts\Prompt;
7 
8class Kanban extends Prompt
9{
10 public function __construct()
11 {
12 static::$themes['default'][Kanban::class] = KanbanRenderer::class;
13 }
14 
15 public function value(): mixed
16 {
17 return true;
18 }
19}

We also need a little script so that we can run our app, let's create that now:

1<?php
2 
3use App\Kanban;
4 
5require __DIR__ . '/../vendor/autoload.php';
6 
7(new Kanban)->prompt();

The prompt method is part of the Prompts library, it loosely does the following:

  • Hides the terminal cursor

  • Hides output typed into the terminal

  • Sets up an infinite while loop to listen for keypresses

  • Runs the render method every time it receives and processes a keypress

If you run the script now, it will just hang and it won't do anything, which is correct. You can press ctrl + c to exit the script.

Adding Data

Next, let's add some data. We could pull this from a database or API, but to keep things simple we'll just track it in an array. Let's define our columns and load it up with some initial data:

1<?php
2 
3namespace App;
4 
5use App\Themes\Default\KanbanRenderer;
6use Laravel\Prompts\Prompt;
7 
8class Kanban extends Prompt
9{
10 public array $columns = [
11 [
12 'title' => 'To Do',
13 'items' => [
14 [
15 'title' => 'Make Kanban Board',
16 'description' => 'But in the terminal?',
17 ],
18 [
19 'title' => 'Eat Pizza',
20 'description' => '(Whole pie).',
21 ],
22 ],
23 ],
24 [
25 'title' => 'In Progress',
26 'items' => [
27 [
28 'title' => 'Get Milk',
29 'description' => 'From the store (whole).',
30 ],
31 [
32 'title' => 'Learn Go',
33 'description' => 'Charm CLI looks dope.',
34 ],
35 [
36 'title' => 'Submit Statamic PR',
37 'description' => 'Nocache tag fix.',
38 ],
39 ],
40 ],
41 [
42 'title' => 'Done',
43 'items' => [
44 [
45 'title' => 'Wait Patiently',
46 'description' => 'For the next prompt.',
47 ],
48 ],
49 ],
50 ];
51 ...
52 public function __construct()
53 {
54 static::$themes['default'][Kanban::class] = KanbanRenderer::class;
55 }
56 
57 public function value(): mixed
58 {
59 // Must implement a `value` method when extending Prompts,
60 // although it's not actually needed in our case
61 return true;
62 }
63}

Excellent. We now have some data, so let's render our basic Kanban board.

We want our board to have three tall boxes with titles and, within each, smaller boxes for each item that represents a card with a title and description for each one.

Some great news here: Prompts already has a super easy way to draw boxes. Literally has a trait for it, DrawsBoxes. So we're going to hack the box method from DrawsBoxes and use it to our advantage.

Understanding the Renderer

Let's back up for just one moment.

The way the Prompts Renderer class works, simplified, is that it has an output property which is simply a string. It has a slew of traits and helper methods available to you that keep concatenating strings onto the output, eventually producing the final string.

It then has a __toString method that handles a bit of logic regarding spacing and new lines surrounding the given output and returns the final string.

So what we're going to do is continually use those helper methods, capture their string output, and reset the output property back to an empty string. This will make more sense momentarily.

Rendering the Basic Board

Back to our KanbanRenderer class. Remember, we're simply building up a huge string that represents our app's UI. Let's see what rendering our basic board looks like:

1<?php
2 
3namespace App\Themes\Default;
4 
5use App\Kanban;
6use Laravel\Prompts\Themes\Default\Renderer;
7 
8class KanbanRenderer extends Renderer
9{
10 public function __invoke(Kanban $kanban): string
11 {
12 // Available width of terminal minus some buffer
13 $totalWidth = $kanban->terminal()->cols() - 16;
14 // Available height of terminal minus some buffer
15 $totalHeight = $kanban->terminal()->lines() - 10;
16 
17 // Column width should be the total width divided by the number of columns
18 $columnWidth = (int) floor($totalWidth / count($kanban->columns));
19 // Card width is the column width minus some padding
20 $cardWidth = $columnWidth - 6;
21 
22 // Loop through our columns and render each one
23 $columns = collect($kanban->columns)->map(function ($column, $columnIndex) use (
24 $cardWidth,
25 $columnWidth,
26 $kanban,
27 $totalHeight,
28 ) {
29 // Loop through each card in the column and render it
30 $cardsOutput = collect($column['items'])->map(
31 // Render the card with a dimmed border
32 fn ($card) => $this->getBoxOutput(
33 $card['title'],
34 PHP_EOL . $card['description'] . PHP_EOL,
35 'dim',
36 $cardWidth,
37 ),
38 );
39 
40 $cardContent = PHP_EOL . $cardsOutput->implode(PHP_EOL);
41 
42 // Add new lines to the card content to make it the same height as the terminal
43 $cardContent .= str_repeat(PHP_EOL, $totalHeight - count(explode(PHP_EOL, $cardContent)) + 1);
44 
45 $columnTitle = $kanban->columns[$columnIndex]['title'];
46 
47 // Render the column with a dimmed border and the card content
48 $columnContent = $this->getBoxOutput(
49 $this->dim($columnTitle),
50 $cardContent,
51 'dim',
52 $columnWidth,
53 );
54 
55 return explode(PHP_EOL, $columnContent);
56 });
57 
58 // Zip the columns together so we can render them side by side
59 collect($columns->shift())
60 ->zip(...$columns)
61 ->map(fn ($lines) => $lines->implode(''))
62 // Render the lines
63 ->each(fn ($line) => $this->line($line));
64 
65 return $this;
66 }
67 
68 protected function getBoxOutput(string $title, string $body, string $color, int $width): string
69 {
70 // Reset the output string
71 $this->output = '';
72 
73 // Set the minWidth to the desired width, the box method
74 // uses this to calculate how wide the box should be
75 $this->minWidth = $width;
76 
77 $this->box(
78 title: $title,
79 body: $body,
80 color: $color,
81 );
82 
83 $content = $this->output;
84 
85 $this->output = '';
86 
87 return $content;
88 }
89}

The above code produces this:

Our basic Kanban board
Our basic Kanban board

Our Secret Weapon: The Zip Method

One of the most important things to note here is the zip method. We're going to use that a lot when rendering TUIs. Let's take a closer look at line 59:

1$lines = collect($columns->shift())
2 ->zip(...$columns)
3 ->map(fn ($lines) => $lines->implode(''));

Straight from the Laravel docs: "The zip method merges together the values of the given array with the values of the original collection at their corresponding index."

By line 59, what we have is a collection of three columns represented as arrays of lines.

We snag the first item in the collection, then zip the remaining columns together with it. We then have one large collection with sub collections representing the corresponding lines from each column, which we can then safely implode on an empty string, creating our final and complete line of our UI.

Visually represented, it looks something like this:

Adding Interaction

At this point, let's think about how we want the user to interact with our app:

  • When the user loads up the app, the first column and the first card should be active by default

  • When the user press the right or left arrows, it should change the active column

  • When the user presses the up or down arrows, it should change the active card

  • When the user presses enter, it should move the active card to the next column

That means we need to track an active column index and an active item index. Let's add that to our Kanban class:

1<?php
2 
3namespace App;
4 
5use App\Themes\Default\KanbanRenderer;
6use Laravel\Prompts\Prompt;
7 
8class Kanban extends Prompt
9{ ...
10 public array $columns = [
11 [
12 'title' => 'To Do',
13 'items' => [
14 [
15 'title' => 'Make Kanban Board',
16 'description' => 'But in the terminal?',
17 ],
18 [
19 'title' => 'Eat Pizza',
20 'description' => '(Whole pie).',
21 ],
22 ],
23 ],
24 [
25 'title' => 'In Progress',
26 'items' => [
27 [
28 'title' => 'Get Milk',
29 'description' => 'From the store (whole).',
30 ],
31 [
32 'title' => 'Learn Go',
33 'description' => 'Charm CLI looks dope.',
34 ],
35 [
36 'title' => 'Submit Statamic PR',
37 'description' => 'Nocache tag fix.',
38 ],
39 ],
40 ],
41 [
42 'title' => 'Done',
43 'items' => [
44 [
45 'title' => 'Wait Patiently',
46 'description' => 'For the next prompt.',
47 ],
48 ],
49 ],
50 ];
51 
52 public int $itemIndex = 0;
53 
54 public int $columnIndex = 0;
55 
56 public function __construct()
57 {
58 static::$themes['default'][Kanban::class] = KanbanRenderer::class;
59 }
60 
61 public function value(): mixed
62 {
63 return true;
64 }
65}

These properties need to be public so that our renderer can access them.

Let's register some keys to listen to. The following chunk is largely taken directly from the Prompt codebase, let's add it to our Kanban class:

1<?php
2 
3namespace App;
4 
5use App\Themes\Default\KanbanRenderer;
6use Laravel\Prompts\Key;
7use Laravel\Prompts\Prompt;
8 
9class Kanban extends Prompt
10{ ...
11 public array $columns = [
12 [
13 'title' => 'To Do',
14 'items' => [
15 [
16 'title' => 'Make Kanban Board',
17 'description' => 'But in the terminal?',
18 ],
19 [
20 'title' => 'Eat Pizza',
21 'description' => '(Whole pie).',
22 ],
23 ],
24 ],
25 [
26 'title' => 'In Progress',
27 'items' => [
28 [
29 'title' => 'Get Milk',
30 'description' => 'From the store (whole).',
31 ],
32 [
33 'title' => 'Learn Go',
34 'description' => 'Charm CLI looks dope.',
35 ],
36 [
37 'title' => 'Submit Statamic PR',
38 'description' => 'Nocache tag fix.',
39 ],
40 ],
41 ],
42 [
43 'title' => 'Done',
44 'items' => [
45 [
46 'title' => 'Wait Patiently',
47 'description' => 'For the next prompt.',
48 ],
49 ],
50 ],
51 ];
52 
53 public int $itemIndex = 0;
54 
55 public int $columnIndex = 0;
56 
57 public function __construct()
58 {
59 static::$themes['default'][Kanban::class] = KanbanRenderer::class;
60 
61 $this->listenForKeys();
62 }
63 
64 public function value(): mixed
65 {
66 return true;
67 }
68 
69 protected function listenForKeys(): void
70 {
71 $this->on('key', function ($key) {
72 if ($key[0] === "\e") {
73 match ($key) {
74 Key::UP, Key::UP_ARROW => $this->itemIndex = max(0, $this->itemIndex - 1),
75 Key::DOWN, Key::DOWN_ARROW => $this->itemIndex = min(count($this->columns[$this->columnIndex]['items']) - 1, $this->itemIndex + 1),
76 Key::RIGHT, Key::RIGHT_ARROW => $this->nextColumn(),
77 Key::LEFT, Key::LEFT_ARROW => $this->previousColumn(),
78 default => null,
79 };
80 
81 return;
82 }
83 
84 // Keys may be buffered
85 foreach (mb_str_split($key) as $key) {
86 if ($key === Key::ENTER) {
87 $this->moveCurrentItem();
88 return;
89 }
90 }
91 });
92 }
93}

And now let's fill in a few of those methods:

1<?php
2 
3namespace App;
4 
5use App\Themes\Default\KanbanRenderer;
6use Laravel\Prompts\Key;
7use Laravel\Prompts\Prompt;
8 
9class Kanban extends Prompt
10{ ...
11 public array $columns = [
12 [
13 'title' => 'To Do',
14 'items' => [
15 [
16 'title' => 'Make Kanban Board',
17 'description' => 'But in the terminal?',
18 ],
19 [
20 'title' => 'Eat Pizza',
21 'description' => '(Whole pie).',
22 ],
23 ],
24 ],
25 [
26 'title' => 'In Progress',
27 'items' => [
28 [
29 'title' => 'Get Milk',
30 'description' => 'From the store (whole).',
31 ],
32 [
33 'title' => 'Learn Go',
34 'description' => 'Charm CLI looks dope.',
35 ],
36 [
37 'title' => 'Submit Statamic PR',
38 'description' => 'Nocache tag fix.',
39 ],
40 ],
41 ],
42 [
43 'title' => 'Done',
44 'items' => [
45 [
46 'title' => 'Wait Patiently',
47 'description' => 'For the next prompt.',
48 ],
49 ],
50 ],
51 ];
52 
53 public int $itemIndex = 0;
54 
55 public int $columnIndex = 0;
56 
57 public function __construct()
58 {
59 static::$themes['default'][Kanban::class] = KanbanRenderer::class;
60 
61 $this->listenForKeys();
62 }
63 
64 public function value(): mixed
65 {
66 return true;
67 }
68 
69 protected function listenForKeys(): void
70 {
71 $this->on('key', function ($key) {
72 if ($key[0] === "\e") {
73 match ($key) {
74 Key::UP, Key::UP_ARROW => $this->itemIndex = max(0, $this->itemIndex - 1),
75 Key::DOWN, Key::DOWN_ARROW => $this->itemIndex = min(count($this->columns[$this->columnIndex]['items']) - 1, $this->itemIndex + 1),
76 Key::RIGHT, Key::RIGHT_ARROW => $this->nextColumn(),
77 Key::LEFT, Key::LEFT_ARROW => $this->previousColumn(),
78 default => null,
79 };
80 
81 return;
82 }
83 
84 // Keys may be buffered
85 foreach (mb_str_split($key) as $key) {
86 if ($key === Key::ENTER) {
87 $this->moveCurrentItem();
88 return;
89 }
90 }
91 });
92 }
93 
94 protected function nextColumn(): void
95 {
96 $this->columnIndex = min(count($this->columns) - 1, $this->columnIndex + 1);
97 // Reset the item index when moving to a new column
98 $this->itemIndex = 0;
99 }
100 
101 protected function previousColumn(): void
102 {
103 $this->columnIndex = max(0, $this->columnIndex - 1);
104 // Reset the item index when moving to a new column
105 $this->itemIndex = 0;
106 }
107 
108 protected function moveCurrentItem(): void
109 {
110 $newColumnIndex = $this->columnIndex + 1;
111 
112 if ($newColumnIndex >= count($this->columns)) {
113 $newColumnIndex = 0;
114 }
115 
116 // Add the item to the new column
117 $this->columns[$newColumnIndex]['items'][] = $this->columns[$this->columnIndex]['items'][$this->itemIndex];
118 
119 // Remove it from the old column
120 unset($this->columns[$this->columnIndex]['items'][$this->itemIndex]);
121 
122 // Reindex the items
123 $this->columns[$this->columnIndex]['items'] = array_values($this->columns[$this->columnIndex]['items']);
124 
125 // Reset the item index to the previous item in the column
126 $this->itemIndex = max(0, $this->itemIndex - 1);
127 }
128}

We now have active cards and columns, we should probably visually represent them for the user. Lezzdoit.

Back in our KanbanRenderer:

1<?php
2 
3namespace Chewie\Themes\Default;
4 
5use Chewie\Kanban;
6use Laravel\Prompts\Themes\Default\Concerns\DrawsBoxes;
7use Laravel\Prompts\Themes\Default\Renderer;
8 
9class KanbanRenderer extends Renderer
10{
11 use DrawsBoxes;
12 
13 public function __invoke(Kanban $kanban): string
14 { ...
15 $totalWidth = $kanban->terminal()->cols() - 16;
16 $totalHeight = $kanban->terminal()->lines() - 7;
17 
18 $columnWidth = (int) floor($totalWidth / count($kanban->columns));
19 $cardWidth = $columnWidth - 6;
20 
21 $columns = collect($kanban->columns)->map(function ($column, $columnIndex) use (
22 $cardWidth,
23 $columnWidth,
24 $kanban,
25 $totalHeight,
26 ) {
27 $cardsOutput = collect($column['items'])->map(
28 fn ($card, $cardIndex) => $this->getBoxOutput(
29 $card['title'],
30 PHP_EOL . $card['description'] . PHP_EOL,
31 $cardIndex === $kanban->itemIndex && $kanban->columnIndex === $columnIndex ? 'green' : 'dim',
32 $cardWidth,
33 ),
34 );
35 
36 $cardContent = PHP_EOL . $cardsOutput->implode(PHP_EOL);
37 
38 $cardContent .= str_repeat(PHP_EOL, $totalHeight - count(explode(PHP_EOL, $cardContent)) + 1);
39 
40 $columnTitle = $kanban->columns[$columnIndex]['title'];
41 
42 $columnContent = $this->getBoxOutput(
43 $kanban->columnIndex === $columnIndex ? $this->cyan($columnTitle) : $this->dim($columnTitle),
44 $cardContent,
45 $kanban->columnIndex === $columnIndex ? 'cyan' : 'dim',
46 $columnWidth,
47 );
48 
49 return explode(PHP_EOL, $columnContent);
50 });
51 
52 collect($columns->shift())
53 ->zip(...$columns)
54 ->map(fn ($lines) => $lines->implode(''))
55 ->each(fn ($line) => $this->line($line));
56 
57 return $this;
58 }
59 ...
60 protected function getBoxOutput(string $title, string $body, string $color, int $width): string
61 {
62 $this->output = '';
63 
64 $this->minWidth = $width;
65 
66 $this->box(
67 title: $title,
68 body: $body,
69 color: $color,
70 );
71 
72 $content = $this->output;
73 
74 $this->output = '';
75 
76 return $content;
77 }
78}

Let's see what that looks like:

Adding New Items to the Board

We should let the user add new items. We're going to keep it simple and just make a "separate screen" for that using the built-in Prompts components.

Essentially, this will wipe the screen, ask the user for the title and description of the new item, then re-render the board when they are done:

1<?php
2 
3namespace App;
4 
5use App\Themes\Default\KanbanRenderer;
6use Laravel\Prompts\Key;
7use Laravel\Prompts\Prompt;
8 
9use function Laravel\Prompts\text;
10 
11class Kanban extends Prompt
12{ ...
13 public array $columns = [
14 [
15 'title' => 'To Do',
16 'items' => [
17 [
18 'title' => 'Make Kanban Board',
19 'description' => 'But in the terminal?',
20 ],
21 [
22 'title' => 'Eat Pizza',
23 'description' => '(Whole pie).',
24 ],
25 ],
26 ],
27 [
28 'title' => 'In Progress',
29 'items' => [
30 [
31 'title' => 'Get Milk',
32 'description' => 'From the store (whole).',
33 ],
34 [
35 'title' => 'Learn Go',
36 'description' => 'Charm CLI looks dope.',
37 ],
38 [
39 'title' => 'Submit Statamic PR',
40 'description' => 'Nocache tag fix.',
41 ],
42 ],
43 ],
44 [
45 'title' => 'Done',
46 'items' => [
47 [
48 'title' => 'Wait Patiently',
49 'description' => 'For the next prompt.',
50 ],
51 ],
52 ],
53 ];
54 
55 public int $itemIndex = 0;
56 
57 public int $columnIndex = 0;
58 
59 public function __construct()
60 {
61 static::$themes['default'][Kanban::class] = KanbanRenderer::class;
62 
63 $this->listenForKeys();
64 }
65 
66 public function value(): bool
67 {
68 return true;
69 }
70 
71 protected function listenForKeys(): void
72 {
73 $this->on('key', function ($key) {
74 if ($key[0] === "\e") {
75 match ($key) {
76 Key::UP, Key::UP_ARROW => $this->itemIndex = max(0, $this->itemIndex - 1),
77 Key::DOWN, Key::DOWN_ARROW => $this->itemIndex = min(count($this->columns[$this->columnIndex]['items']) - 1, $this->itemIndex + 1),
78 Key::RIGHT, Key::RIGHT_ARROW => $this->nextColumn(),
79 Key::LEFT, Key::LEFT_ARROW => $this->previousColumn(),
80 default => null,
81 };
82 
83 return;
84 }
85 
86 // Keys may be buffered.
87 foreach (mb_str_split($key) as $key) {
88 if ($key === Key::ENTER) {
89 $this->moveCurrentItem();
90 
91 return;
92 }
93 
94 match ($key) {
95 'n' => $this->addNewItem(),
96 default => null,
97 };
98 }
99 });
100 }
101 ...
102 protected function nextColumn(): void
103 {
104 $this->columnIndex = min(count($this->columns) - 1, $this->columnIndex + 1);
105 $this->itemIndex = 0;
106 }
107 
108 protected function previousColumn(): void
109 {
110 $this->columnIndex = max(0, $this->columnIndex - 1);
111 $this->itemIndex = 0;
112 }
113 
114 protected function moveCurrentItem(): void
115 {
116 $newColumnIndex = $this->columnIndex + 1;
117 
118 if ($newColumnIndex >= count($this->columns)) {
119 $newColumnIndex = 0;
120 }
121 
122 $this->columns[$newColumnIndex]['items'][] = $this->columns[$this->columnIndex]['items'][$this->itemIndex];
123 
124 unset($this->columns[$this->columnIndex]['items'][$this->itemIndex]);
125 
126 $this->columns[$this->columnIndex]['items'] = array_values($this->columns[$this->columnIndex]['items']);
127 
128 $this->itemIndex = max(0, $this->itemIndex - 1);
129 }
130 
131 protected function addNewItem(): void
132 {
133 // Clear our listeners so we don't capture the keypresses registered for the board
134 $this->clearListeners();
135 // Capture previous new lines rendered
136 $this->capturePreviousNewLines();
137 // Reset the cursor position back to the top
138 $this->resetCursorPosition();
139 // Erase everything that's currently on the screen
140 $this->eraseDown();
141 
142 $title = text('Title', 'Title of task');
143 
144 $description = text('Description', 'Description of task');
145 
146 // Add the new item to the current column
147 $this->columns[$this->columnIndex]['items'][] = [
148 'title' => $title,
149 'description' => $description,
150 ];
151 
152 // Re-register our key listeners
153 $this->listenForKeys();
154 // Re-render the board
155 $this->prompt();
156 }
157 
158 protected function resetCursorPosition(): void
159 {
160 $lines = count(explode(PHP_EOL, $this->prevFrame)) - 1;
161 
162 $this->moveCursor(-999, $lines * -1);
163 }
164}

Allowing the User to Quit the App

Almost there! Promise.

We'll also need to register a way for the user to quit the app when they are done, let's do that when they hit q:

1<?php
2 
3namespace App;
4 
5use App\Themes\Default\KanbanRenderer;
6use Laravel\Prompts\Key;
7use Laravel\Prompts\Prompt;
8 
9class Kanban extends Prompt
10{ ...
11 public array $columns = [
12 [
13 'title' => 'To Do',
14 'items' => [
15 [
16 'title' => 'Make Kanban Board',
17 'description' => 'But in the terminal?',
18 ],
19 [
20 'title' => 'Eat Pizza',
21 'description' => '(Whole pie).',
22 ],
23 ],
24 ],
25 [
26 'title' => 'In Progress',
27 'items' => [
28 [
29 'title' => 'Get Milk',
30 'description' => 'From the store (whole).',
31 ],
32 [
33 'title' => 'Learn Go',
34 'description' => 'Charm CLI looks dope.',
35 ],
36 [
37 'title' => 'Submit Statamic PR',
38 'description' => 'Nocache tag fix.',
39 ],
40 ],
41 ],
42 [
43 'title' => 'Done',
44 'items' => [
45 [
46 'title' => 'Wait Patiently',
47 'description' => 'For the next prompt.',
48 ],
49 ],
50 ],
51 ];
52 
53 public int $itemIndex = 0;
54 
55 public int $columnIndex = 0;
56 
57 public function __construct()
58 {
59 static::$themes['default'][Kanban::class] = KanbanRenderer::class;
60 
61 $this->listenForKeys();
62 }
63 
64 public function value(): mixed
65 {
66 return true;
67 }
68 
69 protected function listenForKeys(): void
70 {
71 $this->on('key', function ($key) {
72 if ($key[0] === "\e") {
73 match ($key) {
74 Key::UP, Key::UP_ARROW => $this->itemIndex = max(0, $this->itemIndex - 1),
75 Key::DOWN, Key::DOWN_ARROW => $this->itemIndex = min(count($this->columns[$this->columnIndex]['items']) - 1, $this->itemIndex + 1),
76 Key::RIGHT, Key::RIGHT_ARROW => $this->nextColumn(),
77 Key::LEFT, Key::LEFT_ARROW => $this->previousColumn(),
78 default => null,
79 };
80 
81 return;
82 }
83 
84 // Keys may be buffered
85 foreach (mb_str_split($key) as $key) {
86 if ($key === Key::ENTER) {
87 $this->moveCurrentItem();
88 return;
89 }
90 
91 match ($key) {
92 'n' => $this->addNewItem(),
93 'q' => $this->quit(),
94 default => null,
95 };
96 }
97 });
98 }
99 ...
100 protected function nextColumn(): void
101 {
102 $this->columnIndex = min(count($this->columns) - 1, $this->columnIndex + 1);
103 // Reset the item index when moving to a new column
104 $this->itemIndex = 0;
105 }
106 
107 protected function previousColumn(): void
108 {
109 $this->columnIndex = max(0, $this->columnIndex - 1);
110 // Reset the item index when moving to a new column
111 $this->itemIndex = 0;
112 }
113 
114 protected function moveCurrentItem(): void
115 {
116 $newColumnIndex = $this->columnIndex + 1;
117 
118 if ($newColumnIndex >= count($this->columns)) {
119 $newColumnIndex = 0;
120 }
121 
122 // Add the item to the new column
123 $this->columns[$newColumnIndex]['items'][] = $this->columns[$this->columnIndex]['items'][$this->itemIndex];
124 
125 // Remove it from the old column
126 unset($this->columns[$this->columnIndex]['items'][$this->itemIndex]);
127 
128 // Reindex the items
129 $this->columns[$this->columnIndex]['items'] = array_values($this->columns[$this->columnIndex]['items']);
130 
131 // Reset the item index to the previous item in the column
132 $this->itemIndex = max(0, $this->itemIndex - 1);
133 }
134 
135 protected function addNewItem(): void
136 {
137 // Clear our listeners so we don't capture the keypresses registered for the board
138 $this->clearListeners();
139 // Capture previous new lines rendered
140 $this->capturePreviousNewLines();
141 // Reset the cursor position back to the top
142 $this->resetCursorPosition();
143 // Erase everything that's currently on the screen
144 $this->eraseDown();
145 
146 $title = text('Title', 'Title of task');
147 
148 $description = text('Description', 'Description of task');
149 
150 // Add the new item to the current column
151 $this->columns[$this->columnIndex]['items'][] = [
152 'title' => $title,
153 'description' => $description,
154 ];
155 
156 // Re-register our key listeners
157 $this->listenForKeys();
158 // Re-render the board
159 $this->prompt();
160 }
161 
162 protected function quit(): void
163 {
164 static::terminal()->exit();
165 }
166}

Folks, We Did It

Give yourself a pat on the back. You just built an interactive TUI by taking advantage of the framework that Laravel Prompts has so graciously laid out for us. Look at this beauty:

I hope this sparked your imagination! Let's build rad stuff for the terminal!

Seriously, hit me up! Show me what you're building. Experiment! Have fun! And if you're going to dive in and haven't build many TUIs before, check out some tips that I've gathered.

And stay tuned for more posts on several other experiments I did, coming soon.

Full source code for the Kanban demo:

(Excellent) syntax highlighting provided by Torchlight