plugin-gentleman 1.1.3 → 1.1.5
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 +11 -3
- package/ascii-frames.ts +2 -2
- package/components.tsx +164 -21
- package/config.ts +2 -0
- package/package.json +2 -2
- package/phrases.ts +20 -0
- package/tui.tsx +13 -1
package/README.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Plugin Gentleman
|
|
2
2
|
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="./public/images/Mostacho.png" alt="Mostacho home preview" width="48%" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="./public/images/Mustachi.png" alt="Mustachi sidebar preview" width="48%" />
|
|
9
|
+
</p>
|
|
10
|
+
|
|
3
11
|
> **For the Gentleman Programming community** — Bringing Mustachi, our beloved mascot, into your OpenCode terminal.
|
|
4
12
|
|
|
5
13
|
An OpenCode TUI plugin crafted for the Gentleman Programming community. Mustachi, the official mascot of Gentleman Programming, now accompanies you through your coding sessions with visual flair and environment awareness:
|
|
@@ -98,7 +106,7 @@ The ASCII representation features:
|
|
|
98
106
|
- **Eyes** that blink and look in 8 directions (center, up, down, left, right, and 4 diagonals) *(sidebar only)*
|
|
99
107
|
- **Mustache** rendered in grayscale gradient on home screen, semantic zone colors in sidebar
|
|
100
108
|
- **Tongue** that appears during busy states or periodic expressive cycles *(sidebar only)*
|
|
101
|
-
- **Motivational phrases** in Rioplatense Spanish style —
|
|
109
|
+
- **Motivational phrases** in Rioplatense Spanish style — one random phrase per expressive cycle *(sidebar only)*
|
|
102
110
|
|
|
103
111
|
**Example phrases during busy states:**
|
|
104
112
|
- *"Ponete las pilas, hermano..."*
|
|
@@ -126,7 +134,7 @@ The ASCII representation features:
|
|
|
126
134
|
**Busy/Expressive State** *(when `animations: true`)*
|
|
127
135
|
- Tongue appears when OpenCode is processing
|
|
128
136
|
- Eyes squint during expressive state
|
|
129
|
-
-
|
|
137
|
+
- A single motivational phrase is chosen for each expressive cycle or busy state (36+ phrase library)
|
|
130
138
|
- Active during detected busy states OR periodic expressive cycles
|
|
131
139
|
|
|
132
140
|
**Expressive Cycle Fallback** *(when `animations: true`)*
|
|
@@ -407,7 +415,7 @@ All animation intervals are in `components.tsx`:
|
|
|
407
415
|
- **Look-around interval:** Currently 3000ms (3s)
|
|
408
416
|
- **Blink interval:** Currently 2000ms with 35% chance (~5-6s average)
|
|
409
417
|
- **Blink frame timing:** Currently 80-100ms per frame progression
|
|
410
|
-
- **Phrase
|
|
418
|
+
- **Phrase selection:** One random phrase per expressive cycle or busy state
|
|
411
419
|
- **Expressive cycle timing:** First cycle at 30-45s, then every 45-60s
|
|
412
420
|
- **Expressive cycle duration:** Currently 8000ms (8s)
|
|
413
421
|
|
package/ascii-frames.ts
CHANGED
|
@@ -48,7 +48,7 @@ export const eyeNeutralRight = [
|
|
|
48
48
|
|
|
49
49
|
export const eyeNeutralUpLeft = [
|
|
50
50
|
" █████ █████ ", // 27 chars
|
|
51
|
-
" ██ ░░██
|
|
51
|
+
" ██ ░░██ ███░░░░██ ", // 27 chars - hollow pupil up-left
|
|
52
52
|
" ██ ░░░░██ ██░░░░░░░██ ", // 27 chars - hollow pupil up-left
|
|
53
53
|
" ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
|
|
54
54
|
"██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
|
|
@@ -56,7 +56,7 @@ export const eyeNeutralUpLeft = [
|
|
|
56
56
|
|
|
57
57
|
export const eyeNeutralUpRight = [
|
|
58
58
|
" ██████ █████ ", // 27 chars
|
|
59
|
-
" ██░░░ ██
|
|
59
|
+
" ██░░░ ██ ███░░░░██ ", // 27 chars - hollow pupil up-right
|
|
60
60
|
" ██░░░░ ██ ██░░░░░░░██ ", // 27 chars - hollow pupil up-right
|
|
61
61
|
" ██░░░░░░░██ ██░░░░░░░██ ", // 27 chars
|
|
62
62
|
"██ ██░░░░░██ ██░░░░░██ ██", // 27 chars
|
package/components.tsx
CHANGED
|
@@ -16,32 +16,152 @@ import {
|
|
|
16
16
|
} from "./ascii-frames"
|
|
17
17
|
import { busyPhrases } from "./phrases"
|
|
18
18
|
|
|
19
|
+
export type SemanticZone = "monocle" | "eyes" | "mustache" | "tongue" | "unknown"
|
|
20
|
+
|
|
21
|
+
export function getZoneColor(zone: SemanticZone | string, theme?: TuiThemeCurrent): string {
|
|
22
|
+
switch (zone) {
|
|
23
|
+
case "monocle":
|
|
24
|
+
return theme?.accent || zoneColors.monocle
|
|
25
|
+
case "eyes":
|
|
26
|
+
return theme?.primary || zoneColors.eyes
|
|
27
|
+
case "mustache":
|
|
28
|
+
return theme?.secondary || zoneColors.mustache
|
|
29
|
+
case "tongue":
|
|
30
|
+
return theme?.warning || zoneColors.tongue
|
|
31
|
+
default:
|
|
32
|
+
return theme?.textMuted || zoneColors.mustache
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const toNumber = (value: unknown): number => {
|
|
37
|
+
if (typeof value !== "number") return 0
|
|
38
|
+
if (!Number.isFinite(value)) return 0
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const formatTokens = (tokens: number): string => {
|
|
43
|
+
const value = Math.max(0, toNumber(tokens))
|
|
44
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
|
45
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
|
46
|
+
return `${Math.round(value)}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const formatCost = (cost: number): string => {
|
|
50
|
+
const value = Math.max(0, toNumber(cost))
|
|
51
|
+
return `$${value.toFixed(2)}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const getPct = (value: number, total: number): number => {
|
|
55
|
+
const safeValue = Math.max(0, toNumber(value))
|
|
56
|
+
const safeTotal = Math.max(0, toNumber(total))
|
|
57
|
+
if (!safeTotal) return 0
|
|
58
|
+
return Math.min(100, Math.round((safeValue / safeTotal) * 100))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const getMessageRole = (message: any): string => {
|
|
62
|
+
return (
|
|
63
|
+
message?.role ??
|
|
64
|
+
message?.message?.role ??
|
|
65
|
+
message?.author?.role ??
|
|
66
|
+
""
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const getTokenUsage = (message: any): any => {
|
|
71
|
+
return message?.tokenUsage ?? message?.usage ?? message?.tokens ?? message?.token_usage ?? {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hasTokenData = (message: any): boolean => {
|
|
75
|
+
const usage = getTokenUsage(message)
|
|
76
|
+
return (
|
|
77
|
+
typeof message?.tokens === "number" ||
|
|
78
|
+
typeof message?.total_tokens === "number" ||
|
|
79
|
+
typeof usage?.total === "number" ||
|
|
80
|
+
typeof usage?.total_tokens === "number" ||
|
|
81
|
+
typeof usage?.input === "number" ||
|
|
82
|
+
typeof usage?.input_tokens === "number" ||
|
|
83
|
+
typeof usage?.prompt_tokens === "number" ||
|
|
84
|
+
typeof usage?.output === "number" ||
|
|
85
|
+
typeof usage?.output_tokens === "number" ||
|
|
86
|
+
typeof usage?.completion_tokens === "number"
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const getContextTokens = (message: any): number => {
|
|
91
|
+
const usage = getTokenUsage(message)
|
|
92
|
+
const direct = toNumber(message?.tokens)
|
|
93
|
+
if (direct > 0) return direct
|
|
94
|
+
|
|
95
|
+
const total = toNumber(usage?.total || usage?.total_tokens || message?.total_tokens)
|
|
96
|
+
if (total > 0) return total
|
|
97
|
+
|
|
98
|
+
const input = toNumber(usage?.input || usage?.input_tokens || usage?.prompt_tokens)
|
|
99
|
+
const output = toNumber(usage?.output || usage?.output_tokens || usage?.completion_tokens)
|
|
100
|
+
return Math.max(0, input + output)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const getMessageCost = (message: any): number => {
|
|
104
|
+
const usage = getTokenUsage(message)
|
|
105
|
+
return Math.max(
|
|
106
|
+
0,
|
|
107
|
+
toNumber(
|
|
108
|
+
message?.cost_usd ??
|
|
109
|
+
message?.cost ??
|
|
110
|
+
message?.total_cost ??
|
|
111
|
+
usage?.cost ??
|
|
112
|
+
usage?.cost_usd,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ProgressBar = (props: {
|
|
118
|
+
theme?: TuiThemeCurrent
|
|
119
|
+
totalTokens: number
|
|
120
|
+
totalCost: number
|
|
121
|
+
contextLimit?: number
|
|
122
|
+
}) => {
|
|
123
|
+
const safeLimit = Math.max(0, toNumber(props.contextLimit))
|
|
124
|
+
const usagePct = getPct(props.totalTokens, safeLimit)
|
|
125
|
+
const barWidth = 18
|
|
126
|
+
const filled = Math.round((usagePct / 100) * barWidth)
|
|
127
|
+
const bar = `${"█".repeat(filled)}${"▒".repeat(Math.max(0, barWidth - filled))}`
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<box flexDirection="column" alignItems="center" marginTop={1}>
|
|
131
|
+
<text fg={props.theme?.textMuted ?? zoneColors.mustache}>Tokens: {formatTokens(props.totalTokens)}</text>
|
|
132
|
+
<text fg={props.theme?.accent ?? zoneColors.monocle}>Usage: {usagePct}% {bar}</text>
|
|
133
|
+
<text fg={props.theme?.textMuted ?? zoneColors.mustache}>Cost: {formatCost(props.totalCost)}</text>
|
|
134
|
+
</box>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
19
138
|
// Home logo: Mustache-only (simple and prominent) with grayscale gradient
|
|
20
139
|
export const HomeLogo = (props: { theme: TuiThemeCurrent }) => {
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
140
|
+
const topTone = getZoneColor("monocle", props.theme)
|
|
141
|
+
const midTone = getZoneColor("eyes", props.theme)
|
|
142
|
+
const bottomTone = getZoneColor("mustache", props.theme)
|
|
143
|
+
const mutedBranding = props.theme?.textMuted ?? "#888888"
|
|
144
|
+
const primaryBranding = props.theme?.primary ?? "#FFFFFF"
|
|
25
145
|
|
|
26
146
|
return (
|
|
27
147
|
<box flexDirection="column" alignItems="center">
|
|
28
|
-
{/* Mustache with
|
|
148
|
+
{/* Mustache with theme-reactive gradient for depth */}
|
|
29
149
|
{mustachiMustacheOnly.map((line, idx) => {
|
|
30
150
|
const totalLines = mustachiMustacheOnly.length
|
|
31
|
-
let color =
|
|
151
|
+
let color = midTone
|
|
32
152
|
if (idx < totalLines / 3) {
|
|
33
|
-
color =
|
|
153
|
+
color = topTone // Top highlight
|
|
34
154
|
} else if (idx >= (2 * totalLines) / 3) {
|
|
35
|
-
color =
|
|
155
|
+
color = bottomTone // Bottom shadow
|
|
36
156
|
}
|
|
37
157
|
return <text fg={color}>{line.padEnd(61, " ")}</text>
|
|
38
158
|
})}
|
|
39
159
|
|
|
40
160
|
{/* OpenCode branding */}
|
|
41
161
|
<box flexDirection="row" gap={0} marginTop={1}>
|
|
42
|
-
<text fg={
|
|
43
|
-
<text fg={
|
|
44
|
-
<text fg={
|
|
162
|
+
<text fg={mutedBranding} dimColor={true}>╭ </text>
|
|
163
|
+
<text fg={primaryBranding} bold={true}> O p e n C o d e </text>
|
|
164
|
+
<text fg={mutedBranding} dimColor={true}> ╮</text>
|
|
45
165
|
</box>
|
|
46
166
|
|
|
47
167
|
<text> </text>
|
|
@@ -50,7 +170,14 @@ export const HomeLogo = (props: { theme: TuiThemeCurrent }) => {
|
|
|
50
170
|
}
|
|
51
171
|
|
|
52
172
|
// Sidebar: Full Mustachi face with progressive animations (semantic zone colors)
|
|
53
|
-
export const SidebarMustachi = (props: {
|
|
173
|
+
export const SidebarMustachi = (props: {
|
|
174
|
+
theme: TuiThemeCurrent
|
|
175
|
+
config: Cfg
|
|
176
|
+
isBusy?: boolean
|
|
177
|
+
branch?: string
|
|
178
|
+
getMessages?: () => any[]
|
|
179
|
+
contextLimit?: number
|
|
180
|
+
}) => {
|
|
54
181
|
const [pupilIndex, setPupilIndex] = createSignal(0)
|
|
55
182
|
const [blinkFrame, setBlinkFrame] = createSignal(0)
|
|
56
183
|
const [tongueFrame, setTongueFrame] = createSignal(0)
|
|
@@ -136,7 +263,7 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
|
|
|
136
263
|
}
|
|
137
264
|
tongueTimeoutId = setTimeout(growTongue, 200)
|
|
138
265
|
|
|
139
|
-
// Pick a random phrase
|
|
266
|
+
// Pick a single random phrase for this expressive cycle/state
|
|
140
267
|
const pickRandomPhrase = () => {
|
|
141
268
|
const randomIndex = Math.floor(Math.random() * busyPhrases.length)
|
|
142
269
|
return busyPhrases[randomIndex]
|
|
@@ -144,12 +271,7 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
|
|
|
144
271
|
|
|
145
272
|
setBusyPhrase(pickRandomPhrase())
|
|
146
273
|
|
|
147
|
-
const interval = setInterval(() => {
|
|
148
|
-
setBusyPhrase(pickRandomPhrase())
|
|
149
|
-
}, 3000)
|
|
150
|
-
|
|
151
274
|
onCleanup(() => {
|
|
152
|
-
clearInterval(interval)
|
|
153
275
|
if (tongueTimeoutId !== undefined) {
|
|
154
276
|
clearTimeout(tongueTimeoutId)
|
|
155
277
|
}
|
|
@@ -233,18 +355,39 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
|
|
|
233
355
|
return lines
|
|
234
356
|
}
|
|
235
357
|
|
|
358
|
+
const branchLabel = props.branch?.trim()
|
|
359
|
+
const messages = props.getMessages?.() ?? []
|
|
360
|
+
const assistantMessages = messages.filter((message: any) => getMessageRole(message) === "assistant")
|
|
361
|
+
|
|
362
|
+
const lastAssistantWithTokens = [...assistantMessages].reverse().find((message: any) => hasTokenData(message))
|
|
363
|
+
const contextTokens = getContextTokens(lastAssistantWithTokens)
|
|
364
|
+
const totalCost = assistantMessages.reduce((sum: number, message: any) => sum + getMessageCost(message), 0)
|
|
365
|
+
|
|
236
366
|
return (
|
|
237
367
|
<box flexDirection="column" alignItems="center">
|
|
238
368
|
{/* Full Mustachi face with semantic zone colors */}
|
|
239
369
|
{buildFace().map(({ content, zone }) => {
|
|
240
|
-
const color =
|
|
370
|
+
const color = getZoneColor(zone, props.theme)
|
|
241
371
|
const paddedLine = content.padEnd(27, " ")
|
|
242
372
|
return <text fg={color}>{paddedLine}</text>
|
|
243
373
|
})}
|
|
244
374
|
|
|
245
|
-
{
|
|
375
|
+
{branchLabel && (
|
|
376
|
+
<text fg={props.theme?.textMuted ?? zoneColors.mustache}>⎇ {branchLabel}</text>
|
|
377
|
+
)}
|
|
378
|
+
|
|
379
|
+
{props.config.show_metrics && (
|
|
380
|
+
<ProgressBar
|
|
381
|
+
theme={props.theme}
|
|
382
|
+
totalTokens={contextTokens}
|
|
383
|
+
totalCost={totalCost}
|
|
384
|
+
contextLimit={props.contextLimit}
|
|
385
|
+
/>
|
|
386
|
+
)}
|
|
387
|
+
|
|
388
|
+
{/* Display a single busy phrase for the current expressive cycle */}
|
|
246
389
|
{busyPhrase() && (
|
|
247
|
-
<text fg={props.theme.
|
|
390
|
+
<text fg={props.theme?.warning ?? zoneColors.tongue}>{busyPhrase()}</text>
|
|
248
391
|
)}
|
|
249
392
|
|
|
250
393
|
<text> </text>
|
package/config.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type Cfg = {
|
|
|
7
7
|
show_detected: boolean
|
|
8
8
|
show_os: boolean
|
|
9
9
|
show_providers: boolean
|
|
10
|
+
show_metrics: boolean
|
|
10
11
|
animations: boolean
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -34,6 +35,7 @@ export const cfg = (opts: Record<string, unknown> | undefined): Cfg => {
|
|
|
34
35
|
show_detected: bool(opts?.show_detected, true),
|
|
35
36
|
show_os: bool(opts?.show_os, true),
|
|
36
37
|
show_providers: bool(opts?.show_providers, true),
|
|
38
|
+
show_metrics: bool(opts?.show_metrics, true),
|
|
37
39
|
animations: bool(opts?.animations, true),
|
|
38
40
|
}
|
|
39
41
|
}
|
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.1.
|
|
4
|
+
"version": "1.1.5",
|
|
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": {
|
|
@@ -55,4 +55,4 @@
|
|
|
55
55
|
"bugs": {
|
|
56
56
|
"url": "https://github.com/IrrealV/plugin-gentleman/issues"
|
|
57
57
|
}
|
|
58
|
-
}
|
|
58
|
+
}
|
package/phrases.ts
CHANGED
|
@@ -55,4 +55,24 @@ export const busyPhrases = [
|
|
|
55
55
|
"Debugging: ser el detective en una novela de crimen donde también sos el asesino",
|
|
56
56
|
"¡Todo compila! (pero no hace lo que debería)",
|
|
57
57
|
"Si depurar es quitar bugs, programar debe ser ponerlos",
|
|
58
|
+
"Esto va más lento que tortuga con asma, pero va",
|
|
59
|
+
"¿Qué hace una abeja en el gimnasio? Zum-ba",
|
|
60
|
+
"Estoy más ocupado que mozo en Día de la Madre",
|
|
61
|
+
"¿Qué le dice una pared a otra? Nos vemos en la esquina",
|
|
62
|
+
"Más despacio que caracol con resaca... pero seguro",
|
|
63
|
+
"¿Cómo se despiden los químicos? Ácido un placer",
|
|
64
|
+
"Va tomando forma, como puré instantáneo",
|
|
65
|
+
"¿Qué hace un pez? Nada",
|
|
66
|
+
"No es lentitud, es suspenso de alta calidad",
|
|
67
|
+
"¿Cuál es el colmo de un jardinero? Que siempre lo dejen plantado",
|
|
68
|
+
"Estoy acomodando los patitos en fila",
|
|
69
|
+
"¿Qué le dice el 0 al 8? Lindo cinturón",
|
|
70
|
+
"Esto sale calentito, como medialuna de panadería",
|
|
71
|
+
"¿Cómo maldice un pollito a otro? Caldito seas",
|
|
72
|
+
"Un cachito más y queda pipí cucú",
|
|
73
|
+
"¿Cuál es el café más peligroso? El ex-preso",
|
|
74
|
+
"Más firme que televisor de bar en la final",
|
|
75
|
+
"¿Qué le dijo una impresora a otra? ¿Esa hoja es tuya o es impresión mía?",
|
|
76
|
+
"Estoy cerrando con moñito, bancame un toque",
|
|
77
|
+
"¿Cuál es el colmo de un electricista? No encontrar su corriente de trabajo",
|
|
58
78
|
]
|
package/tui.tsx
CHANGED
|
@@ -61,7 +61,19 @@ const tui: TuiPlugin = async (api, options) => {
|
|
|
61
61
|
return <DetectedEnv theme={ctx.theme.current} providers={api.state.provider} config={value()} />
|
|
62
62
|
},
|
|
63
63
|
sidebar_content(ctx) {
|
|
64
|
-
|
|
64
|
+
// Extract sessionID from ctx.value parameter
|
|
65
|
+
const sessionID = ctx.value?.sessionID
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<SidebarMustachi
|
|
69
|
+
theme={ctx.theme.current}
|
|
70
|
+
config={value()}
|
|
71
|
+
isBusy={isBusy()}
|
|
72
|
+
branch={api.state.vcs?.branch}
|
|
73
|
+
getMessages={() => sessionID ? api.state.session.messages(sessionID) : []}
|
|
74
|
+
contextLimit={1_000_000}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
65
77
|
},
|
|
66
78
|
},
|
|
67
79
|
})
|