cool boot animation, done in WSL

This commit is contained in:
Isaac Johnson 2026-06-02 17:31:24 -05:00
parent 7a1006508c
commit 616685c9b2
6 changed files with 622 additions and 6 deletions

85
README.md Normal file
View File

@ -0,0 +1,85 @@
# Gitea TUI (fjtui)
A premium interactive CLI TUI Dashboard for exploring Gitea and Forgejo issues directly from your terminal.
## Features
- **Interactive TUI:** A full-featured terminal user interface using Node.js.
- **Multiple Screens:**
- **Setup Wizard:** Easy configuration and connection testing.
- **Repository Picker:** Browse and select repositories seamlessly.
- **Issue List:** View, filter, sort, and search issues in a repository.
- **Issue Details:** Read full issue descriptions and comments in a dedicated view.
- **Create Issue:** Draft and submit new issues directly from the terminal.
- **Add Comment:** Participate in issue discussions seamlessly.
- **Configuration Management:** Save your connection settings and tokens securely for future sessions.
- **Support for Forgejo & Gitea:** Built to integrate smoothly with Forgejo and Gitea APIs.
## Installation
Ensure you have Node.js (v20+) installed.
Clone the repository and install dependencies:
```bash
git clone <repository-url> fjtui
cd fjtui
npm install
```
Build the project:
```bash
npm run build
```
Link globally (optional):
```bash
npm link
```
You can then run the tool anywhere using `gitea-tui`.
## Usage
You can start the interactive setup wizard by simply running:
```bash
npm start
```
Or, if installed globally:
```bash
gitea-tui
```
### Direct Connection
You can also use command-line options to connect directly and bypass the setup wizard:
```bash
npm start -- --url "https://forgejo.example.com" --userid "myuser" --repo "myuser/myrepo" --token "my-access-token"
```
### Command-Line Options
- `-u, --url <url>`: Forgejo/Gitea instance base URL (e.g. `https://forgejo.freshbrewed.science`)
- `-i, --userid <userid>`: Forgejo/Gitea user ID / username
- `-r, --repo <owner/repo>`: Repository path (e.g. `owner/repo`)
- `-t, --token <token>`: Personal Access Token (optional for public repositories)
- `-s, --save`: Save the provided settings to the global config file (`~/.config/fjtui/fjtui.json`)
## Configuration
Settings can be saved interactively via the Setup Wizard or by passing the `-s` or `--save` flag in the CLI. The application configuration file is stored safely at `~/.config/fjtui/fjtui.json`.
## Development
- `npm run build`: Compiles the TypeScript code to JavaScript in the `dist/` directory.
- `npm start`: Runs the built `dist/index.js` file.
## Technologies Used
- **TypeScript** / **Node.js**
- **Commander:** Command-line argument parsing.
- **Chalk:** Terminal styling.
- **Axios:** API interactions.

226
SYSTEM.md Normal file
View File

