plugin-gentleman 1.0.4 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Plugin Gentleman
2
2
 
3
- > **Mustachi** — An animated ASCII mascot bringing personality to your OpenCode terminal.
3
+ > **For the Gentleman Programming community** — Bringing Mustachi, our beloved mascot, into your OpenCode terminal.
4
4
 
5
- An OpenCode TUI plugin that adds visual flair and environment awareness to your coding sessions:
5
+ An OpenCode TUI plugin crafted for the Gentleman Programming community. Mustachi, the official mascot of Gentleman Programming, now accompanies you through your coding sessions with visual flair and environment awareness:
6
6
 
7
7
  - 🎭 **Prominent ASCII mustache** on the home screen
8
8
  - 👤 **Full Mustachi face** with eyes in the sidebar
@@ -86,16 +86,19 @@ opencode plugin ./plugin-gentleman-<version>.tgz --global
86
86
 
87
87
  **Sidebar:**
88
88
  - Full Mustachi face with eyes and mustache
89
- - Animated eyes that occasionally look around *(when animations enabled)*
89
+ - Animated blink, random look-around in 8 directions *(when animations enabled)*
90
90
  - Tongue and motivational phrases during busy states *(when animations enabled)*
91
+ - Periodic expressive cycle every 45-60s to showcase animations *(when animations enabled)*
91
92
 
92
93
  ### The Mustachi Mascot
93
94
 
94
- An ASCII character with personality:
95
- - **Eyes** that occasionally look in different directions *(sidebar only)*
96
- - **Mustache** rendered in theme colors *(both home and sidebar)*
97
- - **Tongue** that appears during busy/loading states *(sidebar only)*
98
- - **Motivational phrases** in Rioplatense Spanish style *(sidebar only)*
95
+ **Mustachi** is the official mascot of the Gentleman Programming community — not something invented just for this plugin, but a character beloved by the community and now integrated into your coding environment.
96
+
97
+ The ASCII representation features:
98
+ - **Eyes** that blink and look in 8 directions (center, up, down, left, right, and 4 diagonals) *(sidebar only)*
99
+ - **Mustache** rendered in grayscale gradient on home screen, semantic zone colors in sidebar
100
+ - **Tongue** that appears during busy states or periodic expressive cycles *(sidebar only)*
101
+ - **Motivational phrases** in Rioplatense Spanish style — 2-3 random phrases rotating every 3s *(sidebar only)*
99
102
 
100
103
  **Example phrases during busy states:**
101
104
  - *"Ponete las pilas, hermano..."*
@@ -109,19 +112,33 @@ An ASCII character with personality:
109
112
 
110
113
  **Low complexity, low frequency** — subtle and non-intrusive:
111
114
 
112
- **Eye Direction** *(when `animations: true`)*
113
- - Every ~4 seconds, eyes may look in a random direction
114
- - 20% chance of movement per interval
115
- - Returns to neutral position most of the time
115
+ **Blink** *(when `animations: true`)*
116
+ - Natural eyelid motion: open half closed half → open
117
+ - Progressive top-to-bottom animation (80-100ms per frame)
118
+ - ~35% chance every 2s (average ~5-6 seconds between blinks)
116
119
 
117
- **Busy State** *(when `animations: true`)*
118
- - Tongue appears when OpenCode is processing
119
- - Motivational phrase rotates every 3 seconds
120
- - Only active during detected busy states
120
+ **Eye Direction** *(when `animations: true`)*
121
+ - Every ~3 seconds, eyes randomly look in one of 8 directions or stay center
122
+ - 60% chance to stay centered, 40% chance to look around
123
+ - Supports: up, down, left, right, and 4 diagonal positions
124
+ - Returns to center frequently for natural feel
125
+
126
+ **Busy/Expressive State** *(when `animations: true`)*
127
+ - Tongue appears progressively when OpenCode is processing
128
+ - Eyes squint during expressive state
129
+ - 2-3 random motivational phrases rotating every 3 seconds (36+ phrase library)
130
+ - Active during detected busy states OR periodic expressive cycles
131
+
132
+ **Expressive Cycle Fallback** *(when `animations: true`)*
133
+ - First cycle: 30-45s after load
134
+ - Subsequent cycles: every 45-60s
135
+ - Duration: 8 seconds per cycle
136
+ - Ensures tongue + phrases are visible even if runtime busy detection is unreliable
121
137
 
122
138
  **Disabled** *(when `animations: false`)*
123
139
  - Mustachi stays in neutral position
124
- - No eye movement, tongue, or phrases
140
+ - No blink, no eye movement, no tongue, no phrases
141
+ - Completely static face
125
142
 
126
143
  ### Gentleman Theme
127
144
 
@@ -131,10 +148,16 @@ A refined dark color palette:
131
148
  - **Accent:** Warm gold (`#E0C15A`)
132
149
  - **Text:** Clean white (`#F3F6F9`)
133
150
 
134
- **Mustachi gradient** (3-tone):
135
- - **Top:** Accent gold
136
- - **Middle:** Primary blue
137
- - **Bottom:** Error pink (`#CB7C94`)
151
+ **Home screen mustache:** 3-tone grayscale gradient for better readability
152
+ - **Top:** Light gray (`#C0C0C0`)
153
+ - **Middle:** Mid gray (`#808080`)
154
+ - **Bottom:** Dark gray (`#505050`)
155
+
156
+ **Sidebar Mustachi:** Semantic zone colors for visual clarity
157
+ - **Monocle:** Soft silver (`#B8B8B8`)
158
+ - **Eyes:** Mid gray (`#808080`)
159
+ - **Mustache:** Dark gray (`#606060`)
160
+ - **Tongue:** Pink/red (`#FF4466`)
138
161
 
139
162
  ### Environment Detection
140
163
 
@@ -148,7 +171,9 @@ Both are fully configurable and can be hidden.
148
171
 
149
172
  ## Configuration
150
173
 
151
- All options are configured via plugin tuple syntax in `opencode.json`.
174
+ All options are configured via plugin tuple syntax in `~/.config/opencode/opencode.json`.
175
+
176
+ **Quick tip:** To disable animations, add `{ "animations": false }` to your plugin config (see examples below).
152
177
 
153
178
  ### Default Settings
154
179
 
@@ -188,6 +213,8 @@ All options are configured via plugin tuple syntax in `opencode.json`.
188
213
 
189
214
  **Disable Animations:**
190
215
 
216
+ If you prefer a completely static Mustachi (no eye movement, no busy-state tongue/phrases):
217
+
191
218
  ```json
192
219
  {
193
220
  "$schema": "https://opencode.ai/config.json",
@@ -197,7 +224,12 @@ All options are configured via plugin tuple syntax in `opencode.json`.
197
224
  }
198
225
  ```
199
226
 
200
- Mustachi will remain static with neutral eyes and no busy-state expressions.
227
+ This sets `animations: false`, which:
228
+ - Disables blink animation
229
+ - Keeps eyes in neutral center position (no looking around)
230
+ - Hides tongue and motivational phrases during busy states
231
+ - Disables periodic expressive cycles
232
+ - Mustachi remains completely static
201
233
 
202
234
  **Logo Only (No Detection):**
203
235
 
@@ -238,18 +270,36 @@ The plugin integrates with OpenCode's TUI system through slot registration:
238
270
 
239
271
  1. **Theme Installation:** Installs `gentleman.json` into OpenCode themes on load
