From ab27ce58ed9611e51256af67ab238602947e811f Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sat, 17 Oct 2026 01:20:04 +0545 Subject: [PATCH] feat: add interactive php-tui terminal log and Git DAG Provide a pure-PHP terminal interface for browsing everyone's and following feeds, inspecting threads, composing posts and replies, and viewing developer profiles with Unix ranks. Fall back to non-interactive ASCII rendering for pipelines and headless terminals. Use php-tui with optional ext-terminal acceleration, typed service boundaries, accessibility-minded keyboard navigation and tests. Rebase onto current production and consolidate feature history into one reviewable commit without dropping upstream changes. --- app/Console/Commands/LogCommand.php | 141 ++ app/Enums/TuiTab.php | 27 + app/Services/Terminal/InteractiveFeed.php | 40 + app/Services/Terminal/TerminalRenderer.php | 293 ++++ app/Services/Terminal/TuiApplication.php | 1184 +++++++++++++++++ composer.json | 4 + composer.lock | 157 ++- tests/Feature/Commands/LogCommandTest.php | 204 +++ tests/Pest.php | 6 + tests/Unit/Enums/TuiTabTest.php | 25 + tests/Unit/Queries/CommitSearchQueryTest.php | 4 + .../Services/Terminal/InteractiveFeedTest.php | 23 + .../Terminal/TerminalRendererTest.php | 162 +++ .../Services/Terminal/TuiApplicationTest.php | 681 ++++++++++ 14 files changed, 2949 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/LogCommand.php create mode 100644 app/Enums/TuiTab.php create mode 100644 app/Services/Terminal/InteractiveFeed.php create mode 100644 app/Services/Terminal/TerminalRenderer.php create mode 100644 app/Services/Terminal/TuiApplication.php create mode 100644 tests/Feature/Commands/LogCommandTest.php create mode 100644 tests/Unit/Enums/TuiTabTest.php create mode 100644 tests/Unit/Services/Terminal/InteractiveFeedTest.php create mode 100644 tests/Unit/Services/Terminal/TerminalRendererTest.php create mode 100644 tests/Unit/Services/Terminal/TuiApplicationTest.php diff --git a/app/Console/Commands/LogCommand.php b/app/Console/Commands/LogCommand.php new file mode 100644 index 0000000000..292635dfd2 --- /dev/null +++ b/app/Console/Commands/LogCommand.php @@ -0,0 +1,141 @@ +option('post'); + + if (is_string($postMessage) && $postMessage !== '') { + return $this->handleDirectPost($postMessage); + } + + $limit = max(1, min(100, (int) $this->option('limit'))); + $username = $this->argument('user'); + + $user = null; + $title = 'everyone'; + + if (is_string($username) && $username !== '') { + $cleanUsername = mb_ltrim($username, '@'); + $user = User::query()->where('username', $cleanUsername)->first(); + + if ($user === null) { + $this->components->error("User @{$cleanUsername} not found."); + + return self::FAILURE; + } + + $title = "@{$user->username}"; + $commits = $user->commits() + ->approved() + ->topLevel() + ->withPublicPreview() + ->latest() + ->limit($limit) + ->get(); + } else { + $commits = Commit::query() + ->approved() + ->topLevel() + ->withPublicPreview() + ->latest() + ->limit($limit) + ->get(); + } + + if ($commits->isEmpty()) { + $this->components->info('No commits found in the log yet.'); + + return self::SUCCESS; + } + + // Identify acting developer... + $asUsername = $this->option('as'); + $activeUser = null; + + if (is_string($asUsername) && $asUsername !== '') { + $activeUser = User::query()->where('username', mb_ltrim($asUsername, '@'))->first(); + } + + $isTty = (bool) $this->option('tui') + || (function_exists('stream_isatty') && stream_isatty(STDIN) && stream_isatty(STDOUT)); + $disableTui = (bool) $this->option('no-tui'); + + if (! $disableTui && $isTty) { + if (InteractiveFeed::run($commits, $title, $activeUser, interactive: ! app()->runningUnitTests())) { + return self::SUCCESS; + } + } + + $color = $renderer->supportsColor(); + + $dag = $user !== null + ? $renderer->profile($user, $commits, color: $color) + : $renderer->log($commits, title: $title, color: $color); + + foreach (explode("\n", mb_rtrim($dag)) as $line) { + $this->line($line); + } + + return self::SUCCESS; + } + + /** + * Handles non-interactive direct commit posting from CLI flags. + */ + private function handleDirectPost(string $message): int + { + $asUsername = $this->option('as') ?? $this->argument('user'); + + $user = (is_string($asUsername) && $asUsername !== '') + ? User::query()->where('username', mb_ltrim($asUsername, '@'))->first() + : User::query()->first(); + + if ($user === null) { + $this->components->error('Cannot commit: no user found. Provide --as=@username.'); + + return self::FAILURE; + } + + $replyTarget = $this->option('reply'); + $parent = null; + + if (is_string($replyTarget) && $replyTarget !== '') { + $parent = resolve(CommitByReferenceQuery::class)->get($user, $replyTarget)?->first(); + + if ($parent === null) { + $this->components->error("Reply target commit {$replyTarget} not found."); + + return self::FAILURE; + } + } + + $created = resolve(CreateCommit::class)->handle($user, $message, $parent); + $hash = Str::substr($created->id, 0, 7); + $status = $created->status === CommitStatus::Approved ? 'Approved' : 'Pending review'; + + $this->components->info("Committed: [{$hash}] by @{$user->username} ({$status})"); + + return self::SUCCESS; + } +} diff --git a/app/Enums/TuiTab.php b/app/Enums/TuiTab.php new file mode 100644 index 0000000000..2c08f04bb0 --- /dev/null +++ b/app/Enums/TuiTab.php @@ -0,0 +1,27 @@ + '[1] Everyone', + self::Following => '[2] Following', + self::Thread => '[3] Thread', + self::Compose => '[4] New Post', + self::Profile => '[5] Profile & Ranks', + self::Help => '[?] Help', + }; + } +} diff --git a/app/Services/Terminal/InteractiveFeed.php b/app/Services/Terminal/InteractiveFeed.php new file mode 100644 index 0000000000..2ad28eb11f --- /dev/null +++ b/app/Services/Terminal/InteractiveFeed.php @@ -0,0 +1,40 @@ + $commits + */ + public static function run( + Collection $commits, + string $title = 'everyone', + ?User $user = null, + ?Display $display = null, + ?PhpTermTerminal $terminal = null, + bool $interactive = true + ): bool { + if ($commits->isEmpty()) { + return false; + } + + $app = new TuiApplication( + feedCommits: $commits, + currentUser: $user, + title: $title, + ); + + return $app->run($display, $terminal, interactive: $interactive); + } +} diff --git a/app/Services/Terminal/TerminalRenderer.php b/app/Services/Terminal/TerminalRenderer.php new file mode 100644 index 0000000000..6b32e025d6 --- /dev/null +++ b/app/Services/Terminal/TerminalRenderer.php @@ -0,0 +1,293 @@ + + */ + public const array LANES = [ + 0 => [217, 72, 15], // Orange (#d9480f) + 1 => [47, 158, 68], // Green (#2f9e44) + 2 => [156, 54, 181], // Purple (#9c36b5) + 3 => [12, 133, 153], // Cyan (#0c8599) + 4 => [194, 37, 92], // Pink (#c2255c) + 5 => [183, 121, 31], // Yellow (#b7791f) + ]; + + /** + * Standard 16-color ANSI fallbacks for terminal clients without TrueColor support. + * + * @var array + */ + public const array LANES_16 = [ + 0 => "\033[33m", // Yellow / Orange + 1 => "\033[32m", // Green + 2 => "\033[35m", // Magenta + 3 => "\033[36m", // Cyan + 4 => "\033[31m", // Red / Pink + 5 => "\033[33m", // Yellow + ]; + + /** + * Sanitizes user-provided text for terminal output by stripping ANSI escapes and C0 controls. + */ + public static function sanitize(string $text): string + { + // Strip ANSI escape sequences: \033[ ... m or other CSI sequences + $stripped = (string) preg_replace('/\033\[[0-9;]*[a-zA-Z]/', '', $text); + + // Strip C0 control characters except \n, \r, \t + return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $stripped); + } + + /** + * Renders the ASCII art banner. + */ + public function banner(bool $color = true): string + { + $bold = $color ? "\033[1m" : ''; + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + + return implode("\n", [ + " {$bold} _ _ _ _ {$reset}", + " {$bold}__| | _____ __ _ __ | | | ___ ___| |__ {$reset}", + " {$bold}/ _` |/ _ \ \ / / | '_ \ | | | / __/ __| '_ \ {$reset}", + " {$bold}| (_| | __/\ V / | | | || | |_\__ \__ \ | | | {$reset}", + " {$bold}\__,_|\___| \_/ |_| |_| \__(_)___/___/_| |_| {$reset}", + '', + " {$dim}A social network for developers. Pipe your thoughts to /dev/null.{$reset}", + '', + ]); + } + + /** + * Renders a timeline log (e.g. following or everyone) as an ASCII git DAG. + * + * @param iterable $commits + */ + public function log(iterable $commits, string $title = 'everyone', bool $color = true, bool $force16 = false): string + { + $dim = $color ? "\033[90m" : ''; + $bold = $color ? "\033[1m" : ''; + $reset = $color ? "\033[0m" : ''; + + $output = [ + $this->banner($color), + " {$bold}Log:{$reset} {$title}", + " {$dim}".str_repeat('─', 60)."{$reset}", + '', + ]; + + $commitsList = is_array($commits) ? $commits : iterator_to_array($commits); + $total = count($commitsList); + + if ($total === 0) { + $output[] = " {$dim}Nobody has committed anything yet. Be the first.{$reset}"; + $output[] = ''; + } else { + foreach ($commitsList as $index => $commit) { + $isLast = $index === $total - 1; + $output[] = $this->commitCard($commit, $color, $force16, ! $isLast); + } + } + + $output[] = $this->footer($color); + + return implode("\n", $output)."\n"; + } + + /** + * Renders a user's terminal profile card and recent commits. + * + * @param iterable $commits + */ + public function profile(User $user, iterable $commits, bool $color = true, bool $force16 = false): string + { + $lane = $this->laneColor($user->lane(), $color, $force16); + $bold = $color ? "\033[1m" : ''; + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + + $name = $user->name ? ' ('.self::sanitize($user->name).')' : ''; + $username = self::sanitize($user->username); + $bio = $user->bio ? "\n {$dim}".self::sanitize($user->bio)."{$reset}" : ''; + + $user->loadPublicCounts(); + + $stats = "{$user->commits_count} commits · {$user->followers_count} followers"; + + $header = [ + $this->banner($color), + " {$lane}@{$username}{$reset}{$bold}{$name}{$reset}", + " {$dim}{$stats}{$reset}{$bio}", + " {$dim}".str_repeat('─', 60)."{$reset}", + '', + ]; + + $commitsList = is_array($commits) ? $commits : iterator_to_array($commits); + $total = count($commitsList); + + if ($total === 0) { + $header[] = " {$dim}@{$username} has not committed anything yet.{$reset}"; + $header[] = ''; + } else { + foreach ($commitsList as $index => $commit) { + $isLast = $index === $total - 1; + $header[] = $this->commitCard($commit, $color, $force16, ! $isLast); + } + } + + $header[] = $this->footer($color); + + return implode("\n", $header)."\n"; + } + + /** + * Renders a single commit with its ancestors and replies thread. + * + * @param iterable $ancestors + * @param iterable $replies + */ + public function thread(Commit $commit, iterable $ancestors, iterable $replies, bool $color = true, bool $force16 = false): string + { + $output = [$this->banner($color)]; + + $ancestorList = is_array($ancestors) ? $ancestors : iterator_to_array($ancestors); + $repliesList = is_array($replies) ? $replies : iterator_to_array($replies); + + // Ancestors... + foreach ($ancestorList as $ancestor) { + $output[] = $this->commitCard($ancestor, $color, $force16, true); + } + + // Focused commit... + $output[] = $this->commitCard($commit, $color, $force16, count($repliesList) > 0, isFocused: true); + + // Replies... + $totalReplies = count($repliesList); + foreach ($repliesList as $index => $reply) { + $isLast = $index === $totalReplies - 1; + $output[] = $this->commitCard($reply, $color, $force16, ! $isLast, isReply: true); + } + + $output[] = $this->footer($color); + + return implode("\n", $output)."\n"; + } + + /** + * Renders an individual commit node with ASCII rail. + */ + public function commitCard(Commit $commit, bool $color = true, bool $force16 = false, bool $hasFollowing = true, bool $isFocused = false, bool $isReply = false): string + { + $lane = $this->laneColor($commit->user?->lane() ?? 0, $color, $force16); + $bold = $color ? "\033[1m" : ''; + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + + $hash = Str::substr($commit->id, 0, 7); + $time = $commit->created_at->diffForHumans(short: true); + $stars = $commit->stars_count > 0 ? " · {$commit->stars_count} ★" : ''; + $replies = $commit->replies_count > 0 ? " · {$commit->replies_count} ⤿" : ''; + + $railStem = $hasFollowing ? "{$dim}│{$reset}" : ' '; + $nodeSymbol = $isReply ? '├─' : '*'; + + if ($isFocused) { + $hashText = "{$bold}{$hash}{$reset} {$dim}(HEAD){$reset}"; + } else { + $hashText = "{$bold}{$hash}{$reset}"; + } + + $username = self::sanitize($commit->user->username ?? 'ghost'); + $headerLine = " {$lane}{$nodeSymbol}{$reset} {$hashText} {$lane}@{$username}{$reset} {$dim}· {$time}{$stars}{$replies}{$reset}"; + + $body = self::sanitize($commit->body); + $bodyLines = explode("\n", wordwrap($body, 70, "\n", true)); + $renderedBody = []; + + foreach ($bodyLines as $line) { + $renderedBody[] = " {$railStem} {$line}"; + } + + if ($hasFollowing) { + $renderedBody[] = " {$railStem}"; + } else { + $renderedBody[] = ''; + } + + return $headerLine."\n".implode("\n", $renderedBody); + } + + /** + * Renders the terminal quick-command footer. + */ + public function footer(bool $color = true): string + { + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + $baseUrl = url('/'); + + return implode("\n", [ + " {$dim}".str_repeat('─', 60)."{$reset}", + " {$dim}Commit:{$reset} curl -fsSL {$baseUrl}/commit | bash -s -- 'Your message'", + " {$dim}Log: {$reset} php artisan log", + ]); + } + + /** + * Resolves the ANSI escape sequence for a user lane (24-bit TrueColor or 16-color ANSI). + */ + public function laneColor(int|string $lane, bool $color, bool $force16 = false): string + { + if (! $color) { + return ''; + } + + if (is_string($lane) && str_starts_with($lane, '#') && mb_strlen($lane) === 7) { + if ($force16) { + return self::LANES_16[0]; + } + + $r = (int) hexdec(mb_substr($lane, 1, 2)); + $g = (int) hexdec(mb_substr($lane, 3, 2)); + $b = (int) hexdec(mb_substr($lane, 5, 2)); + + return sprintf("\033[38;2;%d;%d;%dm", $r, $g, $b); + } + + $laneIndex = is_numeric($lane) ? (int) $lane : 0; + + if ($force16) { + return self::LANES_16[$laneIndex] ?? self::LANES_16[0]; + } + + $rgb = self::LANES[$laneIndex] ?? self::LANES[0]; + + return sprintf("\033[38;2;%d;%d;%dm", $rgb[0], $rgb[1], $rgb[2]); + } + + /** + * Determines whether colors should be enabled based on standard environment conventions. + */ + public function supportsColor(): bool + { + if (isset($_SERVER['NO_COLOR']) && Request::server('NO_COLOR') !== '') { + return false; + } + + return true; + } +} diff --git a/app/Services/Terminal/TuiApplication.php b/app/Services/Terminal/TuiApplication.php new file mode 100644 index 0000000000..a3accf4db3 --- /dev/null +++ b/app/Services/Terminal/TuiApplication.php @@ -0,0 +1,1184 @@ +|null + */ + public ?Collection $followingCommits = null; + + public ?Commit $selectedCommit; + + public ?Commit $threadCommit = null; + + /** + * @var Collection + */ + public Collection $threadAncestors; + + /** + * @var Collection + */ + public Collection $threadReplies; + + public int $threadIndex = 0; + + public string $composeText = ''; + + public ?Commit $composeReplyTo = null; + + public ?string $statusMessage = null; + + public bool $running = true; + + /** + * @param Collection $feedCommits + */ + public function __construct( + public Collection $feedCommits, + public ?User $currentUser = null, + public ?User $targetProfileUser = null, + public string $title = 'everyone', + ) { + $this->selectedCommit = $this->feedCommits->first(); + $this->threadAncestors = new Collection(); + $this->threadReplies = new Collection(); + } + + /** + * Starts the interactive TUI application loop. + */ + public function run(?Display $display = null, ?PhpTermTerminal $terminal = null, bool $interactive = true): bool + { + try { + $terminal ??= $this->buildTerminal($interactive); + $display ??= $this->buildDisplay($terminal, $interactive); + + if (! $interactive) { + $display->clear(); + $this->render($display); + + return true; + } + + $terminal->enableRawMode(); + $display->clear(); + $this->render($display); + + while ($this->running) { + $hasInput = false; + + while (($event = $terminal->events()->next()) instanceof Event) { + $this->handleEvent($event); + $hasInput = true; + } + + if ($hasInput) { + $this->render($display); + } + + Sleep::usleep(15_000); + } + + $terminal->disableRawMode(); + + return true; + } catch (Throwable) { + return false; + } + } + + /** + * Dispatches keyboard input events. + */ + public function handleEvent(Event $event): void + { + if ($this->activeTab === TuiTab::Compose) { + $this->handleComposeEvent($event); + + return; + } + + if ($this->activeTab === TuiTab::Help) { + if ($event instanceof CodedKeyEvent && ($event->code === KeyCode::Esc || $event->code === KeyCode::Enter)) { + $this->activeTab = TuiTab::Everyone; + } elseif ($event instanceof CharKeyEvent) { + match ($event->char) { + '1' => $this->switchTab(TuiTab::Everyone), + '2' => $this->switchTab(TuiTab::Following), + '3' => $this->switchTab(TuiTab::Thread), + '4' => $this->switchTab(TuiTab::Compose), + '5' => $this->switchTab(TuiTab::Profile), + '?', 'q', "\x1b" => $this->activeTab = TuiTab::Everyone, + default => null, + }; + } + + return; + } + + if ($this->activeTab === TuiTab::Profile) { + if ($event instanceof CodedKeyEvent && $event->code === KeyCode::Esc) { + $this->activeTab = TuiTab::Everyone; + + return; + } + + if ($event instanceof CharKeyEvent) { + if (in_array($event->char, ['q', "\x1b"], true)) { + $this->activeTab = TuiTab::Everyone; + + return; + } + + if ($event->char === '1') { + $this->switchTab(TuiTab::Everyone); + + return; + } + + if ($event->char === '2') { + $this->switchTab(TuiTab::Following); + + return; + } + + if ($event->char === '4') { + $this->switchTab(TuiTab::Compose); + + return; + } + } + } + + if ($this->activeTab === TuiTab::Thread) { + $this->handleThreadEvent($event); + + return; + } + + // Global feed navigation... + if ($event instanceof CodedKeyEvent) { + match ($event->code) { + KeyCode::Up => $this->moveSelection(-1), + KeyCode::Down => $this->moveSelection(1), + KeyCode::Home => $this->jumpSelection(0), + KeyCode::End => $this->jumpSelection(PHP_INT_MAX), + KeyCode::Enter => $this->openThread(), + KeyCode::Tab => $this->cycleTab(1), + KeyCode::BackTab => $this->cycleTab(-1), + KeyCode::Esc => $this->running = false, + default => null, + }; + + return; + } + + if ($event instanceof CharKeyEvent) { + match ($event->char) { + 'k' => $this->moveSelection(-1), + 'j' => $this->moveSelection(1), + 'g' => $this->jumpSelection(0), + 'G' => $this->jumpSelection(PHP_INT_MAX), + '1' => $this->switchTab(TuiTab::Everyone), + '2' => $this->switchTab(TuiTab::Following), + '3' => $this->switchTab(TuiTab::Thread), + '4' => $this->switchTab(TuiTab::Compose), + '5' => $this->switchTab(TuiTab::Profile), + '?' => $this->switchTab(TuiTab::Help), + 'p' => $this->openComposer(), + 'r' => $this->openComposer($this->currentSelectedCommit()), + 's' => $this->toggleStar($this->currentSelectedCommit()), + 'u' => $this->openAuthorProfile($this->currentSelectedCommit()), + 'q' => $this->running = false, + default => null, + }; + } + } + + /** + * Moves current list selection. + */ + public function moveSelection(int $delta): void + { + $commits = $this->activeFeedCommits(); + $total = $commits->count(); + + if ($total === 0) { + $this->selectedIndex = 0; + $this->selectedCommit = null; + + return; + } + + $this->selectedIndex = max(0, min($total - 1, $this->selectedIndex + $delta)); + $this->selectedCommit = $commits->get($this->selectedIndex); + } + + /** + * Jumps selection to the given index. + */ + public function jumpSelection(int $index): void + { + $commits = $this->activeFeedCommits(); + $total = $commits->count(); + + if ($total === 0) { + $this->selectedIndex = 0; + $this->selectedCommit = null; + + return; + } + + $this->selectedIndex = max(0, min($total - 1, $index)); + $this->selectedCommit = $commits->get($this->selectedIndex); + } + + /** + * Returns the active feed's commits. + * + * @return Collection + */ + public function activeFeedCommits(): Collection + { + if ($this->activeTab === TuiTab::Following && $this->followingCommits instanceof Collection) { + return $this->followingCommits; + } + + return $this->feedCommits; + } + + /** + * Returns the currently selected commit in the active feed. + */ + public function currentSelectedCommit(): ?Commit + { + return $this->selectedCommit ?? $this->activeFeedCommits()->first(); + } + + /** + * Switches to the specified navigation tab. + */ + public function switchTab(TuiTab $tab): void + { + $this->activeTab = $tab; + + if ($tab === TuiTab::Following && ! $this->followingCommits instanceof Collection) { + $this->loadFollowingFeed(); + } + + if ($tab === TuiTab::Profile && ! $this->targetProfileUser instanceof User) { + $this->targetProfileUser = $this->currentUser ?? $this->currentSelectedCommit()?->user; + } + + $this->moveSelection(0); + } + + /** + * Cycles through navigation tabs. + */ + public function cycleTab(int $direction): void + { + $cases = TuiTab::cases(); + $currentIndex = array_search($this->activeTab, $cases, true); + $nextIndex = ($currentIndex !== false ? (int) $currentIndex : 0) + $direction; + + if ($nextIndex < 0) { + $nextIndex = count($cases) - 1; + } elseif ($nextIndex >= count($cases)) { + $nextIndex = 0; + } + + $this->switchTab($cases[$nextIndex]); + } + + /** + * Loads the following feed if the user is authenticated. + */ + public function loadFollowingFeed(): void + { + if (! $this->currentUser instanceof User) { + $this->followingCommits = new Collection(); + $this->statusMessage = 'Specify --user=@username to follow and view your following feed.'; + + return; + } + + /** @var Collection $commits */ + $commits = resolve(FollowingLogQuery::class) + ->builder($this->currentUser) + ->limit(50) + ->get(); + + $this->followingCommits = $commits; + } + + /** + * Opens the conversation thread for the selected commit. + */ + public function openThread(): void + { + $commit = $this->currentSelectedCommit(); + + if (! $commit instanceof Commit) { + return; + } + + $this->threadCommit = $commit; + $this->threadAncestors = resolve(CommitAncestorsQuery::class)->get($commit, $this->currentUser); + + $paginator = resolve(CommitRepliesQuery::class)->paginate($commit, $this->currentUser); + $this->threadReplies = new Collection($paginator->items()); + + $this->threadIndex = $this->threadAncestors->count(); + $this->activeTab = TuiTab::Thread; + } + + /** + * Opens composer for new commit or reply. + */ + public function openComposer(?Commit $replyTo = null): void + { + $this->composeReplyTo = $replyTo; + $this->composeText = ''; + $this->activeTab = TuiTab::Compose; + } + + /** + * Opens author profile in Profile view. + */ + public function openAuthorProfile(?Commit $commit): void + { + if (! $commit instanceof Commit) { + return; + } + + $this->targetProfileUser = $commit->user; + $this->activeTab = TuiTab::Profile; + } + + /** + * Stars or unstars a commit. + */ + public function toggleStar(?Commit $commit = null): void + { + $commit ??= $this->currentSelectedCommit(); + + if (! $commit instanceof Commit) { + return; + } + + if (! $this->currentUser instanceof User) { + $this->statusMessage = 'Cannot star: specify --user=@username to interact.'; + + return; + } + + $isStarred = $this->currentUser->starredCommits()->where('commit_id', $commit->id)->exists(); + + if ($isStarred) { + resolve(DeleteCommitStar::class)->handle($this->currentUser, $commit); + $commit->setAttribute('stars_count', max(0, $commit->stars_count - 1)); + $this->statusMessage = "Unstarred commit {$commit->id}."; + } else { + resolve(CreateCommitStar::class)->handle($this->currentUser, $commit); + $commit->setAttribute('stars_count', $commit->stars_count + 1); + $this->statusMessage = "Starred commit {$commit->id} ★!"; + } + } + + /** + * Submits the composer post to devnull. + */ + public function submitCommit(): bool + { + $trimmed = mb_trim($this->composeText); + + if (mb_strlen($trimmed) < 3) { + $this->statusMessage = 'Commit message must be at least 3 characters.'; + + return false; + } + + if (! $this->currentUser instanceof User) { + $this->statusMessage = 'Cannot commit: specify --user=@username to post.'; + + return false; + } + + $created = resolve(CreateCommit::class)->handle( + $this->currentUser, + $this->composeText, + $this->composeReplyTo + ); + + $created->load(['user']); + + $this->feedCommits->prepend($created); + $this->selectedIndex = 0; + $this->selectedCommit = $created; + $this->composeText = ''; + $this->composeReplyTo = null; + $this->activeTab = TuiTab::Everyone; + + $statusLabel = $created->status === CommitStatus::Approved ? 'Approved' : 'Pending review'; + $this->statusMessage = 'Committed ['.Str::substr($created->id, 0, 7)."] ({$statusLabel})!"; + + return true; + } + + /** + * Returns flattened sequence of thread items (ancestors, focal commit, replies). + * + * @return Collection + */ + public function allThreadCommits(): Collection + { + $items = new Collection(); + + foreach ($this->threadAncestors as $ancestor) { + $items->push($ancestor); + } + + if ($this->threadCommit instanceof Commit) { + $items->push($this->threadCommit); + } + + foreach ($this->threadReplies as $reply) { + $items->push($reply); + } + + return $items; + } + + /** + * Main display rendering method. + */ + public function render(Display $display): void + { + $header = $this->renderHeader(); + $mainView = match ($this->activeTab) { + TuiTab::Everyone, TuiTab::Following => $this->renderFeedView(), + TuiTab::Thread => $this->renderThreadView(), + TuiTab::Compose => $this->renderComposeView(), + TuiTab::Profile => $this->renderProfileView(), + TuiTab::Help => $this->renderHelpView(), + }; + $footer = $this->renderFooter(); + + $root = GridWidget::default() + ->direction(Direction::Vertical) + ->constraints( + Constraint::length(3), + Constraint::min(10), + Constraint::length(1) + ) + ->widgets($header, $mainView, $footer); + + $display->draw($root); + } + + /** + * Maps unix rank level to its theme color. + */ + public function rankColor(int $level): AnsiColor + { + return match ($level) { + 1 => AnsiColor::Gray, + 2 => AnsiColor::LightCyan, + 3 => AnsiColor::LightBlue, + 4 => AnsiColor::LightMagenta, + 5 => AnsiColor::LightYellow, + default => AnsiColor::LightRed, + }; + } + + /** + * Builds terminal driver based on interactivity mode. + */ + public function buildTerminal(bool $interactive): PhpTermTerminal + { + if ($interactive) { + return PhpTermTerminal::new(); + } + + return PhpTermTerminal::new( + painter: new StringPainter(), + eventProvider: ArrayEventProvider::fromEvents(), + rawMode: new TestRawMode() + ); + } + + /** + * Builds display backend based on interactivity mode. + */ + public function buildDisplay(PhpTermTerminal $terminal, bool $interactive): Display + { + if ($interactive) { + return DisplayBuilder::default(PhpTermBackend::new($terminal))->build(); + } + + return DisplayBuilder::default(DummyBackend::fromDimensions(100, 24))->build(); + } + + /** + * Handles text buffer input in Compose mode. + */ + private function handleComposeEvent(Event $event): void + { + if ($event instanceof CodedKeyEvent) { + if ($event->code === KeyCode::Esc) { + $this->composeText = ''; + $this->composeReplyTo = null; + $this->activeTab = TuiTab::Everyone; + $this->statusMessage = 'Draft cancelled.'; + + return; + } + + if ($event->code === KeyCode::Enter) { + $this->submitCommit(); + + return; + } + + if ($event->code === KeyCode::Backspace) { + $this->composeText = mb_substr($this->composeText, 0, -1); + + return; + } + + if ($event->code === KeyCode::Tab) { + if (mb_strlen($this->composeText) <= 278) { + $this->composeText .= ' '; + } + + return; + } + } + + if ($event instanceof CharKeyEvent) { + if (mb_strlen($this->composeText) < 280) { + $this->composeText .= $event->char; + } + } + } + + /** + * Handles thread navigation and interaction. + */ + private function handleThreadEvent(Event $event): void + { + $allThreadItems = $this->allThreadCommits(); + $total = $allThreadItems->count(); + + if ($event instanceof CodedKeyEvent) { + if ($event->code === KeyCode::Esc || $event->code === KeyCode::Backspace) { + $this->activeTab = TuiTab::Everyone; + + return; + } + + if ($event->code === KeyCode::Up) { + $this->threadIndex = max(0, $this->threadIndex - 1); + + return; + } + + if ($event->code === KeyCode::Down) { + $this->threadIndex = min(max(0, $total - 1), $this->threadIndex + 1); + + return; + } + } + + if ($event instanceof CharKeyEvent) { + if ($event->char === 'k') { + $this->threadIndex = max(0, $this->threadIndex - 1); + + return; + } + + if ($event->char === 'j') { + $this->threadIndex = min(max(0, $total - 1), $this->threadIndex + 1); + + return; + } + + $focused = $allThreadItems->get($this->threadIndex); + + if ($event->char === 'r' && $focused instanceof Commit) { + $this->openComposer($focused); + + return; + } + + if ($event->char === 's' && $focused instanceof Commit) { + $this->toggleStar($focused); + + return; + } + + if (in_array($event->char, ['q', '1'], true)) { + $this->activeTab = TuiTab::Everyone; + + return; + } + } + } + + /** + * Renders header bar with tabs. + */ + private function renderHeader(): Widget + { + $tabTitles = array_map( + fn (TuiTab $tab): Line => Line::fromString($tab->label()), + TuiTab::cases() + ); + + $tabIndex = (int) array_search($this->activeTab, TuiTab::cases(), true); + + $userBadge = $this->currentUser instanceof User + ? " @{$this->currentUser->username} " + : ' Guest '; + + return BlockWidget::default() + ->borders(Borders::BOTTOM) + ->borderType(BorderType::Rounded) + ->borderStyle(Style::default()->fg(AnsiColor::DarkGray)) + ->titles( + Title::fromString('/dev/null.sh'), + Title::fromString($userBadge) + ) + ->widget( + TabsWidget::fromTitles(...$tabTitles) + ->select($tabIndex) + ->highlightStyle( + Style::default() + ->fg(AnsiColor::LightGreen) + ->addModifier(Modifier::BOLD) + ) + ); + } + + /** + * Renders the split feed & inspector view. + */ + private function renderFeedView(): Widget + { + $commits = $this->activeFeedCommits(); + $total = $commits->count(); + + // Feed list lines... + $listLines = []; + + if ($total === 0) { + $listLines[] = Line::fromSpans( + Span::styled( + $this->activeTab === TuiTab::Following + ? ' You are not following anyone with commits yet.' + : ' No commits found in the log yet.', + Style::default()->fg(AnsiColor::DarkGray) + ) + ); + } else { + foreach ($commits->values() as $index => $commit) { + $isSelected = $index === $this->selectedIndex; + $laneStyle = $this->laneStyle($commit->user?->lane() ?? 0); + + $hash = Str::substr($commit->id, 0, 7); + $time = $commit->created_at->diffForHumans(short: true); + $starsCount = $this->getIntAttribute($commit, 'stars_count'); + $stars = $starsCount > 0 ? " · {$starsCount}★" : ''; + $repliesCount = $this->getIntAttribute($commit, 'replies_count'); + $replies = $repliesCount > 0 ? " · {$repliesCount}⤿" : ''; + $username = TerminalRenderer::sanitize($commit->user->username ?? 'ghost'); + + $indicator = $isSelected ? '▶ ' : ' '; + $prefixStyle = $isSelected + ? Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD) + : Style::default()->fg(AnsiColor::DarkGray); + + $listLines[] = Line::fromSpans( + Span::styled($indicator, $prefixStyle), + Span::styled("* [{$hash}] ", Style::default()->fg($isSelected ? AnsiColor::White : AnsiColor::Gray)->addModifier(Modifier::BOLD)), + Span::styled("@{$username}", $laneStyle->addModifier(Modifier::BOLD)), + Span::styled(" · {$time}", Style::default()->fg(AnsiColor::DarkGray)), + Span::styled($stars, Style::default()->fg(AnsiColor::Yellow)->addModifier(Modifier::BOLD)), + Span::styled($replies, Style::default()->fg(AnsiColor::Cyan)), + ); + + // Body preview... + $bodyPreview = Str::limit(str_replace(["\r", "\n"], ' ', TerminalRenderer::sanitize($commit->body)), 48); + $listLines[] = Line::fromSpans( + Span::styled(' │ ', $laneStyle), + Span::styled( + $bodyPreview, + $isSelected + ? Style::default()->fg(AnsiColor::White)->addModifier(Modifier::BOLD) + : Style::default()->fg(AnsiColor::Gray) + ), + ); + } + } + + $listWidget = BlockWidget::default() + ->borders(Borders::ALL) + ->borderType(BorderType::Rounded) + ->borderStyle(Style::default()->fg(AnsiColor::Green)) + ->titles(Title::fromString($this->activeTab === TuiTab::Following ? ' Following Feed ' : ' Everyone Feed ')) + ->widget(ParagraphWidget::fromLines(...$listLines)); + + // Inspector details pane... + $selected = $this->currentSelectedCommit(); + $detailLines = []; + + if ($selected instanceof Commit) { + $user = $selected->user; + $laneStyle = $this->laneStyle($user?->lane() ?? 0); + $username = TerminalRenderer::sanitize($user->username ?? 'ghost'); + $name = ($user !== null && $user->name !== '') ? ' ('.TerminalRenderer::sanitize($user->name).')' : ''; + $hash = Str::substr($selected->id, 0, 7); + $date = $selected->created_at->toDayDateTimeString(); + + $detailLines[] = Line::fromSpans( + Span::styled("@{$username}", $laneStyle->addModifier(Modifier::BOLD)), + Span::styled($name, Style::default()->fg(AnsiColor::DarkGray)), + Span::styled(" [{$hash}]", Style::default()->fg(AnsiColor::White)->addModifier(Modifier::BOLD)) + ); + + if ($user !== null && $user->sshKeys()->exists()) { + $detailLines[] = Line::fromSpans( + Span::styled('🔒 [VERIFIED SSH]', Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD)) + ); + } + + $detailLines[] = Line::fromSpans( + Span::styled("📅 Posted {$date}", Style::default()->fg(AnsiColor::DarkGray)) + ); + + $detailLines[] = Line::fromString(''); + + $wrapped = explode("\n", wordwrap(TerminalRenderer::sanitize($selected->body), 38, "\n", true)); + foreach ($wrapped as $line) { + $detailLines[] = Line::fromSpans( + Span::styled('▌ ', $laneStyle), + Span::styled($line, Style::default()->fg(AnsiColor::White)) + ); + } + + $detailLines[] = Line::fromString(''); + $starsCount = $this->getIntAttribute($selected, 'stars_count'); + $repliesCount = $this->getIntAttribute($selected, 'replies_count'); + $detailLines[] = Line::fromSpans( + Span::styled("★ {$starsCount} stars", Style::default()->fg(AnsiColor::Yellow)->addModifier(Modifier::BOLD)), + Span::styled(" ⤿ {$repliesCount} replies", Style::default()->fg(AnsiColor::Cyan)->addModifier(Modifier::BOLD)) + ); + + $detailLines[] = Line::fromString(''); + $detailLines[] = Line::fromSpans( + Span::styled('[Enter] ', Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD)), + Span::styled('Thread ', Style::default()->fg(AnsiColor::Gray)), + Span::styled('[r] ', Style::default()->fg(AnsiColor::LightCyan)->addModifier(Modifier::BOLD)), + Span::styled('Reply ', Style::default()->fg(AnsiColor::Gray)), + Span::styled('[s] ', Style::default()->fg(AnsiColor::LightYellow)->addModifier(Modifier::BOLD)), + Span::styled('Star ', Style::default()->fg(AnsiColor::Gray)), + Span::styled('[u] ', Style::default()->fg(AnsiColor::LightMagenta)->addModifier(Modifier::BOLD)), + Span::styled('Profile', Style::default()->fg(AnsiColor::Gray)) + ); + } else { + $detailLines[] = Line::fromString('Select a commit from the feed on the left.'); + } + + $detailWidget = BlockWidget::default() + ->borders(Borders::ALL) + ->borderType(BorderType::Rounded) + ->borderStyle(Style::default()->fg(AnsiColor::Cyan)) + ->titles(Title::fromString(' Commit Details ')) + ->widget(ParagraphWidget::fromLines(...$detailLines)); + + return GridWidget::default() + ->direction(Direction::Horizontal) + ->constraints(Constraint::percentage(58), Constraint::percentage(42)) + ->widgets($listWidget, $detailWidget); + } + + /** + * Renders conversation thread tree. + */ + private function renderThreadView(): Widget + { + $allThreadItems = $this->allThreadCommits(); + $threadLines = []; + + if ($allThreadItems->isEmpty()) { + $threadLines[] = Line::fromString('No thread loaded. Select a commit from the feed and press [Enter].'); + } else { + foreach ($allThreadItems->values() as $index => $commit) { + $isSelected = $index === $this->threadIndex; + $isFocal = $commit->id === $this->threadCommit?->id; + $laneStyle = $this->laneStyle($commit->user?->lane() ?? 0); + + $hash = Str::substr($commit->id, 0, 7); + $time = $commit->created_at->diffForHumans(short: true); + $username = TerminalRenderer::sanitize($commit->user->username ?? 'ghost'); + + $prefix = $isFocal ? '* (HEAD) ' : ($commit->parent_id !== null ? '├── ' : '• '); + $indicator = $isSelected ? '▶ ' : ' '; + + $headStyle = $isFocal + ? Style::default()->fg(AnsiColor::LightYellow)->addModifier(Modifier::BOLD) + : Style::default()->fg(AnsiColor::DarkGray); + + $starsCount = $this->getIntAttribute($commit, 'stars_count'); + $starsText = $starsCount > 0 ? " · {$starsCount}★" : ''; + + $threadLines[] = Line::fromSpans( + Span::styled($indicator, Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD)), + Span::styled($prefix, $headStyle), + Span::styled("[{$hash}] ", Style::default()->fg(AnsiColor::White)->addModifier(Modifier::BOLD)), + Span::styled("@{$username}", $laneStyle->addModifier(Modifier::BOLD)), + Span::styled(" · {$time}", Style::default()->fg(AnsiColor::DarkGray)), + Span::styled($starsText, Style::default()->fg(AnsiColor::Yellow)->addModifier(Modifier::BOLD)), + ); + + $bodyLine = Str::limit(str_replace(["\r", "\n"], ' ', TerminalRenderer::sanitize($commit->body)), 70); + $threadLines[] = Line::fromSpans( + Span::styled(' │ ', $laneStyle), + Span::styled($bodyLine, $isSelected ? Style::default()->fg(AnsiColor::White)->addModifier(Modifier::BOLD) : Style::default()->fg(AnsiColor::Gray)) + ); + } + } + + return BlockWidget::default() + ->borders(Borders::ALL) + ->borderType(BorderType::Rounded) + ->borderStyle(Style::default()->fg(AnsiColor::LightMagenta)) + ->titles( + Title::fromString(' Thread Conversation '), + Title::fromString(' [Esc] Back to Feed · [r] Reply · [s] Star ') + ) + ->widget(ParagraphWidget::fromLines(...$threadLines)); + } + + /** + * Renders interactive commit/reply composer. + */ + private function renderComposeView(): Widget + { + $replyUsername = $this->composeReplyTo instanceof Commit + ? TerminalRenderer::sanitize($this->composeReplyTo->user->username ?? 'ghost') + : ''; + + $title = $this->composeReplyTo instanceof Commit + ? " Reply to @{$replyUsername} (".Str::substr($this->composeReplyTo->id, 0, 7).') ' + : ' New Commit to /dev/null '; + + $length = mb_strlen($this->composeText); + $counterColor = match (true) { + $length > 270 => AnsiColor::LightRed, + $length > 220 => AnsiColor::LightYellow, + default => AnsiColor::LightGreen, + }; + + $lines = [ + Line::fromSpans( + Span::styled('Pipe your thoughts directly to /dev/null:', Style::default()->fg(AnsiColor::LightCyan)->addModifier(Modifier::BOLD)) + ), + Line::fromString(''), + Line::fromSpans( + Span::styled($this->composeText, Style::default()->fg(AnsiColor::White)), + Span::styled('█', Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD)) + ), + Line::fromString(''), + Line::fromSpans( + Span::styled(str_repeat('─', 60), Style::default()->fg(AnsiColor::DarkGray)) + ), + Line::fromSpans( + Span::styled("[ {$length} / 280 characters ] ", Style::default()->fg($counterColor)->addModifier(Modifier::BOLD)), + Span::styled('[Enter] ', Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD)), + Span::styled('Commit to devnull · ', Style::default()->fg(AnsiColor::DarkGray)), + Span::styled('[Esc] ', Style::default()->fg(AnsiColor::LightRed)->addModifier(Modifier::BOLD)), + Span::styled('Cancel', Style::default()->fg(AnsiColor::DarkGray)) + ), + ]; + + return BlockWidget::default() + ->borders(Borders::ALL) + ->borderType(BorderType::Rounded) + ->borderStyle(Style::default()->fg(AnsiColor::LightGreen)) + ->titles(Title::fromString($title)) + ->widget(ParagraphWidget::fromLines(...$lines)); + } + + /** + * Renders developer profile, Unix ranks, streak and badges. + */ + private function renderProfileView(): Widget + { + $user = $this->targetProfileUser ?? $this->currentUser ?? $this->currentSelectedCommit()?->user; + + if ($user === null) { + return BlockWidget::default() + ->borders(Borders::ALL) + ->borderType(BorderType::Rounded) + ->titles(Title::fromString(' Profile ')) + ->widget(ParagraphWidget::fromString('No user selected.')); + } + + $achievements = resolve(UserAchievementsQuery::class)->get($user, $this->currentUser); + + $laneStyle = $this->laneStyle($user->lane()); + $username = TerminalRenderer::sanitize($user->username); + $name = $user->name ? ' ('.TerminalRenderer::sanitize($user->name).')' : ''; + + $lines = []; + $lines[] = Line::fromSpans( + Span::styled("@{$username}", $laneStyle->addModifier(Modifier::BOLD)), + Span::styled($name, Style::default()->fg(AnsiColor::DarkGray)), + Span::styled(" · UID {$user->id}", Style::default()->fg(AnsiColor::White)->addModifier(Modifier::BOLD)) + ); + + if ($user->bio) { + $lines[] = Line::fromSpans( + Span::styled(TerminalRenderer::sanitize($user->bio), Style::default()->fg(AnsiColor::Gray)) + ); + } + + $lines[] = Line::fromString(str_repeat('─', 70)); + + // Rank and XP... + $rankTitle = ucfirst((string) $achievements['rank']); + $level = $achievements['level']; + $xp = $achievements['xp']; + $nextXp = $achievements['next_level_xp']; + $progress = $achievements['level_progress']; + + $rankColor = $this->rankColor($level); + + $barFilled = (int) round($progress / 5); + $filledBlocks = str_repeat('█', $barFilled); + $emptyBlocks = str_repeat('░', 20 - $barFilled); + + $lines[] = Line::fromSpans( + Span::styled("Unix Rank: {$rankTitle} [Level {$level}]", Style::default()->fg($rankColor)->addModifier(Modifier::BOLD)), + Span::styled(' · ', Style::default()->fg(AnsiColor::DarkGray)), + Span::styled("{$xp} Total XP", Style::default()->fg(AnsiColor::Yellow)->addModifier(Modifier::BOLD)) + ); + + $nextText = $nextXp !== null ? "Next Rank at {$nextXp} XP" : 'Max Rank Reached'; + $lines[] = Line::fromSpans( + Span::styled('Progress: [', Style::default()->fg(AnsiColor::DarkGray)), + Span::styled($filledBlocks, Style::default()->fg($rankColor)->addModifier(Modifier::BOLD)), + Span::styled($emptyBlocks, Style::default()->fg(AnsiColor::DarkGray)), + Span::styled("] {$progress}% ({$nextText})", Style::default()->fg(AnsiColor::DarkGray)) + ); + + $lines[] = Line::fromString(''); + + // Streak & Breakdown... + $streakCount = $achievements['xp_breakdown']['streak']['count']; + $commitsCount = $achievements['xp_breakdown']['commits']['count']; + $starsRecv = $achievements['xp_breakdown']['stars_received']['count']; + $repliesCount = $achievements['xp_breakdown']['replies']['count']; + $starsGiven = $achievements['xp_breakdown']['stars_given']['count']; + + $lines[] = Line::fromSpans( + Span::styled("🔥 {$streakCount}-Day Streak · ", Style::default()->fg(AnsiColor::LightYellow)->addModifier(Modifier::BOLD)), + Span::styled("{$commitsCount} Commits · ", Style::default()->fg(AnsiColor::White)), + Span::styled("★ {$starsRecv} Stars Recv · ", Style::default()->fg(AnsiColor::Yellow)), + Span::styled("⤿ {$repliesCount} Replies · ", Style::default()->fg(AnsiColor::Cyan)), + Span::styled("{$starsGiven} Stars Given", Style::default()->fg(AnsiColor::DarkGray)) + ); + + $lines[] = Line::fromString(''); + $lines[] = Line::fromSpans( + Span::styled('Unlocked Achievements & Badges:', Style::default()->fg(AnsiColor::White)->addModifier(Modifier::BOLD)) + ); + + foreach (array_slice($achievements['badges'], 0, 6) as $badge) { + $isUnlocked = $badge['unlocked']; + $check = $isUnlocked ? '[✓]' : '[ ]'; + $badgeStyle = $isUnlocked + ? Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD) + : Style::default()->fg(AnsiColor::DarkGray); + + $lines[] = Line::fromSpans( + Span::styled(" {$check} ", $badgeStyle), + Span::styled("{$badge['name']}: ", $isUnlocked ? Style::default()->fg(AnsiColor::LightCyan)->addModifier(Modifier::BOLD) : Style::default()->fg(AnsiColor::DarkGray)), + Span::styled((string) $badge['description'], Style::default()->fg(AnsiColor::Gray)) + ); + } + + return BlockWidget::default() + ->borders(Borders::ALL) + ->borderType(BorderType::Rounded) + ->borderStyle(Style::default()->fg($rankColor)) + ->titles( + Title::fromString(" Developer Profile: @{$username} "), + Title::fromString(' [Esc] Back to Feed ') + ) + ->widget(ParagraphWidget::fromLines(...$lines)); + } + + /** + * Renders help modal and keybinding cheatsheet. + */ + private function renderHelpView(): Widget + { + $lines = [ + Line::fromSpans( + Span::styled('/dev/null.sh Terminal Client - Keybindings & Controls', Style::default()->fg(AnsiColor::LightCyan)->addModifier(Modifier::BOLD)) + ), + Line::fromString(str_repeat('─', 70)), + Line::fromSpans(Span::styled(' Navigation:', Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD))), + Line::fromString(' ↑ / k Move selection up'), + Line::fromString(' ↓ / j Move selection down'), + Line::fromString(' Home / g Jump to top of feed'), + Line::fromString(' End / G Jump to bottom of feed'), + Line::fromString(' Enter Open full thread conversation for selected commit'), + Line::fromString(''), + Line::fromSpans(Span::styled(' Interactions:', Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD))), + Line::fromString(' p Create a new commit to /dev/null'), + Line::fromString(' r Reply to the selected commit'), + Line::fromString(' s Star or unstar the selected commit'), + Line::fromString(' u Inspect author profile, Unix rank & achievements'), + Line::fromString(''), + Line::fromSpans(Span::styled(' Views & Tabs:', Style::default()->fg(AnsiColor::LightGreen)->addModifier(Modifier::BOLD))), + Line::fromString(' 1 Everyone feed (global timeline)'), + Line::fromString(' 2 Following feed'), + Line::fromString(' 3 Thread conversation view'), + Line::fromString(' 4 Composer (New Post / Reply)'), + Line::fromString(' 5 Developer profile & ranks'), + Line::fromString(' ? Toggle this help cheatsheet'), + Line::fromString(' q / Esc Back / Quit application'), + ]; + + return BlockWidget::default() + ->borders(Borders::ALL) + ->borderType(BorderType::Rounded) + ->borderStyle(Style::default()->fg(AnsiColor::LightYellow)) + ->titles(Title::fromString(' Help / Cheatsheet '), Title::fromString(' [Esc] Close Help ')) + ->widget(ParagraphWidget::fromLines(...$lines)); + } + + /** + * Renders bottom status bar. + */ + private function renderFooter(): Widget + { + $status = $this->statusMessage + ? Line::fromSpans( + Span::styled(' ⚡ ', Style::default()->fg(AnsiColor::LightYellow)->addModifier(Modifier::BOLD)), + Span::styled($this->statusMessage, Style::default()->fg(AnsiColor::White)->addModifier(Modifier::BOLD)) + ) + : Line::fromSpans( + Span::styled(' ↑/↓ ', Style::default()->fg(AnsiColor::Green)->addModifier(Modifier::BOLD)), + Span::styled('Nav · ', Style::default()->fg(AnsiColor::DarkGray)), + Span::styled('Enter ', Style::default()->fg(AnsiColor::Green)->addModifier(Modifier::BOLD)), + Span::styled('Thread · ', Style::default()->fg(AnsiColor::DarkGray)), + Span::styled('p ', Style::default()->fg(AnsiColor::Green)->addModifier(Modifier::BOLD)), + Span::styled('Post · ', Style::default()->fg(AnsiColor::DarkGray)), + Span::styled('r ', Style::default()->fg(AnsiColor::Green)->addModifier(Modifier::BOLD)), + Span::styled('Reply · ', Style::default()->fg(AnsiColor::DarkGray)), + Span::styled('s ', Style::default()->fg(AnsiColor::Green)->addModifier(Modifier::BOLD)), + Span::styled('Star · ', Style::default()->fg(AnsiColor::DarkGray)), + Span::styled('1-5 ', Style::default()->fg(AnsiColor::Green)->addModifier(Modifier::BOLD)), + Span::styled('Tabs · ', Style::default()->fg(AnsiColor::DarkGray)), + Span::styled('q ', Style::default()->fg(AnsiColor::Green)->addModifier(Modifier::BOLD)), + Span::styled('Quit', Style::default()->fg(AnsiColor::DarkGray)) + ); + + return ParagraphWidget::fromLines($status); + } + + /** + * Resolves the RgbColor style for a given user lane. + */ + private function laneStyle(int|string $lane): Style + { + if (is_string($lane) && str_starts_with($lane, '#') && mb_strlen($lane) === 7) { + $r = (int) hexdec(mb_substr($lane, 1, 2)); + $g = (int) hexdec(mb_substr($lane, 3, 2)); + $b = (int) hexdec(mb_substr($lane, 5, 2)); + + return Style::default()->fg(RgbColor::fromRgb($r, $g, $b)); + } + + $laneIndex = is_numeric($lane) ? (int) $lane : 0; + $rgb = TerminalRenderer::LANES[$laneIndex] ?? TerminalRenderer::LANES[0]; + + return Style::default()->fg(RgbColor::fromRgb($rgb[0], $rgb[1], $rgb[2])); + } + + /** + * Safely reads an integer attribute from an Eloquent model without triggering strict mode. + */ + private function getIntAttribute(Commit $commit, string $key): int + { + $value = $commit->getAttributes()[$key] ?? 0; + + if (is_int($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int) $value; + } + + return 0; + } +} diff --git a/composer.json b/composer.json index c531177271..41b90d8c5e 100644 --- a/composer.json +++ b/composer.json @@ -19,9 +19,13 @@ "laravel/wayfinder": "^0.1.23", "league/flysystem-aws-s3-v3": "^3.35.3", "nunomaduro/essentials": "^1.2.0", + "php-tui/php-tui": "^0.2.1", "phpseclib/phpseclib": "^4.0.2", "tempest/highlight": "^2.28.1" }, + "suggest": { + "prateekbhujel/php-terminal": "Enables native C-level POSIX raw I/O acceleration without stty subprocesses." + }, "require-dev": { "driftingly/rector-laravel": "^2.6.2", "fakerphp/faker": "^1.24.1", diff --git a/composer.lock b/composer.lock index e71895f2f5..4bf147221f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cf20f71aca3b040759bff4a289a01c2c", + "content-hash": "48417c6def4d42bc77488ddb4586c597", "packages": [ { "name": "aws/aws-crt-php", @@ -3430,6 +3430,159 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "php-tui/cassowary", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-tui/cassowary.git", + "reference": "903b1a23481bf487caf825f7feb93f7a81d56b9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-tui/cassowary/zipball/903b1a23481bf487caf825f7feb93f7a81d56b9d", + "reference": "903b1a23481bf487caf825f7feb93f7a81d56b9d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.34", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.4", + "symfony/var-dumper": "^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpTui\\Cassowary\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Leech" + } + ], + "description": "PHP TUI", + "support": { + "issues": "https://github.com/php-tui/cassowary/issues", + "source": "https://github.com/php-tui/cassowary/tree/0.1.0" + }, + "time": "2023-10-23T16:11:57+00:00" + }, + { + "name": "php-tui/php-tui", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/php-tui/php-tui.git", + "reference": "d4921c791f8f5b58606810916d0c3eb355db7dc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-tui/php-tui/zipball/d4921c791f8f5b58606810916d0c3eb355db7dc7", + "reference": "d4921c791f8f5b58606810916d0c3eb355db7dc7", + "shasum": "" + }, + "require": { + "php": "^8.1", + "php-tui/cassowary": "^0.1.0", + "php-tui/term": "^0.3.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.34", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.3", + "phpstan/phpdoc-parser": "^1.24", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.5", + "phpunit/phpunit": "^10.4", + "rector/rector": "^1.0", + "roave/better-reflection": "^6.15", + "symfony/var-dumper": "^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpTui\\BDF\\": "lib/bdf/src", + "PhpTui\\Tui\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Leech" + } + ], + "description": "Comprehensive TUI library heavily influenced by Ratatui", + "keywords": [ + "cli", + "console", + "terminal", + "tui" + ], + "support": { + "issues": "https://github.com/php-tui/php-tui/issues", + "source": "https://github.com/php-tui/php-tui/tree/0.2.1" + }, + "time": "2024-09-08T09:16:18+00:00" + }, + { + "name": "php-tui/term", + "version": "0.3.4", + "source": { + "type": "git", + "url": "https://github.com/php-tui/term.git", + "reference": "bf8bb01d5997c2b926637a7b436cb5beac6acde9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-tui/term/zipball/bf8bb01d5997c2b926637a7b436cb5beac6acde9", + "reference": "bf8bb01d5997c2b926637a7b436cb5beac6acde9", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.34", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.4", + "symfony/var-dumper": "^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpTui\\Term\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Leech" + } + ], + "description": "comprehensive low level terminal control", + "support": { + "issues": "https://github.com/php-tui/term/issues", + "source": "https://github.com/php-tui/term/tree/0.3.4" + }, + "time": "2023-12-04T18:42:32+00:00" + }, { "name": "phpoption/phpoption", "version": "1.10.0", @@ -14825,5 +14978,5 @@ "php": "^8.5.0" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/tests/Feature/Commands/LogCommandTest.php b/tests/Feature/Commands/LogCommandTest.php new file mode 100644 index 0000000000..54e7f3dafc --- /dev/null +++ b/tests/Feature/Commands/LogCommandTest.php @@ -0,0 +1,204 @@ +create(['username' => 'alice']); + $bob = User::factory()->create(['username' => 'bob']); + + $commitA = Commit::factory()->for($alice)->create([ + 'body' => 'First message from Alice.', + 'status' => CommitStatus::Approved, + ]); + + $commitB = Commit::factory()->for($bob)->create([ + 'body' => 'Second message from Bob.', + 'status' => CommitStatus::Approved, + ]); + + $this->artisan('log --everyone') + ->expectsOutputToContain('@alice') + ->expectsOutputToContain('First message from Alice.') + ->expectsOutputToContain('@bob') + ->expectsOutputToContain('Second message from Bob.') + ->assertSuccessful(); +}); + +it('reads a specific developer log from terminal command', function (): void { + $alice = User::factory()->create(['username' => 'alice']); + $bob = User::factory()->create(['username' => 'bob']); + + Commit::factory()->for($alice)->create([ + 'body' => 'Alice unique post.', + 'status' => CommitStatus::Approved, + ]); + + Commit::factory()->for($bob)->create([ + 'body' => 'Bob unique post.', + 'status' => CommitStatus::Approved, + ]); + + $this->artisan('log alice') + ->expectsOutputToContain('@alice') + ->expectsOutputToContain('Alice unique post.') + ->doesntExpectOutputToContain('Bob unique post.') + ->assertSuccessful(); +}); + +it('handles @ prefix in username argument gracefully', function (): void { + $alice = User::factory()->create(['username' => 'alice']); + + Commit::factory()->for($alice)->create([ + 'body' => 'Prefixed username post.', + 'status' => CommitStatus::Approved, + ]); + + $this->artisan('log @alice') + ->expectsOutputToContain('@alice') + ->expectsOutputToContain('Prefixed username post.') + ->assertSuccessful(); +}); + +it('fails cleanly when requested user is not found', function (): void { + $this->artisan('log nonexistent_dev') + ->expectsOutputToContain('User @nonexistent_dev not found.') + ->assertFailed(); +}); + +it('shows empty log message when no commits exist', function (): void { + $this->artisan('log --everyone') + ->expectsOutputToContain('No commits found in the log yet.') + ->assertSuccessful(); +}); + +it('limits the number of displayed commits with --limit option', function (): void { + $user = User::factory()->create(); + + for ($i = 1; $i <= 5; $i++) { + Commit::factory()->for($user)->create([ + 'body' => "Post number {$i}", + 'status' => CommitStatus::Approved, + 'created_at' => now()->subMinutes(10 - $i), + ]); + } + + $this->artisan('log --everyone --limit=2') + ->expectsOutputToContain('Post number 5') + ->expectsOutputToContain('Post number 4') + ->doesntExpectOutputToContain('Post number 1') + ->assertSuccessful(); +}); + +it('respects --no-tui flag and outputs static git DAG rail', function (): void { + $user = User::factory()->create(['username' => 'nuno']); + + Commit::factory()->for($user)->create([ + 'body' => 'Testing non-interactive static output.', + 'status' => CommitStatus::Approved, + ]); + + $this->artisan('log --everyone --no-tui') + ->expectsOutputToContain('@nuno') + ->expectsOutputToContain('Testing non-interactive static output.') + ->expectsOutputToContain('Log:') + ->assertSuccessful(); +}); + +it('sanitizes malicious ANSI injection in commit bodies', function (): void { + $user = User::factory()->create(['username' => 'attacker']); + + Commit::factory()->for($user)->create([ + 'body' => "Exploit \033[2J\033[HCleaned text", + 'status' => CommitStatus::Approved, + ]); + + $this->artisan('log --everyone') + ->expectsOutputToContain('Exploit Cleaned text') + ->doesntExpectOutputToContain("\033[2J") + ->assertSuccessful(); +}); + +it('launches interactive tui mode with --tui flag', function (): void { + $alice = User::factory()->create(['username' => 'alice']); + + Commit::factory()->for($alice)->create([ + 'body' => 'Interactive TUI commit test.', + 'status' => CommitStatus::Approved, + ]); + + $this->artisan('log --tui') + ->assertSuccessful(); + + $this->artisan('log --as=@alice --tui') + ->assertSuccessful(); +}); + +it('posts a commit directly via cli with --post flag for regular user', function (): void { + $alice = User::factory()->create(['username' => 'alice']); + + $this->artisan('log', [ + '--post' => 'Direct commit from CLI', + '--as' => '@alice', + ]) + ->expectsOutputToContain('Committed:') + ->assertSuccessful(); + + expect(Commit::query()->where('body', 'Direct commit from CLI')->exists())->toBeTrue(); +}); + +it('posts an approved commit directly via cli with --post flag for admin user', function (): void { + config()->set('app.admins', ['root_admin']); + $admin = User::factory()->create(['username' => 'root_admin']); + + $this->artisan('log', [ + '--post' => 'Admin direct commit', + '--as' => '@root_admin', + ]) + ->expectsOutputToContain('Committed:') + ->assertSuccessful(); +}); + +it('posts a reply to an existing commit with --post and --reply', function (): void { + $alice = User::factory()->create(['username' => 'alice']); + $parent = Commit::factory()->for($alice)->create([ + 'body' => 'Parent thread commit.', + 'status' => CommitStatus::Approved, + ]); + + $this->artisan("log --post=\"Reply to commit\" --as=@alice --reply={$parent->id}") + ->expectsOutputToContain('Committed:') + ->assertSuccessful(); + + $reply = Commit::query()->where('body', 'Reply to commit')->first(); + expect($reply)->not->toBeNull(); + expect($reply->parent_id)->toBe($parent->id); +}); + +it('fails direct post when reply target commit cannot be found', function (): void { + $alice = User::factory()->create(['username' => 'alice']); + + $this->artisan('log --post="Invalid reply" --as=@alice --reply=nonexistent_hash_12345') + ->expectsOutputToContain('Reply target commit nonexistent_hash_12345 not found.') + ->assertFailed(); +}); + +it('fails direct post when no user can be resolved', function (): void { + Commit::query()->delete(); + User::query()->delete(); + + $this->artisan('log --post="Orphan post"') + ->expectsOutputToContain('Cannot commit: no user found. Provide --as=@username.') + ->assertFailed(); +}); + +it('falls back to default user when --as is omitted in direct post', function (): void { + $alice = User::factory()->create(['username' => 'alice']); + + $this->artisan('log --post="Default user message"') + ->expectsOutputToContain('Committed:') + ->assertSuccessful(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 6e10bd9639..ff60c0c97b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -28,9 +28,15 @@ Http::preventStrayRequests(); Process::preventStrayProcesses(); Sleep::fake(); + config(['services.openai.key' => null]); $this->freezeTime(); }) + ->afterEach(function (): void { + if (defined('STDIN') && is_resource(STDIN)) { + stream_set_blocking(STDIN, true); + } + }) ->in('Feature', 'Unit'); expect()->extend('toBeOne', fn () => $this->toBe(1)); diff --git a/tests/Unit/Enums/TuiTabTest.php b/tests/Unit/Enums/TuiTabTest.php new file mode 100644 index 0000000000..f20bcbe361 --- /dev/null +++ b/tests/Unit/Enums/TuiTabTest.php @@ -0,0 +1,25 @@ +toBe([ + 'Everyone' => 'everyone', + 'Following' => 'following', + 'Thread' => 'thread', + 'Compose' => 'compose', + 'Profile' => 'profile', + 'Help' => 'help', + ]); +}); + +it('provides readable tab labels with keyboard shortcuts', function (): void { + expect(TuiTab::Everyone->label())->toBe('[1] Everyone') + ->and(TuiTab::Following->label())->toBe('[2] Following') + ->and(TuiTab::Thread->label())->toBe('[3] Thread') + ->and(TuiTab::Compose->label())->toBe('[4] New Post') + ->and(TuiTab::Profile->label())->toBe('[5] Profile & Ranks') + ->and(TuiTab::Help->label())->toBe('[?] Help'); +}); diff --git a/tests/Unit/Queries/CommitSearchQueryTest.php b/tests/Unit/Queries/CommitSearchQueryTest.php index 5cd7f78a9c..6e0687361a 100644 --- a/tests/Unit/Queries/CommitSearchQueryTest.php +++ b/tests/Unit/Queries/CommitSearchQueryTest.php @@ -16,6 +16,10 @@ config(['services.openai.key' => 'sk-test', 'ai.providers.openai.key' => 'sk-test']); }); +afterEach(function (): void { + config(['services.openai.key' => null]); +}); + it('returns the commits nearest in meaning, closest first', function (): void { fakeSearch(); Embeddings::fake([[embeddingVector(0)]])->preventStrayEmbeddings(); diff --git a/tests/Unit/Services/Terminal/InteractiveFeedTest.php b/tests/Unit/Services/Terminal/InteractiveFeedTest.php new file mode 100644 index 0000000000..9fc1e19036 --- /dev/null +++ b/tests/Unit/Services/Terminal/InteractiveFeedTest.php @@ -0,0 +1,23 @@ +toBeFalse(); +}); + +it('gracefully handles non-TTY or missing extension environments without throwing', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + + $result = InteractiveFeed::run(collect([$commit]), 'everyone', interactive: false); + + expect($result)->toBeBool(); +}); diff --git a/tests/Unit/Services/Terminal/TerminalRendererTest.php b/tests/Unit/Services/Terminal/TerminalRendererTest.php new file mode 100644 index 0000000000..31e60f4c78 --- /dev/null +++ b/tests/Unit/Services/Terminal/TerminalRendererTest.php @@ -0,0 +1,162 @@ +toBe('Hello World!'); + + // Strips C0 control characters (BEL, NUL, BS, etc.)... + $control = "Safe\x07\x00\x08Text"; + expect(TerminalRenderer::sanitize($control))->toBe('SafeText'); + + // Preserves valid UTF-8 and emojis... + $emoji = "Commit with 🔥 and 🚀 works\nsecond line"; + expect(TerminalRenderer::sanitize($emoji))->toBe("Commit with 🔥 and 🚀 works\nsecond line"); +}); + +it('renders the ASCII banner', function (): void { + $renderer = new TerminalRenderer(); + + $colored = $renderer->banner(true); + expect($colored)->toContain('/dev/null') + ->toContain("\033["); + + $plain = $renderer->banner(false); + expect($plain)->toContain('/dev/null') + ->not->toContain("\033["); +}); + +it('renders empty log cleanly', function (): void { + $renderer = new TerminalRenderer(); + + $output = $renderer->log([], 'everyone', false); + + expect($output) + ->toContain('Nobody has committed anything yet') + ->toContain('php artisan log') + ->not->toContain("\033["); +}); + +it('renders log with commit nodes and lane colors', function (): void { + $user = User::factory()->create([ + 'username' => 'nuno', + 'preferred_lane' => 0, + ]); + + $commit = Commit::factory()->for($user)->create([ + 'body' => 'Shipped to production.', + 'status' => CommitStatus::Approved, + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->log([$commit], 'everyone', true); + + expect($output) + ->toContain('@nuno') + ->toContain('Shipped to production.') + ->toContain(mb_substr($commit->id, 0, 7)) + ->toContain("\033[38;2;217;72;15m"); // Lane 0 orange +}); + +it('renders log with 16-color ANSI fallbacks', function (): void { + $user = User::factory()->create([ + 'username' => 'nuno', + 'preferred_lane' => 1, + ]); + + $commit = Commit::factory()->for($user)->create([ + 'body' => 'Standard ANSI terminal.', + 'status' => CommitStatus::Approved, + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->log([$commit], 'everyone', color: true, force16: true); + + expect($output) + ->toContain('@nuno') + ->toContain("\033[32m"); // Lane 1 green in ANSI-16 +}); + +it('renders user profile and stats in terminal format', function (): void { + $user = User::factory()->create([ + 'username' => 'taylor', + 'name' => 'Taylor Otwell', + 'bio' => 'Laravel creator.', + 'preferred_lane' => 3, + ]); + + $commit = Commit::factory()->for($user)->create([ + 'body' => 'Laravel release day.', + 'status' => CommitStatus::Approved, + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->profile($user, [$commit], false); + + expect($output) + ->toContain('@taylor (Taylor Otwell)') + ->toContain('Laravel creator.') + ->toContain('1 commits') + ->toContain('Laravel release day.') + ->not->toContain("\033["); +}); + +it('renders thread with ancestors, focused commit, and replies', function (): void { + $user = User::factory()->create(['username' => 'brendt']); + + $parent = Commit::factory()->for($user)->create([ + 'body' => 'Original commit.', + 'status' => CommitStatus::Approved, + ]); + + $focused = Commit::factory()->for($user)->create([ + 'parent_id' => $parent->id, + 'body' => 'In-between reply.', + 'status' => CommitStatus::Approved, + ]); + + $reply = Commit::factory()->for($user)->create([ + 'parent_id' => $focused->id, + 'body' => 'Bottom reply.', + 'status' => CommitStatus::Approved, + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->thread($focused, [$parent], [$reply], false); + + expect($output) + ->toContain('Original commit.') + ->toContain('In-between reply.') + ->toContain('(HEAD)') + ->toContain('Bottom reply.') + ->not->toContain("\033["); +}); + +it('honors NO_COLOR environment variable in supportsColor', function (): void { + $renderer = new TerminalRenderer(); + + $_SERVER['NO_COLOR'] = '1'; + expect($renderer->supportsColor())->toBeFalse(); + + unset($_SERVER['NO_COLOR']); + expect($renderer->supportsColor())->toBeTrue(); +}); + +it('renders user profile with zero commits cleanly', function (): void { + $user = User::factory()->create([ + 'username' => 'newdev', + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->profile($user, [], false); + + expect($output) + ->toContain('@newdev has not committed anything yet.'); +}); diff --git a/tests/Unit/Services/Terminal/TuiApplicationTest.php b/tests/Unit/Services/Terminal/TuiApplicationTest.php new file mode 100644 index 0000000000..04624e3c33 --- /dev/null +++ b/tests/Unit/Services/Terminal/TuiApplicationTest.php @@ -0,0 +1,681 @@ +create(); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + + $app = new TuiApplication(collect([$commit]), $user); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->handleEvent(CharKeyEvent::new('2')); + expect($app->activeTab)->toBe(TuiTab::Following); + + $app->handleEvent(CharKeyEvent::new('5')); + expect($app->activeTab)->toBe(TuiTab::Profile); + + $app->handleEvent(CharKeyEvent::new('?')); + expect($app->activeTab)->toBe(TuiTab::Help); + + $app->handleEvent(CharKeyEvent::new('1')); + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->handleEvent(CharKeyEvent::new('4')); + expect($app->activeTab)->toBe(TuiTab::Compose); +}); + +it('navigates feed selection with up, down, home, and end keys', function (): void { + $user = User::factory()->create(); + $commits = Commit::factory()->count(5)->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + + $app = new TuiApplication($commits, $user); + + expect($app->selectedIndex)->toBe(0); + + // Down arrow or 'j'... + $app->handleEvent(CodedKeyEvent::new(KeyCode::Down)); + expect($app->selectedIndex)->toBe(1); + + $app->handleEvent(CharKeyEvent::new('j')); + expect($app->selectedIndex)->toBe(2); + + // Up arrow or 'k'... + $app->handleEvent(CodedKeyEvent::new(KeyCode::Up)); + expect($app->selectedIndex)->toBe(1); + + $app->handleEvent(CharKeyEvent::new('k')); + expect($app->selectedIndex)->toBe(0); + + // End / Home... + $app->handleEvent(CodedKeyEvent::new(KeyCode::End)); + expect($app->selectedIndex)->toBe(4); + + $app->handleEvent(CodedKeyEvent::new(KeyCode::Home)); + expect($app->selectedIndex)->toBe(0); +}); + +it('opens and closes help tab', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + + $app = new TuiApplication(collect([$commit]), $user); + + $app->handleEvent(CharKeyEvent::new('?')); + + expect($app->activeTab)->toBe(TuiTab::Help); + + $app->handleEvent(CodedKeyEvent::new(KeyCode::Esc)); + expect($app->activeTab)->toBe(TuiTab::Everyone); +}); + +it('composes text and handles backspace in composer tab', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + + $app = new TuiApplication(collect([$commit]), $user); + $app->openComposer(); + + expect($app->activeTab)->toBe(TuiTab::Compose) + ->and($app->composeText)->toBe(''); + + $app->handleEvent(CharKeyEvent::new('h')); + $app->handleEvent(CharKeyEvent::new('i')); + + expect($app->composeText)->toBe('hi'); + + $app->handleEvent(CodedKeyEvent::new(KeyCode::Backspace)); + expect($app->composeText)->toBe('h'); + + // Canceling via Esc returns to feed... + $app->handleEvent(CodedKeyEvent::new(KeyCode::Esc)); + expect($app->activeTab)->toBe(TuiTab::Everyone); +}); + +it('submits a new commit from the composer', function (): void { + $user = User::factory()->create(['username' => 'terminaluser']); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + + $app = new TuiApplication(collect([$commit]), $user); + $app->openComposer(); + $app->composeText = 'Posting directly from php-tui terminal!'; + + $submitted = $app->submitCommit(); + + expect($submitted)->toBeTrue() + ->and($app->activeTab)->toBe(TuiTab::Everyone) + ->and($app->statusMessage)->toContain('Committed'); + + $this->assertDatabaseHas('commits', [ + 'user_id' => $user->id, + 'body' => 'Posting directly from php-tui terminal!', + ]); +}); + +it('stars and unstars commit with s key', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + + $app = new TuiApplication(collect([$commit]), $user); + + $app->toggleStar(); + + expect($app->statusMessage)->toContain('Starred commit'); + $this->assertDatabaseHas('stars', [ + 'user_id' => $user->id, + 'commit_id' => $commit->id, + ]); + + $app->toggleStar(); + expect($app->statusMessage)->toContain('Unstarred commit'); + $this->assertDatabaseMissing('stars', [ + 'user_id' => $user->id, + 'commit_id' => $commit->id, + ]); +}); + +it('renders all tabs on a dummy backend without errors', function (): void { + $user = User::factory()->create([ + 'username' => 'renderdev', + 'name' => 'Render Dev', + ]); + $parent = Commit::factory()->for($user)->create([ + 'body' => 'Top parent commit.', + 'status' => CommitStatus::Approved, + ]); + $reply = Commit::factory()->for($user)->create([ + 'parent_id' => $parent->id, + 'body' => 'Child reply commit.', + 'status' => CommitStatus::Approved, + ]); + + $backend = DummyBackend::fromDimensions(100, 24); + $display = DisplayBuilder::default($backend)->build(); + + $app = new TuiApplication(collect([$parent]), $user); + + // 1. Everyone tab + $app->render($display); + expect($backend->toString())->toContain('Everyone Feed') + ->toContain('Top parent commit.'); + + // 2. Compose tab + $app->openComposer(); + $app->render($display); + expect($backend->toString())->toContain('New Commit to /dev/null') + ->toContain('280 characters'); + + // 3. Thread tab + $app->openThread(); + $app->render($display); + expect($backend->toString())->toContain('Thread Conversation') + ->toContain('Top parent commit.'); + + // 4. Profile tab + $app->activeTab = TuiTab::Profile; + $app->render($display); + expect($backend->toString())->toContain('Developer Profile') + ->toContain('Unix Rank'); + + // 5. Help tab + $app->activeTab = TuiTab::Help; + $app->render($display); + + expect($backend->toString())->toContain('Keybindings & Controls'); +}); + +it('runs the interactive event loop and exits on quit key', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + $app = new TuiApplication(collect([$commit]), $user); + + $terminal = PhpTermTerminal::new( + painter: new StringPainter(), + eventProvider: ArrayEventProvider::fromEvents( + CharKeyEvent::new('q'), + ), + rawMode: new TestRawMode() + ); + + $backend = DummyBackend::fromDimensions(100, 24); + $display = DisplayBuilder::default($backend)->build(); + + $result = $app->run($display, $terminal, interactive: true); + expect($result)->toBeTrue(); +}); + +it('catches exceptions in run loop and returns false cleanly', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + $app = new TuiApplication(collect([$commit]), $user); + + $backend = $this->createMock(Backend::class); + $backend->method('size')->willReturn(Area::fromDimensions(100, 24)); + $backend->method('clearRegion')->willThrowException(new RuntimeException('Backend error')); + + $display = DisplayBuilder::default($backend)->build(); + + $result = $app->run(display: $display); + expect($result)->toBeFalse(); +}); + +it('handles keyboard shortcuts in help tab', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + $app = new TuiApplication(collect([$commit]), $user); + $app->activeTab = TuiTab::Help; + + $app->handleEvent(CharKeyEvent::new('2')); + + expect($app->activeTab)->toBe(TuiTab::Following); + + $app->activeTab = TuiTab::Help; + $app->handleEvent(CharKeyEvent::new('3')); + + expect($app->activeTab)->toBe(TuiTab::Thread); + + $app->activeTab = TuiTab::Help; + $app->handleEvent(CharKeyEvent::new('4')); + + expect($app->activeTab)->toBe(TuiTab::Compose); + + $app->activeTab = TuiTab::Help; + $app->handleEvent(CharKeyEvent::new('5')); + + expect($app->activeTab)->toBe(TuiTab::Profile); + + $app->activeTab = TuiTab::Help; + $app->handleEvent(CharKeyEvent::new('?')); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->activeTab = TuiTab::Help; + $app->handleEvent(CharKeyEvent::new('q')); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->activeTab = TuiTab::Help; + $app->handleEvent(CharKeyEvent::new("\x1b")); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->activeTab = TuiTab::Help; + $app->handleEvent(CodedKeyEvent::new(KeyCode::Enter)); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->activeTab = TuiTab::Help; + $app->handleEvent(CharKeyEvent::new('z')); + + expect($app->activeTab)->toBe(TuiTab::Help); +}); + +it('handles keyboard shortcuts in profile tab', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + $app = new TuiApplication(collect([$commit]), $user); + $app->activeTab = TuiTab::Profile; + + $app->handleEvent(CodedKeyEvent::new(KeyCode::Esc)); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->activeTab = TuiTab::Profile; + $app->handleEvent(CharKeyEvent::new('q')); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->activeTab = TuiTab::Profile; + $app->handleEvent(CharKeyEvent::new("\x1b")); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->activeTab = TuiTab::Profile; + $app->handleEvent(CharKeyEvent::new('1')); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->activeTab = TuiTab::Profile; + $app->handleEvent(CharKeyEvent::new('2')); + + expect($app->activeTab)->toBe(TuiTab::Following); + + $app->activeTab = TuiTab::Profile; + $app->handleEvent(CharKeyEvent::new('4')); + + expect($app->activeTab)->toBe(TuiTab::Compose); + + $app->activeTab = TuiTab::Profile; + $app->handleEvent(CharKeyEvent::new('z')); + + expect($app->activeTab)->toBe(TuiTab::Profile); +}); + +it('navigates and interacts within thread view', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->for($user)->create([ + 'body' => 'Parent', + 'status' => CommitStatus::Approved, + ]); + $reply1 = Commit::factory()->for($user)->create([ + 'parent_id' => $parent->id, + 'body' => 'Reply 1', + 'status' => CommitStatus::Approved, + ]); + $reply2 = Commit::factory()->for($user)->create([ + 'parent_id' => $parent->id, + 'body' => 'Reply 2', + 'status' => CommitStatus::Approved, + ]); + + $app = new TuiApplication(collect([$reply1]), $user); + $app->openThread(); + + expect($app->activeTab)->toBe(TuiTab::Thread); + + // Navigate with Down / j + $app->handleEvent(CodedKeyEvent::new(KeyCode::Down)); + $app->handleEvent(CharKeyEvent::new('j')); + + // Navigate with Up / k + $app->handleEvent(CodedKeyEvent::new(KeyCode::Up)); + $app->handleEvent(CharKeyEvent::new('k')); + + // Star focused commit + $app->handleEvent(CharKeyEvent::new('s')); + + expect($app->statusMessage)->toContain('Starred commit'); + + // Reply to focused commit + $app->handleEvent(CharKeyEvent::new('r')); + expect($app->activeTab)->toBe(TuiTab::Compose); + + // Return to feed via Esc + $app->activeTab = TuiTab::Thread; + $app->handleEvent(CodedKeyEvent::new(KeyCode::Esc)); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + // Return to feed via Backspace + $app->activeTab = TuiTab::Thread; + $app->handleEvent(CodedKeyEvent::new(KeyCode::Backspace)); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + // Return to feed via q + $app->activeTab = TuiTab::Thread; + $app->handleEvent(CharKeyEvent::new('q')); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + // Return to feed via 1 + $app->activeTab = TuiTab::Thread; + $app->handleEvent(CharKeyEvent::new('1')); + + expect($app->activeTab)->toBe(TuiTab::Everyone); +}); + +it('handles all global feed keyboard shortcuts', function (): void { + $user = User::factory()->create(); + $commits = Commit::factory()->count(4)->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + + $app = new TuiApplication($commits, $user); + + $app->handleEvent(CharKeyEvent::new('g')); + + expect($app->selectedIndex)->toBe(0); + + $app->handleEvent(CharKeyEvent::new('G')); + expect($app->selectedIndex)->toBe(3); + + $app->handleEvent(CharKeyEvent::new('3')); + expect($app->activeTab)->toBe(TuiTab::Thread); + $app->activeTab = TuiTab::Everyone; + + $app->handleEvent(CharKeyEvent::new('p')); + + expect($app->activeTab)->toBe(TuiTab::Compose); + $app->activeTab = TuiTab::Everyone; + + $app->handleEvent(CharKeyEvent::new('r')); + + expect($app->activeTab)->toBe(TuiTab::Compose); + $app->activeTab = TuiTab::Everyone; + + $app->handleEvent(CharKeyEvent::new('s')); + + expect($app->statusMessage)->toContain('Starred commit'); + + $app->handleEvent(CharKeyEvent::new('u')); + expect($app->activeTab)->toBe(TuiTab::Profile); + $app->activeTab = TuiTab::Everyone; + + $app->handleEvent(CharKeyEvent::new('q')); + + expect($app->running)->toBeFalse(); + + $app->running = true; + $app->handleEvent(CodedKeyEvent::new(KeyCode::Esc)); + + expect($app->running)->toBeFalse(); + + $app->running = true; + $app->handleEvent(CodedKeyEvent::new(KeyCode::Enter)); + + expect($app->activeTab)->toBe(TuiTab::Thread); + $app->activeTab = TuiTab::Everyone; + + $app->handleEvent(CodedKeyEvent::new(KeyCode::Tab)); + + expect($app->activeTab)->toBe(TuiTab::Following); + + $app->handleEvent(CodedKeyEvent::new(KeyCode::BackTab)); + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->handleEvent(CharKeyEvent::new('1')); + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->handleEvent(CodedKeyEvent::new(KeyCode::Insert)); + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->handleEvent(CharKeyEvent::new('z')); + expect($app->activeTab)->toBe(TuiTab::Everyone); +}); + +it('handles empty feed edge cases cleanly', function (): void { + $user = User::factory()->create(); + $app = new TuiApplication(collect([]), $user); + + $app->moveSelection(1); + expect($app->selectedIndex)->toBe(0) + ->and($app->selectedCommit)->toBeNull(); + + $app->jumpSelection(5); + expect($app->selectedIndex)->toBe(0) + ->and($app->selectedCommit)->toBeNull(); + + $app->openThread(); + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->openAuthorProfile(null); + expect($app->activeTab)->toBe(TuiTab::Everyone); + + $app->toggleStar(); + expect($app->statusMessage)->toBeNull(); +}); + +it('cycles tabs bidirectionally with wrap-around', function (): void { + $user = User::factory()->create(); + $app = new TuiApplication(collect([]), $user); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + // Backward from index 0 wraps to Help + $app->cycleTab(-1); + expect($app->activeTab)->toBe(TuiTab::Help); + + // Forward from Help wraps to Everyone + $app->cycleTab(1); + expect($app->activeTab)->toBe(TuiTab::Everyone); +}); + +it('handles following feed and unauthenticated interactions', function (): void { + $guestApp = new TuiApplication(collect([])); + + $guestApp->loadFollowingFeed(); + + expect($guestApp->statusMessage)->toContain('Specify --user=@username'); + + $guestApp->toggleStar(Commit::factory()->create()); + expect($guestApp->statusMessage)->toContain('Cannot star'); + + $guestApp->openComposer(); + $guestApp->composeText = 'Valid commit message.'; + + $submitted = $guestApp->submitCommit(); + expect($submitted)->toBeFalse() + ->and($guestApp->statusMessage)->toContain('Cannot commit'); + + // Short commit text validation... + $user = User::factory()->create(); + $userApp = new TuiApplication(collect([]), $user); + $userApp->openComposer(); + $userApp->composeText = 'hi'; + + $submitted = $userApp->submitCommit(); + expect($submitted)->toBeFalse() + ->and($userApp->statusMessage)->toContain('at least 3 characters'); +}); + +it('handles compose tab special keys and length boundaries', function (): void { + $user = User::factory()->create(); + $app = new TuiApplication(collect([]), $user); + $app->openComposer(); + + // Tab key inserts 2 spaces + $app->handleEvent(CodedKeyEvent::new(KeyCode::Tab)); + + expect($app->composeText)->toBe(' '); + + // Enter key submits + $app->composeText = 'Posting from compose tab!'; + $app->handleEvent(CodedKeyEvent::new(KeyCode::Enter)); + + expect($app->activeTab)->toBe(TuiTab::Everyone); + + // Max length boundary: ignore chars beyond 280 + $app->openComposer(); + $app->composeText = str_repeat('a', 280); + $app->handleEvent(CharKeyEvent::new('b')); + + expect(mb_strlen($app->composeText))->toBe(280); +}); + +it('renders all edge cases and special states', function (): void { + $backend = DummyBackend::fromDimensions(100, 24); + $display = DisplayBuilder::default($backend)->build(); + + // 1. Empty Everyone feed + $emptyApp = new TuiApplication(collect([])); + $emptyApp->render($display); + expect($backend->toString())->toContain('No commits found in the log yet') + ->toContain('Select a commit from the feed'); + + // 2. Empty Following feed + $emptyApp->activeTab = TuiTab::Following; + $emptyApp->render($display); + + expect($backend->toString())->toContain('You are not following anyone'); + + // 3. Verified SSH author in commit details + $sshUser = User::factory()->create(['username' => 'sshdev']); + SshKey::factory()->for($sshUser)->create(); + $commit1 = Commit::factory()->for($sshUser)->create(['status' => CommitStatus::Approved]); + $commit2 = Commit::factory()->for($sshUser)->create(['status' => CommitStatus::Approved]); + + $sshApp = new TuiApplication(collect([$commit1, $commit2]), $sshUser); + $sshApp->render($display); + + expect($backend->toString())->toContain('[VERIFIED SSH]'); + + // 4. Empty thread view + $sshApp->activeTab = TuiTab::Thread; + $sshApp->render($display); + + expect($backend->toString())->toContain('No thread loaded'); + + // 5. Compose replying to a commit + $sshApp->openComposer($commit1); + $sshApp->render($display); + + expect($backend->toString())->toContain('Reply to @sshdev'); + + // 6. Profile view when user is null + $nullUserApp = new TuiApplication(collect([])); + $nullUserApp->activeTab = TuiTab::Profile; + $nullUserApp->render($display); + + expect($backend->toString())->toContain('No user selected'); + + // 7. Status bar with active message + $sshApp->statusMessage = 'Active status test'; + $sshApp->render($display); + + expect($backend->toString())->toContain('Active status test'); +}); + +it('safely handles non-integer model attributes in getIntAttribute', function (): void { + $backend = DummyBackend::fromDimensions(100, 24); + $display = DisplayBuilder::default($backend)->build(); + + $user = User::factory()->create(); + $commit1 = Commit::factory()->for($user)->create([ + 'status' => CommitStatus::Approved, + ]); + $commit1->setAttribute('stars_count', '42'); + $commit1->setAttribute('replies_count', false); + + $app = new TuiApplication(collect([$commit1]), $user); + $app->render($display); + + expect($backend->toString())->toContain('42★'); +}); + +it('maps Unix rank levels to terminal theme colors', function (int $level, AnsiColor $expectedColor): void { + $app = new TuiApplication(collect([])); + + expect($app->rankColor($level))->toBe($expectedColor); +})->with([ + [1, AnsiColor::Gray], + [2, AnsiColor::LightCyan], + [3, AnsiColor::LightBlue], + [4, AnsiColor::LightMagenta], + [5, AnsiColor::LightYellow], + [6, AnsiColor::LightRed], + [0, AnsiColor::LightRed], +]); + +it('builds interactive and headless terminal and display instances', function (): void { + $app = new TuiApplication(collect([])); + + $headlessTerm = $app->buildTerminal(false); + expect($headlessTerm)->toBeInstanceOf(PhpTermTerminal::class); + + $headlessDisplay = $app->buildDisplay($headlessTerm, false); + expect($headlessDisplay)->toBeInstanceOf(Display::class); + + try { + $interactiveTerm = $app->buildTerminal(true); + expect($interactiveTerm)->toBeInstanceOf(PhpTermTerminal::class); + + $interactiveDisplay = $app->buildDisplay($interactiveTerm, true); + expect($interactiveDisplay)->toBeInstanceOf(Display::class); + } finally { + if (defined('STDIN') && is_resource(STDIN)) { + stream_set_blocking(STDIN, true); + } + } +});