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 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 — single random phrase rotating every 3s *(sidebar only)*
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
- - Single motivational phrase rotating every 3 seconds (36+ phrase library)
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 rotation:** Currently 3000ms (3s) during expressive state
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
- " ██ ░░██ ██░░░░░██ ", // 27 chars - hollow pupil up-left
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
- " ██░░░ ██ ██░░░░░██ ", // 27 chars - hollow pupil up-right
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
- // 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
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 grayscale gradient for depth */}
148
+ {/* Mustache with theme-reactive gradient for depth */}
29
149
  {mustachiMustacheOnly.map((line, idx) => {
30
150
  const totalLines = mustachiMustacheOnly.length
31
- let color = midGray
151
+ let color = midTone
32
152
  if (idx < totalLines / 3) {
33
- color = lightGray // Top highlight
153
+ color = topTone // Top highlight
34
154
  } else if (idx >= (2 * totalLines) / 3) {
35
- color = darkGray // Bottom shadow
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={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>
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: { theme: TuiThemeCurrent; config: Cfg; isBusy?: boolean }) => {
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 and rotate through the library
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 = zoneColors[zone as keyof typeof zoneColors] || zoneColors.mustache
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
- {/* Display single busy phrase if loading */}
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.warning}>{busyPhrase()}</text>
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.3",
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
- return <SidebarMustachi theme={ctx.theme.current} config={value()} isBusy={isBusy()} />
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
  })