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 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
- // 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)
@@ -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 = zoneColors[zone as keyof typeof zoneColors] || zoneColors.mustache
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.warning}>{busyPhrase()}</text>
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",
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
  })