plugin-gentleman 1.0.6 → 1.0.8
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 +114 -39
- package/ascii-frames.ts +163 -0
- package/components.tsx +269 -0
- package/config.ts +39 -0
- package/detection.ts +61 -0
- package/package.json +6 -1
- package/phrases.ts +58 -0
- package/tui.tsx +4 -460
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
|
|
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
|
|
98
|
-
- **Mustache** rendered in
|
|
99
|
-
- **Tongue** that appears during busy
|
|
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
|
-
**
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
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
|
-
**
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
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,
|
|
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
|
-
**
|
|
137
|
-
- **Top:**
|
|
138
|
-
- **Middle:**
|
|
139
|
-
- **Bottom:**
|
|
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
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
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
|
|
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` —
|
|
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,
|
|
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:**
|
|
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
|
-
|
|
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.
|
|
330
|
-
2. Test
|
|
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
|
-
|
|
404
|
+
**Animation timing customization:**
|
|
335
405
|
|
|
336
|
-
|
|
337
|
-
-
|
|
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
|
-
**
|
|
340
|
-
- Edit the `busyPhrases` array in `tui.tsx` *(affects sidebar only)*
|
|
414
|
+
**Color customization:**
|
|
341
415
|
|
|
342
|
-
**
|
|
343
|
-
-
|
|
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
|
|
package/ascii-frames.ts
ADDED
|
@@ -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 - pupils center
|
|
13
|
+
" ██░░███░░██ ██░░░░░░░██ ", // 27 chars - pupils center
|
|
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 - pupils left
|
|
37
|
+
" ██████░░░██ ██░░░░░░░██ ", // 27 chars - pupils left
|
|
38
|
+
"██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
export const eyeNeutralRight = [
|
|
42
|
+
" █████ █████ ", // 27 chars
|
|
43
|
+
" ██░░░░░██ ██░░░░░██ ", // 27 chars
|
|
44
|
+
" ██░░░██████ ██░░░░░░░██ ", // 27 chars - pupils right
|
|
45
|
+
" ██░░░██████ ██░░░░░░░██ ", // 27 chars - pupils right
|
|
46
|
+
"██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
export const eyeNeutralUpLeft = [
|
|
50
|
+
" █████ █████ ", // 27 chars
|
|
51
|
+
" ███████░██ ██░░░░░██ ", // 27 chars - up-left diagonal
|
|
52
|
+
" ████████░██ ██░░░░░░░██ ", // 27 chars - up-left diagonal
|
|
53
|
+
" ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
|
|
54
|
+
"██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
export const eyeNeutralUpRight = [
|
|
58
|
+
" █████ █████ ", // 27 chars
|
|
59
|
+
" ██░███████ ██░░░░░██ ", // 27 chars - up-right diagonal
|
|
60
|
+
" ██░████████ ██░░░░░░░██ ", // 27 chars - up-right diagonal
|
|
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 - down-left diagonal
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
export const eyeNeutralDownRight = [
|
|
74
|
+
" █████ █████ ", // 27 chars
|
|
75
|
+
" ██░░░░░██ ██░░░░░██ ", // 27 chars
|
|
76
|
+
" ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
|
|
77
|
+
" ██░░░███████ ██░░░░░░░██ ", // 27 chars - down-right diagonal
|
|
78
|
+
"██ ██░████████ ██░░░░░██ ██", // 27 chars - down-right diagonal
|
|
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 - perfectly symmetric
|
|
111
|
+
"███████████████████████████", // 27 chars - perfectly symmetric
|
|
112
|
+
" █████████████████████████ ", // 27 chars - perfectly symmetric
|
|
113
|
+
" ▓█████████████████████▓ ", // 27 chars - perfectly symmetric
|
|
114
|
+
" ▓█████████████████▓ ", // 27 chars - perfectly symmetric
|
|
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,269 @@
|
|
|
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 [busyPhrase, setBusyPhrase] = createSignal("")
|
|
58
|
+
const [expressiveCycle, setExpressiveCycle] = createSignal(false)
|
|
59
|
+
|
|
60
|
+
// Animation: pupil movement (look around) - random transitions, not a sequence
|
|
61
|
+
createEffect(() => {
|
|
62
|
+
if (!props.config.animations || props.isBusy || expressiveCycle()) {
|
|
63
|
+
setPupilIndex(0)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const interval = setInterval(() => {
|
|
68
|
+
// Random eye movement - not sequential
|
|
69
|
+
// 60% chance to stay at center, 40% to move randomly
|
|
70
|
+
if (Math.random() < 0.6) {
|
|
71
|
+
setPupilIndex(0) // center
|
|
72
|
+
} else {
|
|
73
|
+
// Pick a random direction (skip center at index 0)
|
|
74
|
+
const randomDir = 1 + Math.floor(Math.random() * (pupilPositionFrames.length - 1))
|
|
75
|
+
setPupilIndex(randomDir)
|
|
76
|
+
}
|
|
77
|
+
}, 3000)
|
|
78
|
+
|
|
79
|
+
onCleanup(() => clearInterval(interval))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Animation: blink - occasional, progressive top-to-bottom motion
|
|
83
|
+
createEffect(() => {
|
|
84
|
+
if (!props.config.animations) return
|
|
85
|
+
|
|
86
|
+
const blinkSequence = async () => {
|
|
87
|
+
// Open -> half -> closed -> half -> open (normal eyelid motion)
|
|
88
|
+
setBlinkFrame(0)
|
|
89
|
+
await new Promise(r => setTimeout(r, 100))
|
|
90
|
+
setBlinkFrame(1)
|
|
91
|
+
await new Promise(r => setTimeout(r, 80))
|
|
92
|
+
setBlinkFrame(2)
|
|
93
|
+
await new Promise(r => setTimeout(r, 80))
|
|
94
|
+
setBlinkFrame(1)
|
|
95
|
+
await new Promise(r => setTimeout(r, 80))
|
|
96
|
+
setBlinkFrame(0)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const interval = setInterval(() => {
|
|
100
|
+
// Blink more frequently to feel alive (~every 5-6 seconds)
|
|
101
|
+
// 35% chance every 2s = average 5.7s between blinks
|
|
102
|
+
if (Math.random() < 0.35) {
|
|
103
|
+
blinkSequence()
|
|
104
|
+
}
|
|
105
|
+
}, 2000)
|
|
106
|
+
|
|
107
|
+
onCleanup(() => clearInterval(interval))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Busy/expressive state animation: tongue + single rotating phrase
|
|
111
|
+
// If isBusy is reliably reactive, use it; otherwise demonstrate expressiveness periodically
|
|
112
|
+
createEffect(() => {
|
|
113
|
+
if (!props.config.animations) {
|
|
114
|
+
setTongueFrame(0)
|
|
115
|
+
setBusyPhrase("")
|
|
116
|
+
setExpressiveCycle(false)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const shouldShowExpression = props.isBusy || expressiveCycle()
|
|
121
|
+
|
|
122
|
+
if (!shouldShowExpression) {
|
|
123
|
+
setTongueFrame(0)
|
|
124
|
+
setBusyPhrase("")
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Show tongue progressively when entering expressive state
|
|
129
|
+
let currentFrame = 0
|
|
130
|
+
let tongueTimeoutId: NodeJS.Timeout | undefined
|
|
131
|
+
const growTongue = () => {
|
|
132
|
+
if (currentFrame < tongueFrames.length - 1) {
|
|
133
|
+
currentFrame++
|
|
134
|
+
setTongueFrame(currentFrame)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
tongueTimeoutId = setTimeout(growTongue, 200)
|
|
138
|
+
|
|
139
|
+
// Pick a random phrase and rotate through the library
|
|
140
|
+
const pickRandomPhrase = () => {
|
|
141
|
+
const randomIndex = Math.floor(Math.random() * busyPhrases.length)
|
|
142
|
+
return busyPhrases[randomIndex]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
setBusyPhrase(pickRandomPhrase())
|
|
146
|
+
|
|
147
|
+
const interval = setInterval(() => {
|
|
148
|
+
setBusyPhrase(pickRandomPhrase())
|
|
149
|
+
}, 3000)
|
|
150
|
+
|
|
151
|
+
onCleanup(() => {
|
|
152
|
+
clearInterval(interval)
|
|
153
|
+
if (tongueTimeoutId !== undefined) {
|
|
154
|
+
clearTimeout(tongueTimeoutId)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Fallback: Periodic expressive cycle (conservative - every ~45-60 seconds for ~8s)
|
|
160
|
+
// This ensures tongue + phrases are visibly demonstrated even if runtime busy state is unreliable
|
|
161
|
+
createEffect(() => {
|
|
162
|
+
if (!props.config.animations || props.isBusy) return
|
|
163
|
+
|
|
164
|
+
const triggerExpressiveCycle = () => {
|
|
165
|
+
setExpressiveCycle(true)
|
|
166
|
+
|
|
167
|
+
// End expressive cycle after 8 seconds
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
setExpressiveCycle(false)
|
|
170
|
+
}, 8000)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// First cycle after 30-45s, then every 45-60s
|
|
174
|
+
const firstDelay = 30000 + Math.random() * 15000
|
|
175
|
+
const firstTimeout = setTimeout(triggerExpressiveCycle, firstDelay)
|
|
176
|
+
|
|
177
|
+
const interval = setInterval(() => {
|
|
178
|
+
triggerExpressiveCycle()
|
|
179
|
+
}, 45000 + Math.random() * 15000)
|
|
180
|
+
|
|
181
|
+
onCleanup(() => {
|
|
182
|
+
clearTimeout(firstTimeout)
|
|
183
|
+
clearInterval(interval)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Build the complete Mustachi face
|
|
188
|
+
const buildFace = () => {
|
|
189
|
+
const lines: { content: string; zone: string }[] = []
|
|
190
|
+
|
|
191
|
+
// Select eye frame based on state
|
|
192
|
+
let eyeFrame = pupilPositionFrames[pupilIndex()]
|
|
193
|
+
|
|
194
|
+
// Apply squint if busy/expressive
|
|
195
|
+
if (props.isBusy || expressiveCycle()) {
|
|
196
|
+
eyeFrame = eyeSquinted
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Apply blink animation if active
|
|
200
|
+
if (blinkFrame() === 1) {
|
|
201
|
+
eyeFrame = eyeBlinkHalf
|
|
202
|
+
} else if (blinkFrame() === 2) {
|
|
203
|
+
eyeFrame = eyeBlinkClosed
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Add eyes with zone metadata
|
|
207
|
+
eyeFrame.forEach((line, idx) => {
|
|
208
|
+
// Lines 0-1 are monocle border, lines 2-4 are eye interior
|
|
209
|
+
const zone = idx < 2 ? "monocle" : "eyes"
|
|
210
|
+
lines.push({ content: line, zone })
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Add mustache section
|
|
214
|
+
mustachiMustacheSection.forEach(line => {
|
|
215
|
+
lines.push({ content: line, zone: "mustache" })
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Add tongue if expressive (mark as tongue zone for pink color)
|
|
219
|
+
if ((props.isBusy || expressiveCycle()) && tongueFrame() > 0) {
|
|
220
|
+
const tongueLines = tongueFrames[tongueFrame()]
|
|
221
|
+
tongueLines.forEach(line => {
|
|
222
|
+
lines.push({ content: line, zone: "tongue" })
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return lines
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<box flexDirection="column" alignItems="center">
|
|
231
|
+
{/* Full Mustachi face with semantic zone colors */}
|
|
232
|
+
{buildFace().map(({ content, zone }) => {
|
|
233
|
+
const color = zoneColors[zone as keyof typeof zoneColors] || zoneColors.mustache
|
|
234
|
+
const paddedLine = content.padEnd(27, " ")
|
|
235
|
+
return <text fg={color}>{paddedLine}</text>
|
|
236
|
+
})}
|
|
237
|
+
|
|
238
|
+
{/* Display single busy phrase if loading */}
|
|
239
|
+
{busyPhrase() && (
|
|
240
|
+
<text fg={props.theme.warning}>{busyPhrase()}</text>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
<text> </text>
|
|
244
|
+
</box>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const DetectedEnv = (props: {
|
|
249
|
+
theme: TuiThemeCurrent
|
|
250
|
+
providers: ReadonlyArray<{ id: string; name: string }> | undefined
|
|
251
|
+
config: Cfg
|
|
252
|
+
}) => {
|
|
253
|
+
if (!props.config.show_detected) return null
|
|
254
|
+
|
|
255
|
+
const os = props.config.show_os ? getOSName() : null
|
|
256
|
+
const providers = props.config.show_providers ? getProviders(props.providers) : null
|
|
257
|
+
|
|
258
|
+
// Don't render if nothing to show
|
|
259
|
+
if (!os && !providers) return null
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<box flexDirection="row" gap={1}>
|
|
263
|
+
<text fg={props.theme.textMuted}>Detected:</text>
|
|
264
|
+
{os && <text fg={props.theme.text}>{os}</text>}
|
|
265
|
+
{os && providers && <text fg={props.theme.textMuted}>·</text>}
|
|
266
|
+
{providers && <text fg={props.theme.text}>{providers}</text>}
|
|
267
|
+
</box>
|
|
268
|
+
)
|
|
269
|
+
}
|
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
|
+
"version": "1.0.8",
|
|
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,58 @@
|
|
|
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
|
+
|
|
42
|
+
// Dev jokes (Argentina + Spain friendly)
|
|
43
|
+
"¿Qué le dice un bit a otro? Nos vemos en el bus",
|
|
44
|
+
"Hay 10 tipos de personas: las que entienden binario y las que no",
|
|
45
|
+
"¿Por qué los programadores confunden Halloween con Navidad? Porque Oct 31 = Dec 25",
|
|
46
|
+
"Mi código no tiene bugs, desarrolla características inesperadas",
|
|
47
|
+
"Un SQL entra a un bar, ve dos tablas y pregunta: ¿Puedo unirme?",
|
|
48
|
+
"¿Cuál es el animal favorito de un programador? El bug",
|
|
49
|
+
"No es un bug, es una funcionalidad no documentada",
|
|
50
|
+
"Hay dos cosas difíciles en programación: naming y cache invalidation",
|
|
51
|
+
"¿Qué hace un programador en la playa? Hace surf... por la web",
|
|
52
|
+
"Código que funciona en mi máquina™",
|
|
53
|
+
"99 little bugs in the code, 99 bugs to fix... take one down, patch it around, 127 little bugs in the code",
|
|
54
|
+
"¿Por qué los programadores prefieren el modo oscuro? Porque la luz atrae bugs",
|
|
55
|
+
"Debugging: ser el detective en una novela de crimen donde también sos el asesino",
|
|
56
|
+
"¡Todo compila! (pero no hace lo que debería)",
|
|
57
|
+
"Si depurar es quitar bugs, programar debe ser ponerlos",
|
|
58
|
+
]
|
package/tui.tsx
CHANGED
|
@@ -1,473 +1,17 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
/** @jsxImportSource @opentui/solid */
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
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
|