240
272
  2. **Theme Activation:** Switches to the gentleman theme *(if `set_theme: true`)*
241
- 3. **Home Logo Slot:** Registers `home_logo` with mustache-only ASCII art
273
+ 3. **Home Logo Slot:** Registers `home_logo` with mustache-only ASCII art (grayscale gradient)
242
274
  4. **Environment Detection Slot:** Registers `home_bottom` with OS/provider info below the prompt
243
275
  5. **Sidebar Slot:** Registers `sidebar_content` with full Mustachi face and animations
244
- 6. **Animation Loop:** Starts interval timers for eye variations and busy-state detection *(if `animations: true`, sidebar only)*
245
- 7. **Busy State Detection:** Attempts to read `api.state.session.running` *(best-effort; may not be exposed by all OpenCode versions)*
276
+ 6. **Animation Loops:** Starts independent interval timers for:
277
+ - Blink animation (~5-6s average interval)
278
+ - Random eye look-around (~3s interval)
279
+ - Phrase rotation during expressive states (3s interval)
280
+ - Periodic expressive cycles (45-60s interval)
281
+ *(All sidebar-only, when `animations: true`)*
282
+ 7. **Busy State Detection:** Attempts to read `api.state.session.running` for reactive busy state *(best-effort; may not be exposed by all OpenCode versions)*
283
+ 8. **Expressive Cycle Fallback:** If busy detection is unreliable, periodic cycles ensure animations are visible
246
284
 
247
285
  ### Technical Stack
248
286
 
249
287
  - **No Build Step:** Plain TSX transpiled at runtime by OpenCode
250
- - **Solid.js Reactivity:** Uses `createSignal` and `createEffect` for animations
288
+ - **Solid.js Reactivity:** Uses `createSignal` and `createEffect` for all animations
251
289
  - **Safe Detection:** All OS/provider detection wrapped in try-catch blocks
252
- - **Cleanup:** Uses `onCleanup` to clear intervals when component unmounts
290
+ - **Cleanup:** Uses `onCleanup` to clear all intervals when components unmount
291
+ - **Multi-file Architecture:** Separated concerns for maintainability (see Architecture below)
292
+
293
+ ### Architecture
294
+
295
+ The plugin is structured as a multi-file module for maintainability and clarity:
296
+
297
+ - **`tui.tsx`** — Plugin entry point. Handles initialization, theme setup, slot registration, and busy state detection.
298
+ - **`components.tsx`** — All UI components (`HomeLogo`, `SidebarMustachi`, `DetectedEnv`). Contains all animation logic with Solid.js signals and effects (blink, look-around, expressive cycle).
299
+ - **`ascii-frames.ts`** — All ASCII art assets: 9 eye position frames, 3 blink frames, squinted eyes, mustache designs, 3 tongue frames, and zone color definitions.
300
+ - **`phrases.ts`** — Library of 36+ motivational phrases (Rioplatense Spanish style) shown during expressive states.
301
+ - **`config.ts`** — Configuration parsing with type-safe defaults and validation helpers.
302
+ - **`detection.ts`** — OS detection (reads `/etc/os-release` on Linux) and provider name mapping.
253
303
 
254
304
  ---
255
305
 
@@ -292,7 +342,12 @@ If you copy `.ts` files to `~/.config/opencode/plugins/` (system plugin):
292
342
  ### Package Contents
293
343
 
294
344
  **Files included in npm package** *(via `files` field in `package.json`)*:
295
- - `tui.tsx` — main plugin implementation
345
+ - `tui.tsx` — plugin entry point and slot registration
346
+ - `components.tsx` — UI components (HomeLogo, SidebarMustachi, DetectedEnv)
347
+ - `ascii-frames.ts` — all ASCII art frames, eye positions, and color definitions
348
+ - `phrases.ts` — motivational phrases library for busy states
349
+ - `config.ts` — configuration parsing helpers and type definitions
350
+ - `detection.ts` — OS and provider detection utilities
296
351
  - `gentleman.json` — theme definition
297
352
  - `package.json` — auto-included by npm
298
353
  - `README.md` — auto-included by npm
@@ -305,12 +360,18 @@ If you copy `.ts` files to `~/.config/opencode/plugins/` (system plugin):
305
360
 
306
361
  ## Known Limitations
307
362
 
308
- 1. **Busy State Detection:** The plugin attempts to detect busy states via `api.state.session.running`, but this may not be exposed in all OpenCode versions. If unavailable, busy-state animations won't trigger *(eye animations still work in sidebar)*.
363
+ 1. **Busy State Detection:** The plugin attempts to detect busy states via `api.state.session.running`, but this may not be exposed in all OpenCode versions. If unavailable, a periodic expressive cycle fallback (every 45-60s) ensures animations remain visible.
309
364
 
310
- 2. **Animation Frequency:** Currently set to low frequency (4s for eyes, 3s for phrase rotation). Adjust the intervals in `tui.tsx` if needed.
365
+ 2. **Animation Frequency:** Current timing intervals:
366
+ - Blink: ~5-6 seconds average (35% chance every 2s)
367
+ - Look-around: 3 seconds (60% center, 40% random direction)
368
+ - Phrase rotation: 3 seconds during expressive state
369
+ - Expressive cycle: first at 30-45s, then every 45-60s
370
+
371
+ To adjust, modify the intervals in `components.tsx` (lines 79, 107, 168, 193-198).
311
372
 
312
373
  3. **Slot Usage:** The plugin uses these OpenCode TUI slots:
313
- - `home_logo` — mustache-only ASCII art
374
+ - `home_logo` — mustache-only ASCII art (grayscale gradient)
314
375
  - `home_bottom` — environment detection (OS + providers)
315
376
  - `sidebar_content` — full Mustachi face with animations
316
377
 
@@ -322,23 +383,39 @@ If you copy `.ts` files to `~/.config/opencode/plugins/` (system plugin):
322
383
 
323
384
  ### Modifying the Plugin
324
385
 
325
- To work on the plugin:
386
+ The plugin is organized into focused modules for easy customization:
387
+
388
+ **Adding new content:**
389
+
390
+ - **Motivational phrases:** Edit `phrases.ts` — add new phrases to the `busyPhrases` array (currently 36+ phrases)
391
+ - **ASCII art frames:** Edit `ascii-frames.ts` — modify eye positions (9 variants), blink frames (3 stages), mustache designs, or tongue frames (3 growth stages)
392
+ - **UI logic:** Edit `components.tsx` — adjust animation timings, add new effects, or tweak component layout
393
+ - **Configuration:** Edit `config.ts` — add new config options with type-safe defaults
394
+ - **Detection logic:** Edit `detection.ts` — add new OS detection patterns or provider mappings
395
+ - **Plugin behavior:** Edit `tui.tsx` — modify slot registration, initialization flow, or busy detection
396
+
397
+ **Testing locally:**
326
398
 
327
- 1. Edit `tui.tsx` (main implementation)
328
- 2. Test locally with `npm link` or `npm pack`
399
+ 1. Make your changes in the appropriate file(s)
400
+ 2. Test with `npm link` or `npm pack` (see "For Developers" section above)
329
401
  3. No build step needed — OpenCode transpiles TSX at runtime
330
402
  4. Restart OpenCode to see changes
331
403
 
332
- ### Adding New Content
404
+ **Animation timing customization:**
333
405
 
