plugin-gentleman 1.1.4 → 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 +8 -0
- package/components.tsx +162 -14
- 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:
|
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)
|
|
@@ -228,18 +355,39 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
|
|
|
228
355
|
return lines
|
|
229
356
|
}
|
|
230
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
|
+
|
|
231
366
|
return (
|
|
232
367
|
<box flexDirection="column" alignItems="center">
|
|
233
368
|
{/* Full Mustachi face with semantic zone colors */}
|
|
234
369
|
{buildFace().map(({ content, zone }) => {
|
|
235
|
-
const color =
|
|
370
|
+
const color = getZoneColor(zone, props.theme)
|
|
236
371
|
const paddedLine = content.padEnd(27, " ")
|
|
237
372
|
return <text fg={color}>{paddedLine}</text>
|
|
238
373
|
})}
|
|
239
374
|
|
|
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
|
+
|
|
240
388
|
{/* Display a single busy phrase for the current expressive cycle */}
|
|
241
389
|
{busyPhrase() && (
|
|
242
|
-
<text fg={props.theme.
|
|
390
|
+
<text fg={props.theme?.warning ?? zoneColors.tongue}>{busyPhrase()}</text>
|
|
243
391
|
)}
|
|
244
392
|
|
|
245
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
|
})
|