@ -0,0 +1,226 @@
# Gitea TUI (fjtui) System Architecture
This document provides a comprehensive overview of the Gitea/Forgejo TUI Dashboard's system architecture, component layout, and user flows. It uses **Mermaid.js** diagrams to detail the application structure and page lifecycle transitions.
---
## 1. High-Level Architecture
The Gitea TUI client is a modular CLI application written in TypeScript and Node.js. It operates as a local state machine that communicates with a Gitea or Forgejo instance REST API over HTTPS.
```mermaid
graph TD
subgraph Client ["Gitea TUI Application Context"]
EP["index.ts (CLI Entry Point)"]
ConfigManager["config.ts (Configuration Manager)"]
TuiEngine["tui.ts (TuiEngine Controller)"]
APILayer["api.ts (API Service Layer)"]
Types["types.ts (State & Entity Types)"]
end
subgraph External ["External Resources"]
LocalConfig["Local Config (./fjtui.json)"]
GlobalConfig["Global Config (~/.config/fjtui/fjtui.json)"]
GiteaAPI["Gitea/Forgejo Instance REST API"]
end
subgraph IO ["Terminal User Interface"]
Stdin["stdin (Raw Keypress Listeners)"]
Stdout["stdout (Chalk Styled Rendering)"]
end
%% Key Relationships
EP -->|Reads Config / Parses Args| ConfigManager
EP -->|Direct Connect Check| APILayer
EP -->|Initializes & Starts| TuiEngine
ConfigManager <-->|Read / Write| LocalConfig
ConfigManager <-->|Read / Write| GlobalConfig
TuiEngine -->|Subscribes to Events| Stdin
TuiEngine -->|Paints Screens| Stdout
TuiEngine -->|Fetches & Mutates Data| APILayer
TuiEngine -->|Saves Config| ConfigManager
APILayer -->|HTTP / REST Requests (Axios)| GiteaAPI
TuiEngine -.->|Operates on AppState| Types
```
---
## 2. Screen & Page Transition Flow
The terminal application operates as a single-page terminal application (SPTA) with a central screen router state. Below is the transition flow (state machine) representing how keypress actions route the user between screens.
```mermaid
stateDiagram-v2
[*] --> Launch : Run gitea-tui (Bootstrap or Direct Connect)
Launch --> Setup : No config / finished or skipped
Launch --> List : Config exists / direct connect (finished or skipped)
state Setup {
[*] --> EnteringCredentials
EnteringCredentials --> Validating : Enter/Submit Form
Validating --> EnteringCredentials : Connection Error
}
Setup --> RepoPicker : Validated & Repositories Loaded
state RepoPicker {
[*] --> BrowsingRepos
BrowsingRepos --> BrowsingRepos : Filter / Search
}
RepoPicker --> Setup : Escape (Go Back)
RepoPicker --> List : Select Repo (Enter) & Load Issues
state List {
[*] --> DisplayingIssues
DisplayingIssues --> DisplayingIssues : Toggle Filters / Sort / Paginate / Search
}
List --> Setup : Change Connection ('o' key)
List --> CreateIssue : Create Issue ('c' key)
List --> Details : View Issue Details (Enter)
state CreateIssue {
[*] --> FillingIssueForm
FillingIssueForm --> SavingIssue : Ctrl+S (Submit)
SavingIssue --> DisplayingIssues : Success (List reloads)
SavingIssue --> FillingIssueForm : Failure (Shows error)
}
CreateIssue --> List : Escape (Cancel)
state Details {
[*] --> ViewingDetails
ViewingDetails --> ViewingDetails : Scroll description & comments
}
Details --> List : Escape / Backspace / Q (Go Back)
Details --> Setup : Change Connection ('o' key)
Details --> AddComment : Add Comment ('c' key)
Details --> EditIssue : Edit Issue ('e' key)
Details --> AddTime : Add Tracked Time ('t' key)
Details --> SetAssignees : Set Assignees ('a' key)
Details --> ConfirmStateChange : Toggle Issue State ('x' key)
state AddComment {
[*] --> EnteringComment
EnteringComment --> SubmittingComment : Ctrl+S (Submit)
SubmittingComment --> ViewingDetails : Success (Comments reload)
SubmittingComment --> EnteringComment : Failure (Shows error)
}
AddComment --> Details : Escape (Cancel)
state EditIssue {
[*] --> EditingIssueForm
EditingIssueForm --> SubmittingEdit : Ctrl+S (Submit)
SubmittingEdit --> ViewingDetails : Success (Details reload)
SubmittingEdit --> EditingIssueForm : Failure (Shows error)
}
EditIssue --> Details : Escape (Cancel)
state AddTime {
[*] --> EnteringTime
EnteringTime --> SubmittingTime : Ctrl+S (Submit)
SubmittingTime --> ViewingDetails : Success (Time reloads)
SubmittingTime --> EnteringTime : Failure (Shows error)
}
AddTime --> Details : Escape (Cancel)
state SetAssignees {
[*] --> EnteringAssignees
EnteringAssignees --> SubmittingAssignees : Enter (Submit)
SubmittingAssignees --> ViewingDetails : Success (Assignees reload)
SubmittingAssignees --> EnteringAssignees : Failure (Shows error)
}
SetAssignees --> Details : Escape (Cancel)
state ConfirmStateChange {
[*] --> AwaitingConfirmation
AwaitingConfirmation --> Details : No / Escape (Cancel)
AwaitingConfirmation --> AnimatingChange : Yes ('y' key)
state AnimatingChange {
[*] --> AnimatingClose : If Closing (Gravestones)
[*] --> AnimatingReopen : If Reopening (Zombies)
AnimatingClose --> ExecutingStateChangeAPI : Finish Frames (1.5s)
AnimatingReopen --> ExecutingStateChangeAPI : Finish Frames (1.8s)
}
ExecutingStateChangeAPI --> ViewingDetails : Done & Reloaded
}
```
---
## 3. Data Interaction & Rerender Lifecycle
When the user interacts with the interface, keyboard events are processed by the active screen key handler, state variables are updated, API calls are made if necessary, and a full screen redraw is triggered on the terminal buffer.
The following sequence diagram details the lifecyle of adding a comment to an issue:
```mermaid
sequenceDiagram
autonumber
actor User as User Terminal
participant TUI as TuiEngine (tui.ts)
participant API as API Layer (api.ts)
participant Gitea as Gitea REST API
User->>TUI: Keypress (e.g. 'c' on Details screen)
TUI->>TUI: Update screen state to 'add-comment'
TUI->>TUI: Render text input area (addCommentForm)
TUI-->>User: Display empty comment field
User->>TUI: Type comment text + Ctrl+S
TUI->>TUI: Set state.loading = true & render spinner
TUI->>API: createIssueComment(config, issueNumber, commentBody)
API->>Gitea: POST /repos/{owner}/{repo}/issues/{index}/comments
Gitea-->>API: 201 Created (Comment entity JSON)
API-->>TUI: Comment Object
TUI->>TUI: Set screen state to 'details'
TUI->>TUI: Trigger loadComments(issue) (Async)
TUI->>API: fetchIssueComments(config, issueNumber)
API->>Gitea: GET /repos/{owner}/{repo}/issues/{index}/comments
Gitea-->>API: 200 OK (Comments list JSON)
API-->>TUI: Comment[] Array
TUI->>TUI: Set state.loading = false
TUI->>TUI: Render details screen + updated comments
TUI-->>User: Display issue details and new comment
```
---
## 4. Module Directories & Code Structure
The project code is organized into a clean multi-file architecture located under [src](file:///wsl.localhost/Ubuntu/home/builder/Workspaces/fjtui/src):
1. **[index.ts](file:///wsl.localhost/Ubuntu/home/builder/Workspaces/fjtui/src/index.ts)**:
- Sets up Command-line parsing with the `commander` package.
- Automatically bootstraps the configuration and constructs the default `AppState`.
- Checks CLI credentials. If valid, validates them directly, bypassing the setup page and going straight to the issue dashboard.
- Instantiates the `TuiEngine` and starts the app loop.
2. **[tui.ts](file:///wsl.localhost/Ubuntu/home/builder/Workspaces/fjtui/src/tui.ts)**:
- Contains the core `TuiEngine` class which manages key listeners on `process.stdin` (in raw mode).
- Manages loading spinners and UI animations (Gravestones for closed issues and Zombies for reopened issues).
- Performs terminal buffer manipulation (e.g. entering alternate screen buffer `\x1B[?1049h`, hiding cursor `\x1B[?25l`).
- Delegates the rendering of specific screens using modular helper functions (e.g., `renderSetupScreen`, `renderListScreen`, `renderDetailsScreen`).
3. **[api.ts](file:///wsl.localhost/Ubuntu/home/builder/Workspaces/fjtui/src/api.ts)**:
- Hosts the REST API layer built on top of `axios`.
- Interacts with Forgejo/Gitea endpoints such as `/repos`, `/issues`, `/comments`, and `/times`.
- Handles network errors and formats them into user-friendly diagnostic messages.
4. **[config.ts](file:///wsl.localhost/Ubuntu/home/builder/Workspaces/fjtui/src/config.ts)**:
- Manages persistence of credentials.
- Reads configuration locally from `fjtui.json` and globally from `~/.config/fjtui/fjtui.json`. Local configurations take priority.
- Writes updated credentials back to the user's home configuration safely.
5. **[types.ts](file:///wsl.localhost/Ubuntu/home/builder/Workspaces/fjtui/src/types.ts)**:
- Declares the TypeScript contracts and interfaces representing the application state (`AppState`), target forms (`SetupForm`, `CreateIssueForm`, etc.), and returned entities (`User`, `Issue`, `Label`, `Comment`, `RepoItem`).
6. **ASCII Art Assets**:
- [ascii-art.txt](file:///wsl.localhost/Ubuntu/home/builder/Workspaces/fjtui/ascii-art.txt): ASCII art loaded at startup to perform the color-shifting launch animation.

55
ascii-art-2.txt Executable file
View File

@ -0,0 +1,55 @@
..
-.
:+.
.:=+***#****++++-.*+++: .++
.-+********#%%#**++++++%***- +*:
:+###%%%%@@@@@%%%%#%%%%%@@@@@@= .+*=
..............:#@%%%%%%%@*:%@@@+ :**+.
#@%%%#@% .=***.
=@@%%%@+ .+****.
=%%@@+ .+*####+.
-##@@+ .-********-
-**@@+ .:-+**#********.
-**%#*********#******.
-**********#*******+
=***********#*####*:
.+***********####*=.
=******#***++=:.
.+: -**@@+
:. -**@@+
-**@@+
.......:::::.......................
.::::::::::::-+#*************####@@@@@@@@@@@@@@@@@@@@:
.%@%*********#%#######%%%%%%%%%%@@@@@@@@@@@@@%#*=-:.
.*@@@@%##**####%%%######@@@@@@@@@@@@@@@@*
.+@@@@@@%###%%#******@@@@@@@@%=.. ...
.-+%@@@%%#******@@@@@@@+
::-******@@@@@@=
.******@@@@@@.
:******@@@@@@=
-*****##@@@@@@@=
.-+**#@@@@@@@@%%#%%#=.
*%%%%%%@@@@@@@@@@%%#%@@@@@@#
:--+****************++++******+--:

55
ascii-art.txt Executable file
View File

@ -0,0 +1,55 @@
.
=
.-==+*+===---: +--- +=
-++++++++*##*++-----:#=== =+
:+**##%%%@@@%####*%####@@%%%%: +*-
*%######%@+ %@@@- +*=
*%###*%# -**+
.%%##%@: =***+
.##@@- =*****=
.++@@- :********.
.++@@- .=+*********+
.++%*+++++**********+
.++++++++**********-
-++++++***********+
=++++++++*******+:
-+++++**+++=-:
+ ++@@-
.++@@-
.++@@-
.-+++++++++++*******@@@@@@@@@@@@@@@@@@@%
#@#+++++++++*****#***#####%%%%%@@@@@@@@@@@@%#*=:.
=@@@@#**++****###******@@@@@@@@@@@@@@@@=
-@@@@@@#***##***++++@@@@@@@@%:
.-#@@%##*++++++@@@@@@@:
.++++++@@@@@@.
++++++@@@@@@
++++++@@@@@@:
.++++***@@@@@@@:
.=++*%@@@@@@@%#*##*:
+%%%%%%@@@@@@@@@@%#+%%%%%%%*
-=================---======-

View File

@ -1,5 +1,8 @@
import readline from 'readline';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { AppState, Issue, Comment } from './types.js';
import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime, changeIssueState, setIssueAssignees } from './api.js';
import { saveGlobalConfig } from './config.js';
@ -191,6 +194,11 @@ export class TuiEngine {
private searchInputBuffer: string = '';
private animationFrame: number = 0;
private animationInterval: NodeJS.Timeout | null = null;
private launchDestScreen: 'list' | 'setup' = 'setup';
private launchFrame: string[] = [];
private launchFrameIndex = 0;
private launchInterval: NodeJS.Timeout | null = null;
private maxLaunchFrames = 60;
constructor(initialState: AppState) {
this.state = initialState;
@ -217,13 +225,15 @@ export class TuiEngine {
// Enter alternate screen buffer and hide standard cursor
process.stdout.write('\x1B[?1049h\x1B[?25l');
// Bootstrap if config exists
// Bootstrap destination
if (this.state.config.url && this.state.config.owner && this.state.config.repo) {
this.state.screen = 'list';
this.loadIssues();
this.launchDestScreen = 'list';
} else {
this.render();
this.launchDestScreen = 'setup';
}
this.state.screen = 'launch';
this.startLaunchAnimation();
}
/**
@ -235,6 +245,10 @@ export class TuiEngine {
clearInterval(this.animationInterval);
this.animationInterval = null;
}
if (this.launchInterval) {
clearInterval(this.launchInterval);
this.launchInterval = null;
}
// Exit alternate screen buffer and show standard cursor
process.stdout.write('\x1B[?1049l\x1B[?25h');
if (process.stdin.isTTY) {
@ -361,6 +375,11 @@ export class TuiEngine {
return;
}
if (this.state.screen === 'launch') {
this.skipLaunchAnimation();
return;
}
if (this.state.screen === 'setup') {
this.handleSetupKeypress(str, key);
} else if (this.state.screen === 'repo-picker') {
@ -878,7 +897,9 @@ export class TuiEngine {
// Clear terminal screen, clear scrollback buffer, and reset cursor position
process.stdout.write('\x1B[2J\x1B[3J\x1B[H');
if (this.state.screen === 'setup') {
if (this.state.screen === 'launch') {
this.renderLaunchScreen(cols, rows);
} else if (this.state.screen === 'setup') {
this.renderSetupScreen(cols, rows);
} else if (this.state.screen === 'repo-picker') {
this.renderRepoPickerScreen(cols, rows);
@ -2204,4 +2225,178 @@ export class TuiEngine {
this.render();
}
}
// LAUNCH ANIMATION IMPLEMENTATION
private startLaunchAnimation() {
this.launchFrameIndex = 0;
this.render();
this.launchInterval = setInterval(() => {
this.launchFrameIndex++;
this.render();
if (this.launchFrameIndex >= this.maxLaunchFrames - 1) {
this.skipLaunchAnimation();
}
}, 60); // 60ms interval for fast, smooth fluid color shifting
}
private skipLaunchAnimation() {
if (this.launchInterval) {
clearInterval(this.launchInterval);
this.launchInterval = null;
}
// Transition to target destination screen
if (this.launchDestScreen === 'list') {
this.state.screen = 'list';
this.loadIssues();
} else {
this.state.screen = 'setup';
this.render();
}
}
private loadLaunchFrames() {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve path to the root folder where ascii-art.txt is located
const artPath = path.resolve(__dirname, '..', 'ascii-art.txt');
if (fs.existsSync(artPath)) {
const rawArt = fs.readFileSync(artPath, 'utf8').split(/\r?\n/);
this.launchFrame = this.preprocessArt(rawArt);
} else {
// Fallback frames if file not found
this.launchFrame = [
" ( ( ",
" ) ) ",
" [===] ",
" \\___/ "
];
}
} catch (e) {
this.launchFrame = [
" ( ( ",
" ) ) ",
" [===] ",
" \\___/ "
];
}
}
private preprocessArt(lines: string[]): string[] {
let startIdx = 0;
while (startIdx < lines.length && lines[startIdx].trim() === '') {
startIdx++;
}
let endIdx = lines.length - 1;
while (endIdx >= 0 && lines[endIdx].trim() === '') {
endIdx--;
}
if (startIdx > endIdx) return [];
const activeLines = lines.slice(startIdx, endIdx + 1);
let minPadding = Infinity;
for (const line of activeLines) {
if (line.trim() === '') continue;
const match = line.match(/^[ \t]*/);
const padding = match ? match[0].length : 0;
if (padding < minPadding) {
minPadding = padding;
}
}
if (minPadding === Infinity || minPadding === 0) return activeLines;
return activeLines.map(line => line.length >= minPadding ? line.substring(minPadding) : '');
}
private renderLaunchScreen(cols: number, rows: number) {
if (this.launchFrame.length === 0) {
this.loadLaunchFrames();
}
const frame = this.launchFrame;
if (frame.length === 0) return;
const W = Math.max(...frame.map(line => line.length));
const H = frame.length;
const padLeftCount = Math.max(0, Math.floor((cols - W) / 2));
const padTopCount = Math.max(0, Math.floor((rows - H - 5) / 2));
for (let i = 0; i < padTopCount; i++) {
console.log('');
}
// HSL helper function to generate HSL-based hex colors
const hslToHex = (h: number, s: number, l: number): string => {
l /= 100;
const a = (s * Math.min(l, 1 - l)) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
};
const step = this.launchFrameIndex;
for (let i = 0; i < frame.length; i++) {
const line = frame[i];
const leftPad = ' '.repeat(padLeftCount);
let coloredLine = '';
if (i < 20) {
// Steam gradient: waving light intensity using sine wave based on time and row index
const factor = i / 20; // 0 to 1
// Wave lightness of the steam
const wave = Math.sin(step * 0.4 - i * 0.3);
const lightness = Math.max(10, Math.min(80, Math.round(25 + wave * 15 + factor * 25)));
// Soft cyan/blue steam hue = 195
const hex = hslToHex(195, 30, lightness);
coloredLine = chalk.hex(hex)(line);
} else {
// Cup body gradient: shifting rainbow wave
const rowOffset = i - 20;
// Cycle the hue over time and rows
const hue = (step * 8 + rowOffset * 12) % 360;
const hex = hslToHex(hue, 95, 55);
coloredLine = chalk.hex(hex).bold(line);
}
console.log(leftPad + coloredLine);
}
console.log('');
const titlePlain = ' G I T E A & F O R G E J O T U I ';
const subtitlePlain = 'Interactive Issue & Pull Request Explorer';
// Cycle the title box background color too!
const titleHue = (step * 6) % 360;
const titleColor = hslToHex(titleHue, 95, 50);
const titleLeftPad = Math.max(0, Math.floor((cols - titlePlain.length) / 2));
const subtitleLeftPad = Math.max(0, Math.floor((cols - subtitlePlain.length) / 2));
console.log(' '.repeat(titleLeftPad) + chalk.bold.bgHex(titleColor).black(titlePlain));
console.log(' '.repeat(subtitleLeftPad) + chalk.gray(subtitlePlain));
console.log('');
const skipPlain = 'Press any key to skip animation';
const skipLeftPad = Math.max(0, Math.floor((cols - skipPlain.length) / 2));
console.log(' '.repeat(skipLeftPad) + chalk.gray.italic(skipPlain));
const printedRows = H + padTopCount + 5;
const remainingRows = Math.max(0, rows - printedRows - 1);
for (let i = 0; i < remainingRows; i++) {
console.log('');
}
}
}

View File

@ -70,7 +70,7 @@ export interface AddTimeForm {
timeInput: string;
}
export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees';
export type ScreenType = 'launch' | 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees';
export interface RepoItem {