plugin-gentleman 1.1.4 → 1.1.6

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
@@ -1,9 +1,9 @@
1
1
  // @ts-nocheck
2
2
  /** @jsxImportSource @opentui/solid */
3
3
  import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
4
- import { createSignal, onCleanup, createEffect } from "solid-js"
4
+ import { createSignal, onCleanup, createEffect, createMemo } from "solid-js"
5
5
  import type { Cfg } from "./config"
6
- import { getOSName, getProviders } from "./detection"
6
+ import { detectPrimaryStackContext, getOSName, getProviders, type DetectedStack } from "./detection"
7
7
  import {
8
8
  pupilPositionFrames,
9
9
  eyeSquinted,
@@ -14,34 +14,230 @@ import {
14
14
  mustachiMustacheOnly,
15
15
  zoneColors,
16
16
  } from "./ascii-frames"
17
- import { busyPhrases } from "./phrases"
17
+ import { pickBusyPhrase } from "./phrases"
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 ellipsize = (value: string, maxLength: number): string => {
118
+ if (value.length <= maxLength) return value
119
+ if (maxLength <= 3) return value.slice(0, maxLength)
120
+ return `${value.slice(0, maxLength - 3)}...`
121
+ }
122
+
123
+ const resolveProp = <T,>(value: T | (() => T) | undefined): T | undefined => {
124
+ if (typeof value === "function") {
125
+ return (value as () => T)()
126
+ }
127
+ return value
128
+ }
129
+
130
+ const rightEyeStackMark: Record<DetectedStack, string> = {
131
+ react: "R",
132
+ angular: "A",
133
+ vue: "V",
134
+ node: "N",
135
+ go: "G",
136
+ python: "P",
137
+ dotnet: "D",
138
+ svelte: "S",
139
+ nextjs: "X",
140
+ rust: "U",
141
+ }
142
+
143
+ const replaceCharAt = (line: string, index: number, value: string): string => {
144
+ if (index < 0 || index >= line.length) return line
145
+ return `${line.slice(0, index)}${value}${line.slice(index + 1)}`
146
+ }
147
+
148
+ const applyRightEyeContextualMark = (frame: string[], stack: DetectedStack | undefined): string[] => {
149
+ if (!stack) return frame
150
+ const marker = rightEyeStackMark[stack]
151
+ if (!marker) return frame
152
+
153
+ return frame.map((line, idx) => {
154
+ if (idx < 2 || idx > 3) return line
155
+ return replaceCharAt(line, 20, marker)
156
+ })
157
+ }
158
+
159
+ type MustachiVisualState = "idle" | "thinking" | "working"
160
+
161
+ const getRuntimeVisualHint = (runtimeContext: any): MustachiVisualState | undefined => {
162
+ try {
163
+ const runtime = runtimeContext?.runtime ?? runtimeContext
164
+ const status = String(runtime?.status ?? runtime?.state ?? runtime?.phase ?? "").toLowerCase()
165
+ const runningSignal = runtime?.running ?? runtimeContext?.session?.running
166
+
167
+ if (runningSignal === true) return "working"
168
+ if (status.includes("work") || status.includes("run") || status.includes("load") || status.includes("generat")) {
169
+ return "working"
170
+ }
171
+ if (status.includes("think") || status.includes("reason") || status.includes("plan")) {
172
+ return "thinking"
173
+ }
174
+ return
175
+ } catch {
176
+ return
177
+ }
178
+ }
179
+
180
+ const resolveVisualState = (input: {
181
+ isBusy: boolean
182
+ runtimeHint?: MustachiVisualState
183
+ expressiveCycle: boolean
184
+ }): MustachiVisualState => {
185
+ if (input.isBusy || input.runtimeHint === "working") return "working"
186
+ if (input.expressiveCycle || input.runtimeHint === "thinking") return "thinking"
187
+ return "idle"
188
+ }
189
+
190
+ const ProgressBar = (props: {
191
+ theme?: TuiThemeCurrent
192
+ totalTokens: number
193
+ totalCost: number
194
+ contextLimit?: number
195
+ }) => {
196
+ const safeLimit = Math.max(0, toNumber(props.contextLimit))
197
+ const hasContextLimit = safeLimit > 0
198
+ const usagePct = getPct(props.totalTokens, safeLimit)
199
+ const barWidth = 18
200
+ const filled = Math.round((usagePct / 100) * barWidth)
201
+ const bar = `${"█".repeat(filled)}${"▒".repeat(Math.max(0, barWidth - filled))}`
202
+
203
+ return (
204
+ <box flexDirection="column" alignItems="center" marginTop={1}>
205
+ <text fg={props.theme?.textMuted ?? zoneColors.mustache}>Tokens: {formatTokens(props.totalTokens)}</text>
206
+ {hasContextLimit && (
207
+ <text fg={props.theme?.accent ?? zoneColors.monocle}>Usage: {usagePct}% {bar}</text>
208
+ )}
209
+ <text fg={props.theme?.textMuted ?? zoneColors.mustache}>Cost: {formatCost(props.totalCost)}</text>
210
+ </box>
211
+ )
212
+ }
18
213
 
19
214
  // Home logo: Mustache-only (simple and prominent) with grayscale gradient
20
215
  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
216
+ const topTone = getZoneColor("monocle", props.theme)
217
+ const midTone = getZoneColor("eyes", props.theme)
218
+ const bottomTone = getZoneColor("mustache", props.theme)
219
+ const mutedBranding = props.theme?.textMuted ?? "#888888"
220
+ const primaryBranding = props.theme?.primary ?? "#FFFFFF"
25
221
 
26
222
  return (
27
223
  <box flexDirection="column" alignItems="center">
28
- {/* Mustache with grayscale gradient for depth */}
224
+ {/* Mustache with theme-reactive gradient for depth */}
29
225
  {mustachiMustacheOnly.map((line, idx) => {
30
226
  const totalLines = mustachiMustacheOnly.length
31
- let color = midGray
227
+ let color = midTone
32
228
  if (idx < totalLines / 3) {
33
- color = lightGray // Top highlight
229
+ color = topTone // Top highlight
34
230
  } else if (idx >= (2 * totalLines) / 3) {
35
- color = darkGray // Bottom shadow
231
+ color = bottomTone // Bottom shadow
36
232
  }
37
233
  return <text fg={color}>{line.padEnd(61, " ")}</text>
38
234
  })}
39
235
 
40
236
  {/* OpenCode branding */}
41
237
  <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>
238
+ <text fg={mutedBranding} dimColor={true}>╭ </text>
239
+ <text fg={primaryBranding} bold={true}> O p e n C o d e </text>
240
+ <text fg={mutedBranding} dimColor={true}> ╮</text>
45
241
  </box>
46
242
 
47
243
  <text> </text>
@@ -50,16 +246,44 @@ export const HomeLogo = (props: { theme: TuiThemeCurrent }) => {
50
246
  }
51
247
 
52
248
  // Sidebar: Full Mustachi face with progressive animations (semantic zone colors)
53
- export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; isBusy?: boolean }) => {
249
+ export const SidebarMustachi = (props: {
250
+ theme: TuiThemeCurrent
251
+ config: Cfg
252
+ isBusy?: boolean
253
+ providers?: ReadonlyArray<{ id: string; name: string }>
254
+ branch?: string | (() => string | undefined)
255
+ getMessages?: () => any[]
256
+ runtimeContext?: any | (() => any)
257
+ contextLimit?: number | (() => number | undefined)
258
+ }) => {
54
259
  const [pupilIndex, setPupilIndex] = createSignal(0)
55
260
  const [blinkFrame, setBlinkFrame] = createSignal(0)
56
261
  const [tongueFrame, setTongueFrame] = createSignal(0)
57
262
  const [busyPhrase, setBusyPhrase] = createSignal("")
58
263
  const [expressiveCycle, setExpressiveCycle] = createSignal(false)
264
+ const [phraseCycle, setPhraseCycle] = createSignal(0)
265
+
266
+ const detectedStack = createMemo(() => {
267
+ return detectPrimaryStackContext({
268
+ providers: props.providers,
269
+ runtimeContext: resolveProp(props.runtimeContext),
270
+ messages: props.getMessages?.(),
271
+ })
272
+ })
273
+
274
+ const runtimeHint = createMemo(() => getRuntimeVisualHint(resolveProp(props.runtimeContext)))
275
+
276
+ const visualState = createMemo<MustachiVisualState>(() => {
277
+ return resolveVisualState({
278
+ isBusy: !!props.isBusy,
279
+ runtimeHint: runtimeHint(),
280
+ expressiveCycle: expressiveCycle(),
281
+ })
282
+ })
59
283
 
60
284
  // Animation: pupil movement (look around) - random transitions, not a sequence
61
285
  createEffect(() => {
62
- if (!props.config.animations || props.isBusy || expressiveCycle()) {
286
+ if (!props.config.animations || visualState() !== "idle") {
63
287
  setPupilIndex(0)
64
288
  return
65
289
  }
@@ -83,17 +307,22 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
83
307
  createEffect(() => {
84
308
  if (!props.config.animations) return
85
309
 
86
- const blinkSequence = async () => {
310
+ const timeoutIds = new Set<NodeJS.Timeout>()
311
+ const schedule = (fn: () => void, delay: number) => {
312
+ const timeoutId = setTimeout(() => {
313
+ timeoutIds.delete(timeoutId)
314
+ fn()
315
+ }, delay)
316
+ timeoutIds.add(timeoutId)
317
+ }
318
+
319
+ const blinkSequence = () => {
87
320
  // Open -> half -> closed -> half -> open (normal eyelid motion)
88
321
  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)
322
+ schedule(() => setBlinkFrame(1), 100)
323
+ schedule(() => setBlinkFrame(2), 180)
324
+ schedule(() => setBlinkFrame(1), 260)
325
+ schedule(() => setBlinkFrame(0), 340)
97
326
  }
98
327
 
99
328
  const interval = setInterval(() => {
@@ -104,7 +333,13 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
104
333
  }
105
334
  }, 2000) // Natural cadence: check every 2s for blink
106
335
 
107
- onCleanup(() => clearInterval(interval))
336
+ onCleanup(() => {
337
+ clearInterval(interval)
338
+ for (const timeoutId of timeoutIds) {
339
+ clearTimeout(timeoutId)
340
+ }
341
+ timeoutIds.clear()
342
+ })
108
343
  })
109
344
 
110
345
  // Busy/expressive state animation: tongue + single rotating phrase
@@ -117,7 +352,7 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
117
352
  return
118
353
  }
119
354
 
120
- const shouldShowExpression = props.isBusy || expressiveCycle()
355
+ const shouldShowExpression = visualState() !== "idle"
121
356
 
122
357
  if (!shouldShowExpression) {
123
358
  setTongueFrame(0)
@@ -136,13 +371,13 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
136
371
  }
137
372
  tongueTimeoutId = setTimeout(growTongue, 200)
138
373
 
139
- // Pick a single random phrase for this expressive cycle/state
140
- const pickRandomPhrase = () => {
141
- const randomIndex = Math.floor(Math.random() * busyPhrases.length)
142
- return busyPhrases[randomIndex]
143
- }
144
-
145
- setBusyPhrase(pickRandomPhrase())
374
+ const nextCycle = phraseCycle() + 1
375
+ setPhraseCycle(nextCycle)
376
+ setBusyPhrase(previous => pickBusyPhrase({
377
+ framework: detectedStack(),
378
+ cycle: nextCycle,
379
+ previous,
380
+ }))
146
381
 
147
382
  onCleanup(() => {
148
383
  if (tongueTimeoutId !== undefined) {
@@ -155,6 +390,7 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
155
390
  // This ensures tongue + phrases are visibly demonstrated even if runtime busy state is unreliable
156
391
  createEffect(() => {
157
392
  if (!props.config.animations || props.isBusy) return
393
+ if (runtimeHint() === "working" || runtimeHint() === "thinking") return
158
394
 
159
395
  let cycleEndTimeout: NodeJS.Timeout | undefined
160
396
 
@@ -167,8 +403,8 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
167
403
  }, 8000)
168
404
  }
169
405
 
170
- // First cycle after 30-45s, then every 45-60s (calm, occasional expressiveness)
171
- const firstDelay = 30000 + Math.random() * 15000
406
+ // First cycle after 45-60s, then every 45-60s (calm, occasional expressiveness)
407
+ const firstDelay = 45000 + Math.random() * 15000
172
408
  const firstTimeout = setTimeout(triggerExpressiveCycle, firstDelay)
173
409
 
174
410
  const interval = setInterval(() => {
@@ -194,7 +430,7 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
194
430
  let eyeFrame = pupilPositionFrames[pupilIndex()]
195
431
 
196
432
  // Apply squint if busy/expressive
197
- if (props.isBusy || expressiveCycle()) {
433
+ if (visualState() !== "idle") {
198
434
  eyeFrame = eyeSquinted
199
435
  }
200
436
 
@@ -203,6 +439,8 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
203
439
  eyeFrame = eyeBlinkHalf
204
440
  } else if (blinkFrame() === 2) {
205
441
  eyeFrame = eyeBlinkClosed
442
+ } else {
443
+ eyeFrame = applyRightEyeContextualMark(eyeFrame, detectedStack())
206
444
  }
207
445
 
208
446
  // Add eyes with zone metadata
@@ -218,7 +456,7 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
218
456
  })
219
457
 
220
458
  // Add tongue if expressive (mark as tongue zone for pink color)
221
- if ((props.isBusy || expressiveCycle()) && tongueFrame() > 0) {
459
+ if (visualState() !== "idle" && tongueFrame() > 0) {
222
460
  const tongueLines = tongueFrames[tongueFrame()]
223
461
  tongueLines.forEach(line => {
224
462
  lines.push({ content: line, zone: "tongue" })
@@ -228,18 +466,53 @@ export const SidebarMustachi = (props: { theme: TuiThemeCurrent; config: Cfg; is
228
466
  return lines
229
467
  }
230
468
 
469
+ const branchLabel = createMemo(() => {
470
+ const value = resolveProp(props.branch)?.trim()
471
+ if (!value) return ""
472
+ return ellipsize(value, 24)
473
+ })
474
+
475
+ const resolvedContextLimit = createMemo(() => resolveProp(props.contextLimit))
476
+
477
+ const assistantMessages = createMemo(() => {
478
+ const messages = props.getMessages?.() ?? []
479
+ return messages.filter((message: any) => getMessageRole(message) === "assistant")
480
+ })
481
+
482
+ const contextTokens = createMemo(() => {
483
+ const lastAssistantWithTokens = [...assistantMessages()].reverse().find((message: any) => hasTokenData(message))
484
+ return getContextTokens(lastAssistantWithTokens)
485
+ })
486
+
487
+ const totalCost = createMemo(() => {
488
+ return assistantMessages().reduce((sum: number, message: any) => sum + getMessageCost(message), 0)
489
+ })
490
+
231
491
  return (
232
492
  <box flexDirection="column" alignItems="center">
233
493
  {/* Full Mustachi face with semantic zone colors */}
234
494
  {buildFace().map(({ content, zone }) => {
235
- const color = zoneColors[zone as keyof typeof zoneColors] || zoneColors.mustache
495
+ const color = getZoneColor(zone, props.theme)
236
496
  const paddedLine = content.padEnd(27, " ")
237
497
  return <text fg={color}>{paddedLine}</text>
238
498
  })}
239
499
 
500
+ {branchLabel() && (
501
+ <text fg={props.theme?.textMuted ?? zoneColors.mustache}>⎇ {branchLabel()}</text>
502
+ )}
503
+
504
+ {props.config.show_metrics && (
505
+ <ProgressBar
506
+ theme={props.theme}
507
+ totalTokens={contextTokens()}
508
+ totalCost={totalCost()}
509
+ contextLimit={resolvedContextLimit()}
510
+ />
511
+ )}
512
+
240
513
  {/* Display a single busy phrase for the current expressive cycle */}
241
514
  {busyPhrase() && (
242
- <text fg={props.theme.warning}>{busyPhrase()}</text>
515
+ <text fg={props.theme?.warning ?? zoneColors.tongue}>{busyPhrase()}</text>
243
516
  )}
244
517
 
245
518
  <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/detection.ts CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  import { readFileSync } from "node:fs"
4
4
 
5
+ export type DetectedStack =
6
+ | "react"
7
+ | "angular"
8
+ | "vue"
9
+ | "node"
10
+ | "go"
11
+ | "python"
12
+ | "dotnet"
13
+ | "svelte"
14
+ | "nextjs"
15
+ | "rust"
16
+
5
17
  // Helper to detect OS name
6
18
  export const getOSName = (): string => {
7
19
  try {
@@ -59,3 +71,110 @@ export const getProviders = (providers: ReadonlyArray<{ id: string; name: string
59
71
  // Return compact comma-separated list
60
72
  return Array.from(names).sort().join(", ")
61
73
  }
74
+
75
+ type StackDetectionInput = {
76
+ providers?: ReadonlyArray<{ id: string; name: string }>
77
+ runtimeContext?: any
78
+ messages?: any[]
79
+ }
80
+
81
+ const stackRules: Record<DetectedStack, RegExp[]> = {
82
+ react: [/\breact\b/, /\btsx\b/, /\bjsx\b/],
83
+ angular: [/\bangular\b/, /\bnx\b/],
84
+ vue: [/\bvue\b/, /\bnuxt\b/],
85
+ node: [/\bnode\b/, /\bnodejs\b/, /\bexpress\b/, /\bnestjs\b/],
86
+ go: [/\bgolang\b/, /\bgo\s+mod\b/, /\bgo\.sum\b/, /\bgin\b/, /\bfiber\b/],
87
+ python: [/\bpython\b/, /\.py\b/, /\bdjango\b/, /\bfastapi\b/],
88
+ dotnet: [/\.net\b/, /\bdotnet\b/, /\bc#\b/, /\basp\.net\b/],
89
+ svelte: [/\bsvelte\b/, /\bsveltekit\b/],
90
+ nextjs: [/\bnext\.js\b/, /\bnextjs\b/],
91
+ rust: [/\brust\b/, /\baxum\b/, /\bactix\b/],
92
+ }
93
+
94
+ const stackPriority: DetectedStack[] = [
95
+ "nextjs",
96
+ "react",
97
+ "angular",
98
+ "vue",
99
+ "svelte",
100
+ "node",
101
+ "go",
102
+ "python",
103
+ "dotnet",
104
+ "rust",
105
+ ]
106
+
107
+ const pushHint = (bucket: string[], value: unknown) => {
108
+ if (typeof value === "string" && value.trim()) {
109
+ bucket.push(value.toLowerCase())
110
+ }
111
+ }
112
+
113
+ const extractText = (value: unknown): string => {
114
+ if (typeof value === "string") return value
115
+ if (Array.isArray(value)) {
116
+ return value.map(item => extractText(item)).filter(Boolean).join(" ")
117
+ }
118
+ if (value && typeof value === "object") {
119
+ const item = value as Record<string, unknown>
120
+ return [extractText(item.text), extractText(item.content), extractText(item.value)]
121
+ .filter(Boolean)
122
+ .join(" ")
123
+ }
124
+ return ""
125
+ }
126
+
127
+ export const detectPrimaryStackContext = (input: StackDetectionInput): DetectedStack | undefined => {
128
+ try {
129
+ const hints: string[] = []
130
+ const runtime = input.runtimeContext
131
+ const model = runtime?.model ?? runtime?.runtime?.model
132
+ const metadata = runtime?.metadata ?? runtime?.runtime?.metadata
133
+
134
+ for (const provider of input.providers ?? []) {
135
+ pushHint(hints, provider.id)
136
+ pushHint(hints, provider.name)
137
+ }
138
+
139
+ pushHint(hints, model?.id)
140
+ pushHint(hints, model?.name)
141
+ pushHint(hints, runtime?.provider)
142
+ pushHint(hints, metadata?.framework)
143
+ pushHint(hints, metadata?.stack)
144
+ pushHint(hints, metadata?.language)
145
+
146
+ const recentMessages = (input.messages ?? []).slice(-6)
147
+ for (const message of recentMessages) {
148
+ pushHint(hints, message?.model)
149
+ pushHint(hints, message?.provider)
150
+ pushHint(hints, extractText(message?.content))
151
+ pushHint(hints, extractText(message?.message?.content))
152
+ }
153
+
154
+ if (!hints.length) return
155
+
156
+ const scores = new Map<DetectedStack, number>()
157
+ const corpus = hints.join(" ")
158
+ for (const stack of stackPriority) {
159
+ let score = 0
160
+ for (const pattern of stackRules[stack]) {
161
+ if (pattern.test(corpus)) score += 1
162
+ }
163
+ if (score > 0) scores.set(stack, score)
164
+ }
165
+
166
+ let best: DetectedStack | undefined
167
+ let bestScore = 0
168
+ for (const stack of stackPriority) {
169
+ const score = scores.get(stack) ?? 0
170
+ if (score > bestScore) {
171
+ best = stack
172
+ bestScore = score
173
+ }
174
+ }
175
+
176
+ return best
177
+ } catch {
178
+ return
179
+ }
180
+ }
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.6",
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": {
@@ -14,6 +14,7 @@
14
14
  "show_detected": true,
15
15
  "show_os": true,
16
16
  "show_providers": true,
17
+ "show_metrics": true,
17
18
  "animations": true
18
19
  }
19
20
  }
@@ -55,4 +56,4 @@
55
56
  "bugs": {
56
57
  "url": "https://github.com/IrrealV/plugin-gentleman/issues"
57
58
  }
58
- }
59
+ }
package/phrases.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // Motivational phrases for busy/loading state
2
2
  // Add new phrases here to expand the library
3
3
 
4
+ import type { DetectedStack } from "./detection"
5
+
4
6
  export const busyPhrases = [
5
7
  // Original classics
6
8
  "Ponete las pilas, hermano...",
@@ -55,4 +57,105 @@ export const busyPhrases = [
55
57
  "Debugging: ser el detective en una novela de crimen donde también sos el asesino",
56
58
  "¡Todo compila! (pero no hace lo que debería)",
57
59
  "Si depurar es quitar bugs, programar debe ser ponerlos",
60
+ "Esto va más lento que tortuga con asma, pero va",
61
+ "¿Qué hace una abeja en el gimnasio? Zum-ba",
62
+ "Estoy más ocupado que mozo en Día de la Madre",
63
+ "¿Qué le dice una pared a otra? Nos vemos en la esquina",
64
+ "Más despacio que caracol con resaca... pero seguro",
65
+ "¿Cómo se despiden los químicos? Ácido un placer",
66
+ "Va tomando forma, como puré instantáneo",
67
+ "¿Qué hace un pez? Nada",
68
+ "No es lentitud, es suspenso de alta calidad",
69
+ "¿Cuál es el colmo de un jardinero? Que siempre lo dejen plantado",
70
+ "Estoy acomodando los patitos en fila",
71
+ "¿Qué le dice el 0 al 8? Lindo cinturón",
72
+ "Esto sale calentito, como medialuna de panadería",
73
+ "¿Cómo maldice un pollito a otro? Caldito seas",
74
+ "Un cachito más y queda pipí cucú",
75
+ "¿Cuál es el café más peligroso? El ex-preso",
76
+ "Más firme que televisor de bar en la final",
77
+ "¿Qué le dijo una impresora a otra? ¿Esa hoja es tuya o es impresión mía?",
78
+ "Estoy cerrando con moñito, bancame un toque",
79
+ "¿Cuál es el colmo de un electricista? No encontrar su corriente de trabajo",
58
80
  ]
81
+
82
+ export const frameworkBusyPhrases: Partial<Record<DetectedStack, string[]>> = {
83
+ react: [
84
+ "Ajustando hooks y estado...",
85
+ "Renderizando componentes con onda...",
86
+ "Sincronizando props y efectos...",
87
+ ],
88
+ angular: [
89
+ "Ordenando módulos y providers...",
90
+ "Inyectando dependencias como relojito...",
91
+ "Acomodando el template de Angular...",
92
+ ],
93
+ vue: [
94
+ "Ajustando refs y reactividad...",
95
+ "Cocinando un composable fino...",
96
+ "Vue está pensando en voz baja...",
97
+ ],
98
+ node: [
99
+ "Encolando eventos del loop...",
100
+ "Levantando handlers de Node...",
101
+ "Conectando rutas del backend...",
102
+ ],
103
+ go: [
104
+ "Compilando goroutines con paciencia...",
105
+ "Ajustando canales y concurrencia...",
106
+ "Go está afilando la respuesta...",
107
+ ],
108
+ python: [
109
+ "Ordenando imports y scripts...",
110
+ "Entrenando al snake para correr más...",
111
+ "Ajustando funciones con estilo Python...",
112
+ ],
113
+ dotnet: [
114
+ "Alineando servicios de .NET...",
115
+ "Puliendo capas con C#...",
116
+ "Ensamblando pipeline de ASP.NET...",
117
+ ],
118
+ svelte: [
119
+ "Svelte está reaccionando en silencio...",
120
+ "Ajustando stores y transiciones...",
121
+ "Compilando magia liviana...",
122
+ ],
123
+ nextjs: [
124
+ "Pre-renderizando rutas de Next...",
125
+ "Ajustando server components...",
126
+ "Hidratando la app con elegancia...",
127
+ ],
128
+ rust: [
129
+ "Prestando atención al borrow checker...",
130
+ "Afinando ownership sin piedad...",
131
+ "Oxidando bugs, una línea a la vez...",
132
+ ],
133
+ }
134
+
135
+ const hashSeed = (value: string): number => {
136
+ let hash = 0
137
+ for (let i = 0; i < value.length; i++) {
138
+ hash = (hash * 31 + value.charCodeAt(i)) >>> 0
139
+ }
140
+ return hash
141
+ }
142
+
143
+ export const pickBusyPhrase = (input: {
144
+ framework?: DetectedStack
145
+ cycle: number
146
+ previous?: string
147
+ }): string => {
148
+ const frameworkPool = input.framework ? frameworkBusyPhrases[input.framework] : undefined
149
+ const pool = frameworkPool && frameworkPool.length > 0 ? [...frameworkPool, ...busyPhrases] : busyPhrases
150
+
151
+ if (!pool.length) return ""
152
+
153
+ const seed = hashSeed(input.framework || "generic")
154
+ let index = Math.abs(seed + input.cycle * 7) % pool.length
155
+
156
+ if (pool.length > 1 && input.previous && pool[index] === input.previous) {
157
+ index = (index + 1) % pool.length
158
+ }
159
+
160
+ return pool[index]
161
+ }
package/tui.tsx CHANGED
@@ -12,6 +12,29 @@ const rec = (value: unknown) => {
12
12
  return Object.fromEntries(Object.entries(value))
13
13
  }
14
14
 
15
+ const toNumber = (value: unknown): number | undefined => {
16
+ if (typeof value !== "number") return
17
+ if (!Number.isFinite(value) || value <= 0) return
18
+ return value
19
+ }
20
+
21
+ const getContextLimitFromRuntime = (ctxValue: any): number | undefined => {
22
+ const runtimeMetadata = ctxValue?.runtime?.metadata
23
+ const metadata = ctxValue?.metadata
24
+ const model = ctxValue?.model
25
+
26
+ return (
27
+ toNumber(runtimeMetadata?.contextLimit) ??
28
+ toNumber(runtimeMetadata?.context_limit) ??
29
+ toNumber(metadata?.contextLimit) ??
30
+ toNumber(metadata?.context_limit) ??
31
+ toNumber(model?.contextLimit) ??
32
+ toNumber(model?.context_limit) ??
33
+ toNumber(ctxValue?.contextLimit) ??
34
+ toNumber(ctxValue?.context_limit)
35
+ )
36
+ }
37
+
15
38
  const tui: TuiPlugin = async (api, options) => {
16
39
  const boot = cfg(rec(options))
17
40
  if (!boot.enabled) return
@@ -61,7 +84,21 @@ const tui: TuiPlugin = async (api, options) => {
61
84
  return <DetectedEnv theme={ctx.theme.current} providers={api.state.provider} config={value()} />
62
85
  },
63
86
  sidebar_content(ctx) {
64
- return <SidebarMustachi theme={ctx.theme.current} config={value()} isBusy={isBusy()} />
87
+ return (
88
+ <SidebarMustachi
89
+ theme={ctx.theme.current}
90
+ config={value()}
91
+ isBusy={isBusy()}
92
+ providers={api.state.provider}
93
+ branch={() => api.state.vcs?.branch}
94
+ getMessages={() => {
95
+ const sessionID = ctx.value?.sessionID
96
+ return sessionID ? api.state.session.messages(sessionID) : []
97
+ }}
98
+ runtimeContext={() => ctx.value}
99
+ contextLimit={() => getContextLimitFromRuntime(ctx.value)}
100
+ />
101
+ )
65
102
  },
66
103
  },
67
104
  })