plugin-gentleman 1.0.6 → 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
@@ -86,18 +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
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.
95
96
 
96
97
  The ASCII representation features:
97
- - **Eyes** that occasionally look in different directions *(sidebar only)*
98
- - **Mustache** rendered in theme colors *(both home and sidebar)*
99
- - **Tongue** that appears during busy/loading states *(sidebar only)*
100
- - **Motivational phrases** in Rioplatense Spanish style *(sidebar only)*
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)*
101
102
 
102
103
  **Example phrases during busy states:**
103
104
  - *"Ponete las pilas, hermano..."*
@@ -111,19 +112,33 @@ The ASCII representation features:
111
112
 
112
113
  **Low complexity, low frequency** — subtle and non-intrusive:
113
114
 
114
- **Eye Direction** *(when `animations: true`)*
115
- - Every ~4 seconds, eyes may look in a random direction
116
- - 20% chance of movement per interval
117
- - 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)
118
119
 
119
- **Busy State** *(when `animations: true`)*
120
- - Tongue appears when OpenCode is processing
121
- - Motivational phrase rotates every 3 seconds
122
- - 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
123
137
 
124
138
  **Disabled** *(when `animations: false`)*
125
139
  - Mustachi stays in neutral position
126
- - No eye movement, tongue, or phrases
140
+ - No blink, no eye movement, no tongue, no phrases
141
+ - Completely static face
127
142
 
128
143
  ### Gentleman Theme
129
144
 
@@ -133,10 +148,16 @@ A refined dark color palette:
133
148
  - **Accent:** Warm gold (`#E0C15A`)
134
149
  - **Text:** Clean white (`#F3F6F9`)
135
150
 
136
- **Mustachi gradient** (3-tone):
137
- - **Top:** Accent gold
138
- - **Middle:** Primary blue
139
- - **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`)
140
161
 
141
162
  ### Environment Detection
142
163
 
@@ -150,7 +171,9 @@ Both are fully configurable and can be hidden.
150
171
 
151
172
  ## Configuration
152
173
 
153
- 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).
154
177
 
155
178
  ### Default Settings
156
179
 
@@ -190,6 +213,8 @@ All options are configured via plugin tuple syntax in `opencode.json`.
190
213
 
191
214
  **Disable Animations:**
192
215
 
216
+ If you prefer a completely static Mustachi (no eye movement, no busy-state tongue/phrases):
217
+
193
218
  ```json
194
219
  {
195
220
  "$schema": "https://opencode.ai/config.json",
@@ -199,7 +224,12 @@ All options are configured via plugin tuple syntax in `opencode.json`.
199
224
  }
200
225
  ```
201
226
 
202
- 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
203
233
 
204
234
  **Logo Only (No Detection):**
205
235
 
@@ -240,18 +270,36 @@ The plugin integrates with OpenCode's TUI system through slot registration:
240
270
 
241
271
  1. **Theme Installation:** Installs `gentleman.json` into OpenCode themes on load
242
272
  2. **Theme Activation:** Switches to the gentleman theme *(if `set_theme: true`)*
243
- 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)
244
274
  4. **Environment Detection Slot:** Registers `home_bottom` with OS/provider info below the prompt
245
275
  5. **Sidebar Slot:** Registers `sidebar_content` with full Mustachi face and animations
246
- 6. **Animation Loop:** Starts interval timers for eye variations and busy-state detection *(if `animations: true`, sidebar only)*
247
- 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
248
284
 
249
285
  ### Technical Stack
250
286
 
251
287
  - **No Build Step:** Plain TSX transpiled at runtime by OpenCode
252
- - **Solid.js Reactivity:** Uses `createSignal` and `createEffect` for animations
288
+ - **Solid.js Reactivity:** Uses `createSignal` and `createEffect` for all animations
253
289
  - **Safe Detection:** All OS/provider detection wrapped in try-catch blocks
254
- - **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.
255
303
 
256
304
  ---
257
305
 
@@ -294,7 +342,12 @@ If you copy `.ts` files to `~/.config/opencode/plugins/` (system plugin):
294
342
  ### Package Contents
295
343
 
296
344
  **Files included in npm package** *(via `files` field in `package.json`)*:
297
- - `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
298
351
  - `gentleman.json` — theme definition