334
- **Eye variations:**
335
- - Edit the `eyeVariations` array in `tui.tsx` *(affects sidebar only)*
406
+ All animation intervals are in `components.tsx`:
407
+ - **Look-around interval:** Line 79 currently 3000ms (3s)
408
+ - **Blink interval:** Line 107 — currently 2000ms with 35% chance (~5-6s average)
409
+ - **Blink frame timing:** Lines 91-98 — currently 80-100ms per frame progression
410
+ - **Phrase rotation:** Line 168 — currently 3000ms (3s) during expressive state
411
+ - **Expressive cycle timing:** Lines 193-198 — first cycle at 30-45s, then every 45-60s
412
+ - **Expressive cycle duration:** Line 189 — currently 8000ms (8s)
336
413
 
337
- **Busy phrases:**
338
- - Edit the `busyPhrases` array in `tui.tsx` *(affects sidebar only)*
414
+ **Color customization:**
339
415
 
340
- **Animation timings:**
341
- - Adjust `setInterval` durations in the `SidebarMustachi` component
416
+ - **Zone colors (sidebar):** Edit `zoneColors` object in `ascii-frames.ts` (monocle, eyes, mustache, tongue)
417
+ - **Home grayscale gradient:** Edit `HomeLogo` component in `components.tsx` (lines 22-24)
418
+ - **Theme colors:** Edit `gentleman.json` for the full color palette
342
419
 
343
420
  ---
344
421
 
