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 +8 -0
- package/components.tsx +313 -40
- package/config.ts +2 -0
- package/detection.ts +119 -0
- package/package.json +3 -2
- package/phrases.ts +103 -0
- package/tui.tsx +38 -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
|
@@ -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 {
|
|
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
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
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
|
|
224
|
+
{/* Mustache with theme-reactive gradient for depth */}
|
|
29
225
|
{mustachiMustacheOnly.map((line, idx) => {
|
|
30
226
|
const totalLines = mustachiMustacheOnly.length
|
|
31
|
-
let color =
|
|
227
|
+
let color = midTone
|
|
32
228
|
if (idx < totalLines / 3) {
|
|
33
|
-
color =
|
|
229
|
+
color = topTone // Top highlight
|
|
34
230
|
} else if (idx >= (2 * totalLines) / 3) {
|
|
35
|
-
color =
|
|
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={
|
|
43
|
-
<text fg={
|
|
44
|
-
<text fg={
|
|
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: {
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
90
|
-
setBlinkFrame(
|
|
91
|
-
|
|
92
|
-
setBlinkFrame(
|
|
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(() =>
|
|
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 =
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
171
|
-
const firstDelay =
|
|
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 (
|
|
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 ((
|
|
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 =
|
|
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.
|
|
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
|
+
"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
|
|
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
|
})
|