299
352
  - `package.json` — auto-included by npm
300
353
  - `README.md` — auto-included by npm
@@ -307,12 +360,18 @@ If you copy `.ts` files to `~/.config/opencode/plugins/` (system plugin):
307
360
 
308
361
  ## Known Limitations
309
362
 
310
- 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.
311
364
 
312
- 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).
313
372
 
314
373
  3. **Slot Usage:** The plugin uses these OpenCode TUI slots:
315
- - `home_logo` — mustache-only ASCII art
374
+ - `home_logo` — mustache-only ASCII art (grayscale gradient)
316
375
  - `home_bottom` — environment detection (OS + providers)
317
376
  - `sidebar_content` — full Mustachi face with animations
318
377
 
@@ -324,23 +383,39 @@ If you copy `.ts` files to `~/.config/opencode/plugins/` (system plugin):
324
383
 
325
384
  ### Modifying the Plugin
326
385
 
327
- 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:**
328
398
 
329
- 1. Edit `tui.tsx` (main implementation)
330
- 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)
331
401
  3. No build step needed — OpenCode transpiles TSX at runtime
332
402
  4. Restart OpenCode to see changes
333
403
 
334
- ### Adding New Content
404
+ **Animation timing customization:**
335
405
 
336
- **Eye variations:**
337
- - 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)
338
413
 
339
- **Busy phrases:**
340
- - Edit the `busyPhrases` array in `tui.tsx` *(affects sidebar only)*
414
+ **Color customization:**
341
415
 
342
- **Animation timings:**
343
- - 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
344
419
 
345
420
  ---