@@ -0,0 +1,163 @@
1
+ // ASCII art frames and animation data
2
+
3
+ // Premium Mustachi ASCII art - structured by semantic zones
4
+ // Each eye state is a complete frame to avoid partial replacements
5
+
6
+ // Eye frames - neutral state with different pupil positions
7
+ // All lines are padded to 27 chars for perfect alignment with mustache
8
+
9
+ export const eyeNeutralCenter = [
10
+ " █████ █████ ", // 27 chars
11
+ " ██░░░░░██ ██░░░░░██ ", // 27 chars
12
+ " ██░░███░░██ ██░░░░░░░██ ", // 27 chars
13
+ " ██░░███░░██ ██░░░░░░░██ ", // 27 chars
14
+ "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
15
+ ]
16
+
17
+ export const eyeNeutralUp = [
18
+ " █████ █████ ", // 27 chars
19
+ " ██████░██ ██░░░░░██ ", // 27 chars - pupils up
20
+ " ██░█████░██ ██░░░░░░░██ ", // 27 chars - pupils up
21
+ " ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
22
+ "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
23
+ ]
24
+
25
+ export const eyeNeutralDown = [
26
+ " █████ █████ ", // 27 chars
27
+ " ██░░░░░██ ██░░░░░██ ", // 27 chars
28
+ " ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
29
+ " ██░░███░░██ ██░░░░░░░██ ", // 27 chars - pupils down
30
+ "██ ██████░░██ ██░░░░░██ ██", // 27 chars - pupils down
31
+ ]
32
+
33
+ export const eyeNeutralLeft = [
34
+ " █████ █████ ", // 27 chars
35
+ " ██░░░░░██ ██░░░░░██ ", // 27 chars
36
+ " ██████░░░██ ██░░░░░░░██ ", // 27 chars
37
+ " ██████░░░██ ██░░░░░░░██ ", // 27 chars
38
+ "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
39
+ ]
40
+
41
+ export const eyeNeutralRight = [
42
+ " █████ █████ ", // 27 chars
43
+ " ██░░░░░██ ██░░░░░██ ", // 27 chars
44
+ " ██░░░██████ ██░░░░░░░██ ", // 27 chars
45
+ " ██░░░██████ ██░░░░░░░██ ", // 27 chars
46
+ "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
47
+ ]
48
+
49
+ export const eyeNeutralUpLeft = [
50
+ " █████ █████ ", // 27 chars
51
+ " ███████░██ ██░░░░░██ ", // 27 chars - up-left diagonal
52
+ " ████████░██ ██░░░░░░░██ ", // 27 chars
53
+ " ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
54
+ "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
55
+ ]
56
+
57
+ export const eyeNeutralUpRight = [
58
+ " █████ █████ ", // 27 chars
59
+ " ██░░░░░██ ██░██████ ", // 27 chars - up-right diagonal
60
+ " ██░░░░░░░██ ██░████████ ", // 27 chars
61
+ " ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
62
+ "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
63
+ ]
64
+
65
+ export const eyeNeutralDownLeft = [
66
+ " █████ █████ ", // 27 chars
67
+ " ██░░░░░██ ██░░░░░██ ", // 27 chars
68
+ " ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
69
+ " ███████░░██ ██░░░░░░░██ ", // 27 chars - down-left diagonal
70
+ "██ ████████░█ ██░░░░░██ ██", // 27 chars
71
+ ]
72
+
73
+ export const eyeNeutralDownRight = [
74
+ " █████ █████ ", // 27 chars
75
+ " ██░░░░░██ ██░░░░░██ ", // 27 chars
76
+ " ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
77
+ " ██░░░███████ ██░░░░░░░██ ", // 27 chars - down-right diagonal
78
+ "██ ██░░░░░██ █████████ ██", // 27 chars
79
+ ]
80
+
81
+ // Squinted eyes version for busy/expressive state
82
+ export const eyeSquinted = [
83
+ " █████ █████ ", // 27 chars
84
+ " ██░░░░░██ ██░░░░░██ ", // 27 chars
85
+ " ██░░███░░██ ██░░░░░░░██ ", // 27 chars
86
+ " █████████ █████████ ", // 27 chars
87
+ "██ █████ █████ ██", // 27 chars
88
+ ]
89
+
90
+ // Blink frames - half closed (upper eyelid descending from top)
91
+ export const eyeBlinkHalf = [
92
+ " █████ █████ ", // 27 chars - monocle border top unchanged
93
+ " ██████████ ██████████ ", // 27 chars - upper eyelid descends halfway
94
+ " ██░░███░░██ ██░░░░░░░██ ", // 27 chars - pupils still visible
95
+ " ██░░███░░██ ██░░░░░░░██ ", // 27 chars - pupils still visible
96
+ "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars - bottom unchanged
97
+ ]
98
+
99
+ // Blink frames - fully closed (upper eyelid fully descended)
100
+ export const eyeBlinkClosed = [
101
+ " █████ █████ ", // 27 chars - monocle border top unchanged
102
+ " ██████████ ██████████ ", // 27 chars - upper eyelid down
103
+ " ██████████ ██████████ ", // 27 chars - eyes fully closed
104
+ " █████████ █████████ ", // 27 chars - bottom transition
105
+ "██ █████ █████ ██", // 27 chars - bottom unchanged
106
+ ]
107
+
108
+ // Mustache section (all lines padded to 27 chars for alignment)
109
+ export const mustachiMustacheSection = [
110
+ "██████████ █████████", // 27 chars
111
+ "████████████ ███████████", // 27 chars
112
+ " ██████████████████████████ ", // 27 chars
113
+ " ▓██████████ █████████▓", // 27 chars
114
+ " ▓██████ ██████▓ ", // 27 chars
115
+ ]
116
+
117
+ // Tongue animation frames (progressive) - compact design
118
+ export const tongueFrames = [
119
+ [], // no tongue
120
+ [" ███", " █"], // tongue out
121
+ ]
122
+
123
+ // Mustache-only ASCII art for home logo (original massive solid block design)
124
+ export const mustachiMustacheOnly = [
125
+ "",
126
+ " ████████ ████████",
127
+ " ████████████ ████████████",
128
+ " ██ ████████████████ ████████████████ ██",
129
+ " ████ ████████████████████ ████████████████████ ████",
130
+ " ██████ ███████████████████████████████████████████ ██████",
131
+ " ███████████████████████████████████████████████████████████",
132
+ " ███████████████████████████████████████████████████████████",
133
+ " ███████████████████████████████████████████████████████████",
134
+ " █████████████████████████████████████████████████████████",
135
+ " ███████████████████████████████████████████████████████",
136
+ " ▓▓█████████████████████ █████████████████████▓▓",
137
+ " ▓▓▓███████████████ ███████████████▓▓▓",
138
+ " ▓▓▓█████████ █████████▓▓▓",
139
+ " ▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓",
140
+ "",
141
+ ]
142
+
143
+ // Pupil position mapping for look-around animation
144
+ // All possible eye directions for random transitions
145
+ export const pupilPositionFrames = [
146
+ eyeNeutralCenter, // center (most common)
147
+ eyeNeutralUp, // up
148
+ eyeNeutralDown, // down
149
+ eyeNeutralLeft, // left
150
+ eyeNeutralRight, // right
151
+ eyeNeutralUpLeft, // up-left diagonal
152
+ eyeNeutralUpRight, // up-right diagonal
153
+ eyeNeutralDownLeft, // down-left diagonal
154
+ eyeNeutralDownRight, // down-right diagonal
155
+ ]
156
+
157
+ // Semantic zone colors for better visual hierarchy
158
+ export const zoneColors = {
159
+ monocle: "#B8B8B8", // Subtle steel/silver for monocle border (distinct from eye color)
160
+ eyes: "#808080", // Mid gray for eyes
161
+ mustache: "#606060", // Darker gray for mustache
162
+ tongue: "#FF4466", // Pink/Red for tongue
163
+ }
package/components.tsx ADDED
@@ -0,0 +1,294 @@
1
+ // @ts-nocheck
2
+ /** @jsxImportSource @opentui/solid */
3
+ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
4
+ import { createSignal, onCleanup, createEffect } from "solid-js"
5
+ import type { Cfg } from "./config"
6
+ import { getOSName, getProviders } from "./detection"
7
+ import {
8
+ pupilPositionFrames,
9
+ eyeSquinted,
10
+ eyeBlinkHalf,
11
+ eyeBlinkClosed,
12
+ mustachiMustacheSection,
13
+ tongueFrames,
14
+ mustachiMustacheOnly,
15
+ zoneColors,
16
+ } from "./ascii-frames"
17
+ import { busyPhrases } from "./phrases"
18
+
19
+ // Home logo: Mustache-only (simple and prominent) with grayscale gradient
20
+ export const HomeLogo = (props: { theme: TuiThemeCurrent }) => {
21
+ // Grayscale palette for better TUI readability
22
+ const lightGray = "#C0C0C0" // Light gray for highlights
23
+ const midGray = "#808080" // Mid gray for main body
24
+ const darkGray = "#505050" // Dark gray for shadows
25
+
26
+ return (
27
+ <box flexDirection="column" alignItems="center">
28
+ {/* Mustache with grayscale gradient for depth */}
29
+ {mustachiMustacheOnly.map((line, idx) => {
30
+ const totalLines = mustachiMustacheOnly.length
31
+ let color = midGray
32
+ if (idx < totalLines / 3) {
33
+ color = lightGray // Top highlight
34
+ } else if (idx >= (2 * totalLines) / 3) {
35
+ color = darkGray // Bottom shadow
36
+ }
37
+ return <text fg={color}>{line.padEnd(61, " ")}</text>
38
+ })}
39
+
40
+ {/* OpenCode branding */}
41
+ <box flexDirection="row" gap={0} marginTop={1}>
42
+ <text fg={props.theme.textMuted} dimColor={true}>╭ </text>
43
+ <text fg={props.theme.primary} bold={true}> O p e n C o d e </text>
44
+ <text fg={props.theme.textMuted} dimColor={true}> ╮</text>
45
+ </box>
46
+
47
+ <text> </text>
48
+ </box>
49
+ )
50
+ }
51
+
52
+ // Sidebar: Full Mustachi face with progressive animations (semantic zone colors)
53
+ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; isBusy?: boolean }) => {
54
+ const [pupilIndex, setPupilIndex] = createSignal(0)
55
+ const [blinkFrame, setBlinkFrame] = createSignal(0)
56
+ const [tongueFrame, setTongueFrame] = createSignal(0)
57
+ const [busyPhrases1, setBusyPhrases1] = createSignal("")
58
+ const [busyPhrases2, setBusyPhrases2] = createSignal("")
59
+ const [busyPhrases3, setBusyPhrases3] = createSignal("")
60
+ const [expressiveCycle, setExpressiveCycle] = createSignal(false)
61
+
62
+ // Animation: pupil movement (look around) - random transitions, not a sequence
63
+ createEffect(() => {
64
+ if (!props.config.animations || props.isBusy || expressiveCycle()) {
65
+ setPupilIndex(0)
66
+ return
67
+ }
68
+
69
+ const interval = setInterval(() => {
70
+ // Random eye movement - not sequential
71
+ // 60% chance to stay at center, 40% to move randomly
72
+ if (Math.random() < 0.6) {
73
+ setPupilIndex(0) // center
74
+ } else {
75
+ // Pick a random direction (skip center at index 0)
76
+ const randomDir = 1 + Math.floor(Math.random() * (pupilPositionFrames.length - 1))
77
+ setPupilIndex(randomDir)
78
+ }
79
+ }, 3000)
80
+
81
+ onCleanup(() => clearInterval(interval))
82
+ })
83
+
84
+ // Animation: blink - occasional, progressive top-to-bottom motion
85
+ createEffect(() => {
86
+ if (!props.config.animations) return
87
+
88
+ const blinkSequence = async () => {
89
+ // Open -> half -> closed -> half -> open (normal eyelid motion)
90
+ setBlinkFrame(0)
91
+ await new Promise(r => setTimeout(r, 100))
92
+ setBlinkFrame(1)
93
+ await new Promise(r => setTimeout(r, 80))
94
+ setBlinkFrame(2)
95
+ await new Promise(r => setTimeout(r, 80))
96
+ setBlinkFrame(1)
97
+ await new Promise(r => setTimeout(r, 80))
98
+ setBlinkFrame(0)
99
+ }
100
+
101
+ const interval = setInterval(() => {
102
+ // Blink more frequently to feel alive (~every 5-6 seconds)
103
+ // 35% chance every 2s = average 5.7s between blinks
104
+ if (Math.random() < 0.35) {
105
+ blinkSequence()
106
+ }
107
+ }, 2000)
108
+
109
+ onCleanup(() => clearInterval(interval))
110
+ })
111
+
112
+ // Busy/expressive state animation: tongue + 2-3 random phrases
113
+ // If isBusy is reliably reactive, use it; otherwise demonstrate expressiveness periodically
114
+ createEffect(() => {
115
+ if (!props.config.animations) {
116
+ setTongueFrame(0)
117
+ setBusyPhrases1("")
118
+ setBusyPhrases2("")
119
+ setBusyPhrases3("")
120
+ setExpressiveCycle(false)
121
+ return
122
+ }
123
+
124
+ const shouldShowExpression = props.isBusy || expressiveCycle()
125
+
126
+ if (!shouldShowExpression) {
127
+ setTongueFrame(0)
128
+ setBusyPhrases1("")
129
+ setBusyPhrases2("")
130
+ setBusyPhrases3("")
131
+ return
132
+ }
133
+
134
+ // Show tongue progressively when entering expressive state
135
+ let currentFrame = 0
136
+ let tongueTimeoutId: NodeJS.Timeout | undefined
137
+ const growTongue = () => {
138
+ if (currentFrame < tongueFrames.length - 1) {
139
+ currentFrame++
140
+ setTongueFrame(currentFrame)
141
+ }
142
+ }
143
+ tongueTimeoutId = setTimeout(growTongue, 200)
144
+
145
+ // Pick 2-3 random phrases from the expanded library
146
+ const pickRandomPhrases = () => {
147
+ const shuffled = [...busyPhrases].sort(() => Math.random() - 0.5)
148
+ const count = 2 + Math.floor(Math.random() * 2) // 2 or 3 phrases
149
+ return shuffled.slice(0, count)
150
+ }
151
+
152
+ let phraseSet = pickRandomPhrases()
153
+ let phraseIdx = 0
154
+ setBusyPhrases1(phraseSet[0] || "")
155
+ setBusyPhrases2(phraseSet[1] || "")
156
+ setBusyPhrases3(phraseSet[2] || "")
157
+
158
+ const interval = setInterval(() => {
159
+ phraseIdx++
160
+ if (phraseIdx >= phraseSet.length) {
161
+ // Refresh the phrase set
162
+ phraseSet = pickRandomPhrases()
163
+ phraseIdx = 0
164
+ }
165
+ setBusyPhrases1(phraseSet[0] || "")
166
+ setBusyPhrases2(phraseSet[1] || "")
167
+ setBusyPhrases3(phraseSet[2] || "")
168
+ }, 3000)
169
+
170
+ onCleanup(() => {
171
+ clearInterval(interval)
172
+ if (tongueTimeoutId !== undefined) {
173
+ clearTimeout(tongueTimeoutId)
174
+ }
175
+ })
176
+ })
177
+
178
+ // Fallback: Periodic expressive cycle (conservative - every ~45-60 seconds for ~8s)
179
+ // This ensures tongue + phrases are visibly demonstrated even if runtime busy state is unreliable
180
+ createEffect(() => {
181
+ if (!props.config.animations || props.isBusy) return
182
+
183
+ const triggerExpressiveCycle = () => {
184
+ setExpressiveCycle(true)
185
+
186
+ // End expressive cycle after 8 seconds
187
+ setTimeout(() => {
188
+ setExpressiveCycle(false)
189
+ }, 8000)
190
+ }
191
+
192
+ // First cycle after 30-45s, then every 45-60s
193
+ const firstDelay = 30000 + Math.random() * 15000
194
+ const firstTimeout = setTimeout(triggerExpressiveCycle, firstDelay)
195
+
196
+ const interval = setInterval(() => {
197
+ triggerExpressiveCycle()
198
+ }, 45000 + Math.random() * 15000)
199
+
200
+ onCleanup(() => {
201
+ clearTimeout(firstTimeout)
202
+ clearInterval(interval)
203
+ })
204
+ })
205
+
206
+ // Build the complete Mustachi face
207
+ const buildFace = () => {
208
+ const lines: { content: string; zone: string }[] = []
209
+
210
+ // Select eye frame based on state
211
+ let eyeFrame = pupilPositionFrames[pupilIndex()]
212
+
213
+ // Apply squint if busy/expressive
214
+ if (props.isBusy || expressiveCycle()) {
215
+ eyeFrame = eyeSquinted
216
+ }
217
+
218
+ // Apply blink animation if active
219
+ if (blinkFrame() === 1) {
220
+ eyeFrame = eyeBlinkHalf
221
+ } else if (blinkFrame() === 2) {
222
+ eyeFrame = eyeBlinkClosed
223
+ }
224
+
225
+ // Add eyes with zone metadata
226
+ eyeFrame.forEach((line, idx) => {
227
+ // Lines 0-1 are monocle border, lines 2-4 are eye interior
228
+ const zone = idx < 2 ? "monocle" : "eyes"
229
+ lines.push({ content: line, zone })
230
+ })
231
+
232
+ // Add mustache section
233
+ mustachiMustacheSection.forEach(line => {
234
+ lines.push({ content: line, zone: "mustache" })
235
+ })
236
+
237
+ // Add tongue if expressive (mark as tongue zone for pink color)
238
+ if ((props.isBusy || expressiveCycle()) && tongueFrame() > 0) {
239
+ const tongueLines = tongueFrames[tongueFrame()]
240
+ tongueLines.forEach(line => {
241
+ lines.push({ content: line, zone: "tongue" })
242
+ })
243
+ }
244
+
245
+ return lines
246
+ }
247
+
248
+ return (
249
+ <box flexDirection="column" alignItems="center">
250
+ {/* Full Mustachi face with semantic zone colors */}
251
+ {buildFace().map(({ content, zone }) => {
252
+ const color = zoneColors[zone as keyof typeof zoneColors] || zoneColors.mustache
253
+ const paddedLine = content.padEnd(27, " ")
254
+ return <text fg={color}>{paddedLine}</text>
255
+ })}
256
+
257
+ {/* Display 2-3 busy phrases if loading */}
258
+ {busyPhrases1() && (
259
+ <text fg={props.theme.warning}>{busyPhrases1()}</text>
260
+ )}
261
+ {busyPhrases2() && (
262
+ <text fg={props.theme.warning}>{busyPhrases2()}</text>
263
+ )}
264
+ {busyPhrases3() && (
265
+ <text fg={props.theme.warning}>{busyPhrases3()}</text>
266
+ )}
267
+
268
+ <text> </text>
269
+ </box>
270
+ )
271
+ }
272
+
273
+ export const DetectedEnv = (props: {
274
+ theme: TuiThemeCurrent
275
+ providers: ReadonlyArray<{ id: string; name: string }> | undefined
276
+ config: Cfg
277
+ }) => {
278
+ if (!props.config.show_detected) return null
279
+
280
+ const os = props.config.show_os ? getOSName() : null
281
+ const providers = props.config.show_providers ? getProviders(props.providers) : null
282
+
283
+ // Don't render if nothing to show
284
+ if (!os && !providers) return null
285
+
286
+ return (
287
+ <box flexDirection="row" gap={1}>
288
+ <text fg={props.theme.textMuted}>Detected:</text>
289
+ {os && <text fg={props.theme.text}>{os}</text>}
290
+ {os && providers && <text fg={props.theme.textMuted}>·</text>}
291
+ {providers && <text fg={props.theme.text}>{providers}</text>}
292
+ </box>
293
+ )
294
+ }
package/config.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Configuration types and parsing helpers
2
+
3
+ export type Cfg = {
4
+ enabled: boolean
5
+ theme: string
6
+ set_theme: boolean
7
+ show_detected: boolean
8
+ show_os: boolean
9
+ show_providers: boolean
10
+ animations: boolean
11
+ }
12
+
13
+ const rec = (value: unknown) => {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
15
+ return Object.fromEntries(Object.entries(value))
16
+ }
17
+
18
+ const pick = (value: unknown, fallback: string) => {
19
+ if (typeof value !== "string") return fallback
20
+ if (!value.trim()) return fallback
21
+ return value
22
+ }
23
+
24
+ const bool = (value: unknown, fallback: boolean) => {
25
+ if (typeof value !== "boolean") return fallback
26
+ return value
27
+ }
28
+
29
+ export const cfg = (opts: Record<string, unknown> | undefined): Cfg => {
30
+ return {
31
+ enabled: bool(opts?.enabled, true),
32
+ theme: pick(opts?.theme, "gentleman"),
33
+ set_theme: bool(opts?.set_theme, true),
34
+ show_detected: bool(opts?.show_detected, true),
35
+ show_os: bool(opts?.show_os, true),
36
+ show_providers: bool(opts?.show_providers, true),
37
+ animations: bool(opts?.animations, true),
38
+ }
39
+ }
package/detection.ts ADDED
@@ -0,0 +1,61 @@
1
+ // OS and provider detection utilities
2
+
3
+ import { readFileSync } from "node:fs"
4
+
5
+ // Helper to detect OS name
6
+ export const getOSName = (): string => {
7
+ try {
8
+ const platform = typeof process !== "undefined" ? process.platform : "unknown"
9
+ switch (platform) {
10
+ case "linux":
11
+ try {
12
+ const osRelease = readFileSync("/etc/os-release", "utf8")
13
+ const match = osRelease.match(/^NAME="?([^"\n]+)"?/m)
14
+ if (match) return match[1]
15
+ } catch {
16
+ }
17
+ return "Linux"
18
+ case "darwin":
19
+ return "macOS"
20
+ case "win32":
21
+ return "Windows"
22
+ default:
23
+ return platform
24
+ }
25
+ } catch {
26
+ return "Unknown"
27
+ }
28
+ }
29
+
30
+ // Map provider IDs to friendly display names
31
+ const providerDisplayNames: Record<string, string> = {
32
+ "openai": "OpenAI",
33
+ "google": "Google",
34
+ "github-copilot": "Copilot",
35
+ "opencode-go": "OpenCode GO",
36
+ "anthropic": "Claude",
37
+ "deepseek": "DeepSeek",
38
+ "openrouter": "OpenRouter",
39
+ "mistral": "Mistral",
40
+ "groq": "Groq",
41
+ "cohere": "Cohere",
42
+ "together": "Together",
43
+ "perplexity": "Perplexity",
44
+ }
45
+
46
+ // Helper to detect active providers from OpenCode state
47
+ export const getProviders = (providers: ReadonlyArray<{ id: string; name: string }> | undefined): string => {
48
+ if (!providers || providers.length === 0) {
49
+ return "No providers configured"
50
+ }
51
+
52
+ // Map provider IDs to friendly names and deduplicate
53
+ const names = new Set<string>()
54
+ for (const provider of providers) {
55
+ const displayName = providerDisplayNames[provider.id] || provider.name || provider.id
56
+ names.add(displayName)
57
+ }
58
+
59
+ // Return compact comma-separated list
60
+ return Array.from(names).sort().join(", ")
61
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "plugin-gentleman",
4
- "version": "1.0.4",
4
+ "version": "1.0.7",
5
5
  "description": "OpenCode TUI plugin featuring Mustachi - an animated ASCII mascot with eyes, mustache, and optional motivational phrases during busy states",
6
6
  "type": "module",
7
7
  "exports": {
@@ -20,6 +20,11 @@
20
20
  },
21
21
  "files": [
22
22
  "tui.tsx",
23
+ "config.ts",
24
+ "detection.ts",
25
+ "ascii-frames.ts",
26
+ "phrases.ts",
27
+ "components.tsx",
23
28
  "gentleman.json"
24
29
  ],
25
30
  "engines": {
package/phrases.ts ADDED
@@ -0,0 +1,41 @@
1
+ // Motivational phrases for busy/loading state
2
+ // Add new phrases here to expand the library
3
+
4
+ export const busyPhrases = [
5
+ // Original classics
6
+ "Ponete las pilas, hermano...",
7
+ "Dale que va, dale que va...",
8
+ "Ya casi, ya casi...",
9
+ "Ahí vamos, loco...",
10
+ "Un toque más y listo...",
11
+ "Aguantá que estoy pensando...",
12
+ "Momento, momento...",
13
+ "Ya te lo traigo, tranqui...",
14
+
15
+ // New additions for variety
16
+ "Dame un segundo, campeón...",
17
+ "Trabajando en eso, maestro...",
18
+ "Ya salimos, paciencia...",
19
+ "Dejame terminar esto...",
20
+ "Casi listo, bancame...",
21
+ "Estoy en eso, tranquilo...",
22
+ "Un cachito más, hermano...",
23
+ "Procesando, no desesperes...",
24
+ "Ya falta poco, dale...",
25
+ "Lo estoy cocinando...",
26
+ "Mirá que estoy pensando...",
27
+ "Tranqui, ya lo tengo...",
28
+ "Un ratito más, loco...",
29
+ "Dejame concentrar...",
30
+ "Vamos vamos, sin apuro...",
31
+ "Que no se note el esfuerzo...",
32
+ "Ahí va, ahí va...",
33
+ "Procesando con elegancia...",
34
+ "Un toque más de paciencia...",
35
+ "Ya te resuelvo esto...",
36
+ "Estoy en la zona, esperá...",
37
+ "Dale tiempo al tiempo...",
38
+ "Tranquilo, todo bajo control...",
39
+ "Un segundo de concentración...",
40
+ "Mirá cómo se hace esto...",
41
+ ]
package/tui.tsx CHANGED
@@ -1,438 +1,17 @@
1
1
  // @ts-nocheck
2
2
  /** @jsxImportSource @opentui/solid */
3
- import { readFileSync } from "node:fs"
4
- import type { TuiPlugin, TuiPluginModule, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
5
- import { createSignal, onCleanup, createEffect } from "solid-js"
3
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
4
+ import { createSignal, createEffect } from "solid-js"
5
+ import { cfg, type Cfg } from "./config"
6
+ import { HomeLogo, SidebarMustachi, DetectedEnv } from "./components"
6
7
 
7
8
  const id = "gentleman"
8
9
 
9
- // Premium Mustachi ASCII art - compact version for sidebar (25 chars wide)
10
- // Base structure with eyes that will be replaced dynamically
11
- const mustachiNeutralBase = [
12
- " █████ █████",
13
- " ██░░░░░██ ██░░░░░██",
14
- " ██░░███░░██ ██░░░░░░░██",
15
- " ██░░███░░██ ██░░░░░░░██",
16
- "██ ██░░░░░██ ██░░░░░██ ██",
17
- ]
18
-
19
- // Squinted eyes version for busy state
20
- const mustachiSquintedBase = [
21
- " █████ █████",
22
- " ██░░░░░██ ██░░░░░██",
23
- " ██░░███░░██ ██░░░░░░░██",
24
- " █████████ █████████",
25
- "██ █████ █████ ██",
26
- ]
27
-
28
- // Mustache section (compact 25-char wide design)
29
- const mustachiMustacheSection = [
30
- "██████████ ████████",
31
- "████████████ ██████████",
32
- " █████████████████████████",
33
- " ▓██████████ ██████████▓",
34
- " ▓██████ ██████▓",
35
- ]
36
-
37
- // Tongue animation frames (progressive) - compact design
38
- const tongueFrames = [
39
- [], // no tongue
40
- [" ███", " █"], // tongue out
41
- ]
42
-
43
- // Mustache-only ASCII art for home logo (original massive solid block design)
44
- const mustachiMustacheOnly = [
45
- "",
46
- " ████████ ████████",
47
- " ████████████ ████████████",
48
- " ██ ████████████████ ████████████████ ██",
49
- " ████ ████████████████████ ████████████████████ ████",
50
- " ██████ ███████████████████████████████████████████ ██████",
51
- " ███████████████████████████████████████████████████████████",
52
- " ███████████████████████████████████████████████████████████",
53
- " ███████████████████████████████████████████████████████████",
54
- " █████████████████████████████████████████████████████████",
55
- " ███████████████████████████████████████████████████████",
56
- " ▓▓█████████████████████ █████████████████████▓▓",
57
- " ▓▓▓███████████████ ███████████████▓▓▓",
58
- " ▓▓▓█████████ █████████▓▓▓",
59
- " ▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓",
60
- "",
61
- ]
62
-
63
- // Left pupil positions for look-around animation (progressive)
64
- // Modifies only the left eye (white sclera with dark pupil)
65
- // Right eye is monocle/glass and remains static
66
- // Pupil is on lines 2 and 3 (indices 2-3) of the 5-line eye array
67
- const leftPupilPositions = [
68
- "██░░███░░██", // center (line 2 of eyes)
69
- "██████░░░██", // looking left
70
- "██░░░██████", // looking right
71
- "██░░███░░██", // center again
72
- ]
73
-
74
- // Blink animation frames (progressive) - affects both eyes
75
- const blinkFrames = [
76
- // Open eyes (default state embedded in base arrays)
77
- { left: mustachiNeutralBase, squinted: mustachiSquintedBase },
78
- // Half closed
79
- {
80
- left: [
81
- " █████ █████",
82
- " ██░░░░░██ ██░░░░░██",
83
- " ██░░███░░██ ██░░░░░░░██",
84
- " █████████ █████████",
85
- "██ █████ █████ ██",
86
- ],
87
- squinted: mustachiSquintedBase // squinted stays squinted during blink
88
- },
89
- // Fully closed
90
- {
91
- left: [
92
- " █████ █████",
93
- " ██░░░░░██ ██░░░░░██",
94
- " █████████ █████████",
95
- " █████████ █████████",
96
- "██ █████ █████ ██",
97
- ],
98
- squinted: mustachiSquintedBase
99
- },
100
- ]
101
-
102
- // Busy/loading state with tongue and motivational phrases
103
- const busyPhrases = [
104
- "Ponete las pilas, hermano...",
105
- "Dale que va, dale que va...",
106
- "Ya casi, ya casi...",
107
- "Ahí vamos, loco...",
108
- "Un toque más y listo...",
109
- "Aguantá que estoy pensando...",
110
- "Momento, momento...",
111
- "Ya te lo traigo, tranqui...",
112
- ]
113
-
114
- type Cfg = {
115
- enabled: boolean
116
- theme: string
117
- set_theme: boolean
118
- show_detected: boolean
119
- show_os: boolean
120
- show_providers: boolean
121
- animations: boolean
122
- }
123
-
124
- type Api = Parameters<TuiPlugin>[0]
125
-
126
10
  const rec = (value: unknown) => {
127
11
  if (!value || typeof value !== "object" || Array.isArray(value)) return
128
12
  return Object.fromEntries(Object.entries(value))
129
13
  }
130
14
 
131
- const pick = (value: unknown, fallback: string) => {
132
- if (typeof value !== "string") return fallback
133
- if (!value.trim()) return fallback
134
- return value
135
- }
136
-
137
- const bool = (value: unknown, fallback: boolean) => {
138
- if (typeof value !== "boolean") return fallback
139
- return value
140
- }
141
-
142
- const cfg = (opts: Record<string, unknown> | undefined): Cfg => {
143
- return {
144
- enabled: bool(opts?.enabled, true),
145
- theme: pick(opts?.theme, "gentleman"),
146
- set_theme: bool(opts?.set_theme, true),
147
- show_detected: bool(opts?.show_detected, true),
148
- show_os: bool(opts?.show_os, true),
149
- show_providers: bool(opts?.show_providers, true),
150
- animations: bool(opts?.animations, true),
151
- }
152
- }
153
-
154
- // Helper to detect OS name
155
- const getOSName = (): string => {
156
- try {
157
- const platform = typeof process !== "undefined" ? process.platform : "unknown"
158
- switch (platform) {
159
- case "linux":
160
- try {
161
- const osRelease = readFileSync("/etc/os-release", "utf8")
162
- const match = osRelease.match(/^NAME="?([^"\n]+)"?/m)
163
- if (match) return match[1]
164
- } catch {
165
- }
166
- return "Linux"
167
- case "darwin":
168
- return "macOS"
169
- case "win32":
170
- return "Windows"
171
- default:
172
- return platform
173
- }
174
- } catch {
175
- return "Unknown"
176
- }
177
- }
178
-
179
- // Map provider IDs to friendly display names
180
- const providerDisplayNames: Record<string, string> = {
181
- "openai": "OpenAI",
182
- "google": "Google",
183
- "github-copilot": "Copilot",
184
- "opencode-go": "OpenCode GO",
185
- "anthropic": "Claude",
186
- "deepseek": "DeepSeek",
187
- "openrouter": "OpenRouter",
188
- "mistral": "Mistral",
189
- "groq": "Groq",
190
- "cohere": "Cohere",
191
- "together": "Together",
192
- "perplexity": "Perplexity",
193
- }
194
-
195
- // Helper to detect active providers from OpenCode state
196
- const getProviders = (providers: ReadonlyArray<{ id: string; name: string }> | undefined): string => {
197
- if (!providers || providers.length === 0) {
198
- return "No providers configured"
199
- }
200
-
201
- // Map provider IDs to friendly names and deduplicate
202
- const names = new Set<string>()
203
- for (const provider of providers) {
204
- const displayName = providerDisplayNames[provider.id] || provider.name || provider.id
205
- names.add(displayName)
206
- }
207
-
208
- // Return compact comma-separated list
209
- return Array.from(names).sort().join(", ")
210
- }
211
-
212
- // Home logo: Mustache-only (simple and prominent) with grayscale gradient
213
- const HomeLogo = (props: { theme: TuiThemeCurrent }) => {
214
- // Grayscale palette for better TUI readability
215
- const lightGray = "#C0C0C0" // Light gray for highlights
216
- const midGray = "#808080" // Mid gray for main body
217
- const darkGray = "#505050" // Dark gray for shadows
218
-
219
- return (
220
- <box flexDirection="column" alignItems="center">
221
- {/* Mustache with grayscale gradient for depth */}
222
- {mustachiMustacheOnly.map((line, idx) => {
223
- const totalLines = mustachiMustacheOnly.length
224
- let color = midGray
225
- if (idx < totalLines / 3) {
226
- color = lightGray // Top highlight
227
- } else if (idx >= (2 * totalLines) / 3) {
228
- color = darkGray // Bottom shadow
229
- }
230
- return <text fg={color}>{line.padEnd(61, " ")}</text>
231
- })}
232
-
233
- {/* OpenCode branding */}
234
- <box flexDirection="row" gap={0} marginTop={1}>
235
- <text fg={props.theme.textMuted} dimColor={true}>╭ </text>
236
- <text fg={props.theme.primary} bold={true}> O p e n C o d e </text>
237
- <text fg={props.theme.textMuted} dimColor={true}> ╮</text>
238
- </box>
239
-
240
- <text> </text>
241
- </box>
242
- )
243
- }
244
-
245
- // Sidebar: Full Mustachi face with progressive animations (grayscale for clarity)
246
- const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; isBusy?: boolean }) => {
247
- const [pupilIndex, setPupilIndex] = createSignal(0)
248
- const [blinkFrame, setBlinkFrame] = createSignal(0)
249
- const [tongueFrame, setTongueFrame] = createSignal(0)
250
- const [busyPhrase, setBusyPhrase] = createSignal("")
251
-
252
- // Animation: pupil movement (look around) - low frequency, progressive
253
- createEffect(() => {
254
- if (!props.config.animations || props.isBusy) {
255
- setPupilIndex(0)
256
- return
257
- }
258
-
259
- const interval = setInterval(() => {
260
- // Cycle through pupil positions progressively
261
- setPupilIndex((prev) => {
262
- // 80% chance to stay at center, 20% to move
263
- if (Math.random() < 0.8) return 0
264
- return (prev + 1) % leftPupilPositions.length
265
- })
266
- }, 3000)
267
-
268
- onCleanup(() => clearInterval(interval))
269
- })
270
-
271
- // Animation: blink - occasional, progressive
272
- createEffect(() => {
273
- if (!props.config.animations) return
274
-
275
- const blinkSequence = async () => {
276
- // Open -> half -> closed -> half -> open
277
- setBlinkFrame(0)
278
- await new Promise(r => setTimeout(r, 150))
279
- setBlinkFrame(1)
280
- await new Promise(r => setTimeout(r, 100))
281
- setBlinkFrame(2)
282
- await new Promise(r => setTimeout(r, 100))
283
- setBlinkFrame(1)
284
- await new Promise(r => setTimeout(r, 100))
285
- setBlinkFrame(0)
286
- }
287
-
288
- const interval = setInterval(() => {
289
- // Blink occasionally (15% chance every 4s)
290
- if (Math.random() < 0.15) {
291
- blinkSequence()
292
- }
293
- }, 4000)
294
-
295
- onCleanup(() => clearInterval(interval))
296
- })
297
-
298
- // Busy state animation: tongue grows progressively + rotate phrases
299
- createEffect(() => {
300
- if (!props.config.animations || !props.isBusy) {
301
- setTongueFrame(0)
302
- setBusyPhrase("")
303
- return
304
- }
305
-
306
- // Grow tongue progressively when entering busy state (2 frames: hidden -> visible)
307
- let currentFrame = 0
308
- let tongueTimeoutId: NodeJS.Timeout | undefined
309
- const growTongue = () => {
310
- if (currentFrame < tongueFrames.length - 1) {
311
- currentFrame++
312
- setTongueFrame(currentFrame)
313
- }
314
- }
315
- // Show tongue immediately when busy
316
- tongueTimeoutId = setTimeout(growTongue, 200)
317
-
318
- // Rotate busy phrases
319
- let phraseIdx = 0
320
- setBusyPhrase(busyPhrases[phraseIdx])
321
-
322
- const interval = setInterval(() => {
323
- phraseIdx = (phraseIdx + 1) % busyPhrases.length
324
- setBusyPhrase(busyPhrases[phraseIdx])
325
- }, 3000)
326
-
327
- onCleanup(() => {
328
- clearInterval(interval)
329
- if (tongueTimeoutId !== undefined) {
330
- clearTimeout(tongueTimeoutId)
331
- }
332
- })
333
- })
334
-
335
- // Build the complete Mustachi face
336
- const buildFace = () => {
337
- const lines: string[] = []
338
-
339
- // Select eye base based on busy state
340
- let eyeBase = props.isBusy ? mustachiSquintedBase : mustachiNeutralBase
341
-
342
- // Apply blink animation if active
343
- if (blinkFrame() > 0 && blinkFrame() < blinkFrames.length) {
344
- eyeBase = props.isBusy
345
- ? blinkFrames[blinkFrame()].squinted
346
- : blinkFrames[blinkFrame()].left
347
- }
348
-
349
- // Add eyes with pupil position (modify line 2 for left eye pupil - index 2 in 5-line array)
350
- eyeBase.forEach((line, idx) => {
351
- if (idx === 2 && !props.isBusy && pupilIndex() >= 0) {
352
- // Replace pupil in left eye (positions 2-12 of the line for the 25-char compact design)
353
- const pupil = leftPupilPositions[pupilIndex()]
354
- const modifiedLine = line.substring(0, 2) + pupil + line.substring(13)
355
- lines.push(modifiedLine)
356
- } else {
357
- lines.push(line)
358
- }
359
- })
360
-
361
- // Add mustache section
362
- mustachiMustacheSection.forEach(line => lines.push(line))
363
-
364
- // Add tongue if busy (progressive frames) - mark as tongue for coloring
365
- if (props.isBusy && tongueFrame() > 0) {
366
- const tongueLines = tongueFrames[tongueFrame()]
367
- tongueLines.forEach(line => lines.push(`TONGUE:${line}`))
368
- }
369
-
370
- return lines
371
- }
372
-
373
- // Grayscale palette for TUI clarity
374
- const lightGray = "#C0C0C0" // Light gray for highlights
375
- const midGray = "#808080" // Mid gray for main body
376
- const darkGray = "#505050" // Dark gray for shadows
377
- const tongueColor = "#FF4466" // Pink/Red for tongue
378
-
379
- return (
380
- <box flexDirection="column" alignItems="center">
381
- {/* Full Mustachi face with grayscale gradient + pink tongue */}
382
- {buildFace().map((line, idx, arr) => {
383
- // Check if this is a tongue line
384
- const isTongue = line.startsWith("TONGUE:")
385
- const displayLine = isTongue ? line.substring(7) : line
386
- const paddedLine = displayLine.padEnd(25, " ")
387
-
388
- if (isTongue) {
389
- return <text fg={tongueColor}>{paddedLine}</text>
390
- }
391
-
392
- // Apply grayscale gradient to eyes and mustache
393
- const totalLines = arr.length
394
- let color = midGray
395
- if (idx < totalLines / 3) {
396
- color = lightGray // Top highlight
397
- } else if (idx >= (2 * totalLines) / 3) {
398
- color = darkGray // Bottom shadow
399
- }
400
- return <text fg={color}>{paddedLine}</text>
401
- })}
402
-
403
- {/* Busy phrase if loading */}
404
- {busyPhrase() && (
405
- <text fg={props.theme.warning}>{busyPhrase()}</text>
406
- )}
407
-
408
- <text> </text>
409
- </box>
410
- )
411
- }
412
-
413
- const DetectedEnv = (props: {
414
- theme: TuiThemeCurrent
415
- providers: ReadonlyArray<{ id: string; name: string }> | undefined
416
- config: Cfg
417
- }) => {
418
- if (!props.config.show_detected) return null
419
-
420
- const os = props.config.show_os ? getOSName() : null
421
- const providers = props.config.show_providers ? getProviders(props.providers) : null
422
-
423
- // Don't render if nothing to show
424
- if (!os && !providers) return null
425
-
426
- return (
427
- <box flexDirection="row" gap={1}>
428
- <text fg={props.theme.textMuted}>Detected:</text>
429
- {os && <text fg={props.theme.text}>{os}</text>}
430
- {os && providers && <text fg={props.theme.textMuted}>·</text>}
431
- {providers && <text fg={props.theme.text}>{providers}</text>}
432
- </box>
433
- )
434
- }
435
-
436
15
  const tui: TuiPlugin = async (api, options) => {
437
16
  const boot = cfg(rec(options))
438
17
  if (!boot.enabled) return