346
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.6",
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,473 +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 - structured by semantic zones
10
- // Each eye state is a complete frame to avoid partial replacements
11
-
12
- // Eye frames - neutral state with different pupil positions
13
- // All lines are padded to 27 chars for perfect alignment with mustache
14
- const eyeNeutralCenter = [
15
- " █████ █████ ", // 27 chars (was 22)
16
- " ██░░░░░██ ██░░░░░██ ", // 27 chars (was 24)
17
- " ██░░███░░██ ██░░░░░░░██ ", // 27 chars (was 25)
18
- " ██░░███░░██ ██░░░░░░░██ ", // 27 chars (was 25)
19
- "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars (unchanged)
20
- ]
21
-
22
- const eyeNeutralLeft = [
23
- " █████ █████ ", // 27 chars (was 22)
24
- " ██░░░░░██ ██░░░░░██ ", // 27 chars (was 24)
25
- " ██████░░░██ ██░░░░░░░██ ", // 27 chars (was 25)
26
- " ██████░░░██ ██░░░░░░░██ ", // 27 chars (was 25)
27
- "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars (unchanged)
28
- ]
29
-
30
- const eyeNeutralRight = [
31
- " █████ █████ ", // 27 chars (was 22)
32
- " ██░░░░░██ ██░░░░░██ ", // 27 chars (was 24)
33
- " ██░░░██████ ██░░░░░░░██ ", // 27 chars (was 25)
34
- " ██░░░██████ ██░░░░░░░██ ", // 27 chars (was 25)
35
- "██ ██░░░░░██ ██░░░░░██ ██", // 27 chars (unchanged)
36
- ]
37
-
38
- // Squinted eyes version for busy/expressive state
39
- const eyeSquinted = [
40
- " █████ █████ ", // 27 chars (was 22)
41
- " ██░░░░░██ ██░░░░░██ ", // 27 chars (was 24)
42
- " ██░░███░░██ ██░░░░░░░██ ", // 27 chars (was 25)
43
- " █████████ █████████ ", // 27 chars (was 24)
44
- "██ █████ █████ ██", // 27 chars (unchanged)
45
- ]
46
-
47
- // Blink frames - half closed
48
- const eyeBlinkHalf = [
49
- " █████ █████ ", // 27 chars (was 22)
50
- " ██░░░░░██ ██░░░░░██ ", // 27 chars (was 24)
51
- " ██░░███░░██ ██░░░░░░░██ ", // 27 chars (was 25)
52
- " █████████ █████████ ", // 27 chars (was 24)
53
- "██ █████ █████ ██", // 27 chars (unchanged)
54
- ]
55
-
56
- // Blink frames - fully closed
57
- const eyeBlinkClosed = [
58
- " █████ █████ ", // 27 chars (was 22)
59
- " ██░░░░░██ ██░░░░░██ ", // 27 chars (was 24)
60
- " █████████ █████████ ", // 27 chars (was 24)
61
- " █████████ █████████ ", // 27 chars (was 24)
62
- "██ █████ █████ ██", // 27 chars (unchanged)
63
- ]
64
-
65
- // Mustache section (all lines padded to 27 chars for alignment)
66
- const mustachiMustacheSection = [
67
- "██████████ ████████", // 27 chars (unchanged)
68
- "████████████ ██████████", // 27 chars (unchanged)
69
- " █████████████████████████ ", // 27 chars (was 26)
70
- " ▓██████████ ██████████▓", // 27 chars (unchanged)
71
- " ▓██████ ██████▓ ", // 27 chars (was 25)
72
- ]
73
-
74
- // Tongue animation frames (progressive) - compact design
75
- const tongueFrames = [
76
- [], // no tongue
77
- [" ███", " █"], // tongue out
78
- ]
79
-
80
- // Mustache-only ASCII art for home logo (original massive solid block design)
81
- const mustachiMustacheOnly = [
82
- "",
83
- " ████████ ████████",
84
- " ████████████ ████████████",
85
- " ██ ████████████████ ████████████████ ██",
86
- " ████ ████████████████████ ████████████████████ ████",
87
- " ██████ ███████████████████████████████████████████ ██████",
88
- " ███████████████████████████████████████████████████████████",
89
- " ███████████████████████████████████████████████████████████",
90
- " ███████████████████████████████████████████████████████████",
91
- " █████████████████████████████████████████████████████████",
92
- " ███████████████████████████████████████████████████████",
93
- " ▓▓█████████████████████ █████████████████████▓▓",
94
- " ▓▓▓███████████████ ███████████████▓▓▓",
95
- " ▓▓▓█████████ █████████▓▓▓",
96
- " ▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓",
97
- "",
98
- ]
99
-
100
- // Pupil position mapping for look-around animation
101
- const pupilPositionFrames = [
102
- eyeNeutralCenter, // center
103
- eyeNeutralLeft, // looking left
104
- eyeNeutralRight, // looking right
105
- eyeNeutralCenter, // back to center
106
- ]
107
-
108
- // Busy/loading state with tongue and motivational phrases
109
- const busyPhrases = [
110
- "Ponete las pilas, hermano...",
111
- "Dale que va, dale que va...",
112
- "Ya casi, ya casi...",
113
- "Ahí vamos, loco...",
114
- "Un toque más y listo...",
115
- "Aguantá que estoy pensando...",
116
- "Momento, momento...",
117
- "Ya te lo traigo, tranqui...",
118
- ]
119
-
120
- type Cfg = {
121
- enabled: boolean
122
- theme: string
123
- set_theme: boolean
124
- show_detected: boolean
125
- show_os: boolean
126
- show_providers: boolean
127
- animations: boolean
128
- }
129
-
130
- type Api = Parameters<TuiPlugin>[0]
131
-
132
10
  const rec = (value: unknown) => {
133
11
  if (!value || typeof value !== "object" || Array.isArray(value)) return
134
12
  return Object.fromEntries(Object.entries(value))
135
13
  }
136
14
 
137
- const pick = (value: unknown, fallback: string) => {
138
- if (typeof value !== "string") return fallback
139
- if (!value.trim()) return fallback
140
- return value
141
- }
142
-
143
- const bool = (value: unknown, fallback: boolean) => {
144
- if (typeof value !== "boolean") return fallback
145
- return value
146
- }
147
-
148
- const cfg = (opts: Record<string, unknown> | undefined): Cfg => {
149
- return {
150
- enabled: bool(opts?.enabled, true),
151
- theme: pick(opts?.theme, "gentleman"),
152
- set_theme: bool(opts?.set_theme, true),
153
- show_detected: bool(opts?.show_detected, true),
154
- show_os: bool(opts?.show_os, true),
155
- show_providers: bool(opts?.show_providers, true),
156
- animations: bool(opts?.animations, true),
157
- }
158
- }
159
-
160
- // Helper to detect OS name
161
- const getOSName = (): string => {
162
- try {
163
- const platform = typeof process !== "undefined" ? process.platform : "unknown"
164
- switch (platform) {
165
- case "linux":
166
- try {
167
- const osRelease = readFileSync("/etc/os-release", "utf8")
168
- const match = osRelease.match(/^NAME="?([^"\n]+)"?/m)
169
- if (match) return match[1]
170
- } catch {
171
- }
172
- return "Linux"
173
- case "darwin":
174
- return "macOS"
175
- case "win32":
176
- return "Windows"
177
- default:
178
- return platform
179
- }
180
- } catch {
181
- return "Unknown"
182
- }
183
- }
184
-
185
- // Map provider IDs to friendly display names
186
- const providerDisplayNames: Record<string, string> = {
187
- "openai": "OpenAI",
188
- "google": "Google",
189
- "github-copilot": "Copilot",
190
- "opencode-go": "OpenCode GO",
191
- "anthropic": "Claude",
192
- "deepseek": "DeepSeek",
193
- "openrouter": "OpenRouter",
194
- "mistral": "Mistral",
195
- "groq": "Groq",
196
- "cohere": "Cohere",
197
- "together": "Together",
198
- "perplexity": "Perplexity",
199
- }
200
-
201
- // Helper to detect active providers from OpenCode state
202
- const getProviders = (providers: ReadonlyArray<{ id: string; name: string }> | undefined): string => {
203
- if (!providers || providers.length === 0) {
204
- return "No providers configured"
205
- }
206
-
207
- // Map provider IDs to friendly names and deduplicate
208
- const names = new Set<string>()
209
- for (const provider of providers) {
210
- const displayName = providerDisplayNames[provider.id] || provider.name || provider.id
211
- names.add(displayName)
212
- }
213
-
214
- // Return compact comma-separated list
215
- return Array.from(names).sort().join(", ")
216
- }
217
-
218
- // Home logo: Mustache-only (simple and prominent) with grayscale gradient
219
- const HomeLogo = (props: { theme: TuiThemeCurrent }) => {
220
- // Grayscale palette for better TUI readability
221
- const lightGray = "#C0C0C0" // Light gray for highlights
222
- const midGray = "#808080" // Mid gray for main body
223
- const darkGray = "#505050" // Dark gray for shadows
224
-
225
- return (
226
- <box flexDirection="column" alignItems="center">
227
- {/* Mustache with grayscale gradient for depth */}
228
- {mustachiMustacheOnly.map((line, idx) => {
229
- const totalLines = mustachiMustacheOnly.length
230
- let color = midGray
231
- if (idx < totalLines / 3) {
232
- color = lightGray // Top highlight
233
- } else if (idx >= (2 * totalLines) / 3) {
234
- color = darkGray // Bottom shadow
235
- }
236
- return <text fg={color}>{line.padEnd(61, " ")}</text>
237
- })}
238
-
239
- {/* OpenCode branding */}
240
- <box flexDirection="row" gap={0} marginTop={1}>
241
- <text fg={props.theme.textMuted} dimColor={true}>╭ </text>
242
- <text fg={props.theme.primary} bold={true}> O p e n C o d e </text>
243
- <text fg={props.theme.textMuted} dimColor={true}> ╮</text>
244
- </box>
245
-
246
- <text> </text>
247
- </box>
248
- )
249
- }
250
-
251
- // Sidebar: Full Mustachi face with progressive animations (semantic zone colors)
252
- const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; isBusy?: boolean }) => {
253
- const [pupilIndex, setPupilIndex] = createSignal(0)
254
- const [blinkFrame, setBlinkFrame] = createSignal(0)
255
- const [tongueFrame, setTongueFrame] = createSignal(0)
256
- const [busyPhrase, setBusyPhrase] = createSignal("")
257
- const [expressiveCycle, setExpressiveCycle] = createSignal(false)
258
-
259
- // Animation: pupil movement (look around) - low frequency, progressive
260
- createEffect(() => {
261
- if (!props.config.animations || props.isBusy || expressiveCycle()) {
262
- setPupilIndex(0)
263
- return
264
- }
265
-
266
- const interval = setInterval(() => {
267
- // Cycle through pupil positions progressively
268
- setPupilIndex((prev) => {
269
- // 80% chance to stay at center, 20% to move
270
- if (Math.random() < 0.8) return 0
271
- return (prev + 1) % pupilPositionFrames.length
272
- })
273
- }, 3000)
274
-
275
- onCleanup(() => clearInterval(interval))
276
- })
277
-
278
- // Animation: blink - occasional, progressive
279
- createEffect(() => {
280
- if (!props.config.animations) return
281
-
282
- const blinkSequence = async () => {
283
- // Open -> half -> closed -> half -> open
284
- setBlinkFrame(0)
285
- await new Promise(r => setTimeout(r, 150))
286
- setBlinkFrame(1)
287
- await new Promise(r => setTimeout(r, 100))
288
- setBlinkFrame(2)
289
- await new Promise(r => setTimeout(r, 100))
290
- setBlinkFrame(1)
291
- await new Promise(r => setTimeout(r, 100))
292
- setBlinkFrame(0)
293
- }
294
-
295
- const interval = setInterval(() => {
296
- // Blink occasionally (15% chance every 4s)
297
- if (Math.random() < 0.15) {
298
- blinkSequence()
299
- }
300
- }, 4000)
301
-
302
- onCleanup(() => clearInterval(interval))
303
- })
304
-
305
- // Busy/expressive state animation: tongue + phrases
306
- // If isBusy is reliably reactive, use it; otherwise demonstrate expressiveness periodically
307
- createEffect(() => {
308
- if (!props.config.animations) {
309
- setTongueFrame(0)
310
- setBusyPhrase("")
311
- setExpressiveCycle(false)
312
- return
313
- }
314
-
315
- const shouldShowExpression = props.isBusy || expressiveCycle()
316
-
317
- if (!shouldShowExpression) {
318
- setTongueFrame(0)
319
- setBusyPhrase("")
320
- return
321
- }
322
-
323
- // Show tongue progressively when entering expressive state
324
- let currentFrame = 0
325
- let tongueTimeoutId: NodeJS.Timeout | undefined
326
- const growTongue = () => {
327
- if (currentFrame < tongueFrames.length - 1) {
328
- currentFrame++
329
- setTongueFrame(currentFrame)
330
- }
331
- }
332
- tongueTimeoutId = setTimeout(growTongue, 200)
333
-
334
- // Rotate busy phrases
335
- let phraseIdx = Math.floor(Math.random() * busyPhrases.length)
336
- setBusyPhrase(busyPhrases[phraseIdx])
337
-
338
- const interval = setInterval(() => {
339
- phraseIdx = (phraseIdx + 1) % busyPhrases.length
340
- setBusyPhrase(busyPhrases[phraseIdx])
341
- }, 3000)
342
-
343
- onCleanup(() => {
344
- clearInterval(interval)
345
- if (tongueTimeoutId !== undefined) {
346
- clearTimeout(tongueTimeoutId)
347
- }
348
- })
349
- })
350
-
351
- // Fallback: Periodic expressive cycle (conservative - every 45-60s for ~8s)
352
- // This ensures tongue + phrases are visibly demonstrated even if runtime busy state is unreliable
353
- createEffect(() => {
354
- if (!props.config.animations || props.isBusy) return
355
-
356
- const triggerExpressiveCycle = () => {
357
- setExpressiveCycle(true)
358
-
359
- // End expressive cycle after 8 seconds
360
- setTimeout(() => {
361
- setExpressiveCycle(false)
362
- }, 8000)
363
- }
364
-
365
- // First cycle after 30-45s, then every 45-60s
366
- const firstDelay = 30000 + Math.random() * 15000
367
- const firstTimeout = setTimeout(triggerExpressiveCycle, firstDelay)
368
-
369
- const interval = setInterval(() => {
370
- triggerExpressiveCycle()
371
- }, 45000 + Math.random() * 15000)
372
-
373
- onCleanup(() => {
374
- clearTimeout(firstTimeout)
375
- clearInterval(interval)
376
- })
377
- })
378
-
379
- // Build the complete Mustachi face
380
- const buildFace = () => {
381
- const lines: { content: string; zone: string }[] = []
382
-
383
- // Select eye frame based on state
384
- let eyeFrame = pupilPositionFrames[pupilIndex()]
385
-
386
- // Apply squint if busy/expressive
387
- if (props.isBusy || expressiveCycle()) {
388
- eyeFrame = eyeSquinted
389
- }
390
-
391
- // Apply blink animation if active
392
- if (blinkFrame() === 1) {
393
- eyeFrame = eyeBlinkHalf
394
- } else if (blinkFrame() === 2) {
395
- eyeFrame = eyeBlinkClosed
396
- }
397
-
398
- // Add eyes with zone metadata
399
- eyeFrame.forEach((line, idx) => {
400
- // Lines 0-1 are monocle border, lines 2-4 are eye interior
401
- const zone = idx < 2 ? "monocle" : "eyes"
402
- lines.push({ content: line, zone })
403
- })
404
-
405
- // Add mustache section
406
- mustachiMustacheSection.forEach(line => {
407
- lines.push({ content: line, zone: "mustache" })
408
- })
409
-
410
- // Add tongue if expressive (mark as tongue zone for pink color)
411
- if ((props.isBusy || expressiveCycle()) && tongueFrame() > 0) {
412
- const tongueLines = tongueFrames[tongueFrame()]
413
- tongueLines.forEach(line => {
414
- lines.push({ content: line, zone: "tongue" })
415
- })
416
- }
417
-
418
- return lines
419
- }
420
-
421
- // Semantic zone colors for better visual hierarchy
422
- const zoneColors = {
423
- monocle: "#A0A0A0", // Lighter gray for monocle border
424
- eyes: "#808080", // Mid gray for eyes
425
- mustache: "#606060", // Darker gray for mustache
426
- tongue: "#FF4466", // Pink/Red for tongue
427
- }
428
-
429
- return (
430
- <box flexDirection="column" alignItems="center">
431
- {/* Full Mustachi face with semantic zone colors */}
432
- {buildFace().map(({ content, zone }) => {
433
- const color = zoneColors[zone as keyof typeof zoneColors] || zoneColors.mustache
434
- const paddedLine = content.padEnd(27, " ")
435
- return <text fg={color}>{paddedLine}</text>
436
- })}
437
-
438
- {/* Busy phrase if loading */}
439
- {busyPhrase() && (
440
- <text fg={props.theme.warning}>{busyPhrase()}</text>
441
- )}
442
-
443
- <text> </text>
444
- </box>
445
- )
446
- }
447
-
448
- const DetectedEnv = (props: {
449
- theme: TuiThemeCurrent
450
- providers: ReadonlyArray<{ id: string; name: string }> | undefined
451
- config: Cfg
452
- }) => {
453
- if (!props.config.show_detected) return null
454
-
455
- const os = props.config.show_os ? getOSName() : null
456
- const providers = props.config.show_providers ? getProviders(props.providers) : null
457
-
458
- // Don't render if nothing to show
459
- if (!os && !providers) return null
460
-
461
- return (
462
- <box flexDirection="row" gap={1}>
463
- <text fg={props.theme.textMuted}>Detected:</text>
464
- {os && <text fg={props.theme.text}>{os}</text>}
465
- {os && providers && <text fg={props.theme.textMuted}>·</text>}
466
- {providers && <text fg={props.theme.text}>{providers}</text>}
467
- </box>
468
- )
469
- }
470
-
471
15
  const tui: TuiPlugin = async (api, options) => {
472
16
  const boot = cfg(rec(options))
473
17
  if (!boot.enabled) return