@rizal72/oc-ponytail-widget 0.1.0 → 0.1.1
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/package.json +20 -5
- package/tui.js +158 -0
- package/tui.tsx +0 -145
package/package.json
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rizal72/oc-ponytail-widget",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Unofficial ponytail level badge for OpenCode v2: the level in the prompt footer, next to the coffee cup",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/rizal72/opencode-plugins.git"
|
|
9
|
+
},
|
|
6
10
|
"license": "MIT",
|
|
7
|
-
"files": [
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
"files": [
|
|
12
|
+
"*.ts",
|
|
13
|
+
"tui.js",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"opencode-plugin",
|
|
19
|
+
"ponytail",
|
|
20
|
+
"widget"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
10
25
|
"exports": {
|
|
11
26
|
".": "./index.ts",
|
|
12
|
-
"./tui": "./tui.
|
|
27
|
+
"./tui": "./tui.js"
|
|
13
28
|
}
|
|
14
29
|
}
|
package/tui.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
2
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
3
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
4
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
5
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
6
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
7
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
8
|
+
// oc-ponytail-widget — TUI half.
|
|
9
|
+
//
|
|
10
|
+
// The badge pi has and OpenCode was missing: the ponytail level in the prompt footer, to the right of
|
|
11
|
+
// the coffee cup — full. The **glyph** is green while a run is active and grey when idle, exactly
|
|
12
|
+
// like the cup; the **level word** stays dim and carries the state. The badge is **always visible,
|
|
13
|
+
// `off` included**: seeing `off` is how you know the plugin is loaded and deliberately doing nothing —
|
|
14
|
+
// the rule the cup follows, and a deliberate deviation from pi, which hides it.
|
|
15
|
+
// (pi's own extension publishes this as a status key, drawn by pi's footer; the OpenCode half of the
|
|
16
|
+
// package is **server-only** — no TUI part at all, verified 2026-10-17 — so this is ours.)
|
|
17
|
+
//
|
|
18
|
+
// md-horse_variant (U+F15C1). Nerd Fonts has **no** ponytail glyph (checked against its own
|
|
19
|
+
// `glyphnames.json`: nothing for pony/tail/hair/mane/braid), and the `fa-*` horses live at
|
|
20
|
+
// U+E000–U+F8FF, a range the owner's font does not cover in every rendering path — the `md-*` range
|
|
21
|
+
// (plane 15) it does.
|
|
22
|
+
//
|
|
23
|
+
// The level is read from the **same sources the package reads** (`hooks/ponytail-config.js`), in the
|
|
24
|
+
// same order: the state file the OpenCode plugin writes on `/ponytail <level>`, then the shared config
|
|
25
|
+
// (`~/.config/ponytail/config.json` — the one file pi and OpenCode both read), then the package's own
|
|
26
|
+
// hardcoded `full`. Nothing of ours is written: a badge that could contradict the ruleset actually in
|
|
27
|
+
// the prompt would be worse than no badge.
|
|
28
|
+
//
|
|
29
|
+
// shortcut: the state file is re-read when a run **starts**, not the moment it is written — a level set
|
|
30
|
+
// with `/ponytail lite` appears on the turn that command triggers, not before. Upgrade path: watch the
|
|
31
|
+
// file (`fs.watch`) instead of riding the run events.
|
|
32
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import { homedir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { createMemo, createRoot, createSignal } from "solid-js";
|
|
37
|
+
|
|
38
|
+
/** md-horse_variant (U+F15C1) — a horse, because the ponytail glyph does not exist. */
|
|
39
|
+
const GLYPH = "\u{f15c1}";
|
|
40
|
+
|
|
41
|
+
/** The package's runtime levels. `review` is session-only and can never be a persisted level. */
|
|
42
|
+
const LEVELS = ["off", "lite", "full", "ultra"];
|
|
43
|
+
|
|
44
|
+
/** Fallbacks, used only when the theme defines none of the candidates. */
|
|
45
|
+
const FALLBACK_GREEN = "#3fb950";
|
|
46
|
+
const FALLBACK_DIM = "#8b949e";
|
|
47
|
+
|
|
48
|
+
/** Where the package looks for its own files, `$XDG_CONFIG_HOME` first — same rule. */
|
|
49
|
+
const CONFIG_DIR = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
50
|
+
function normalized(value) {
|
|
51
|
+
if (typeof value !== "string") return undefined;
|
|
52
|
+
const candidate = value.trim().toLowerCase();
|
|
53
|
+
return LEVELS.includes(candidate) ? candidate : undefined;
|
|
54
|
+
}
|
|
55
|
+
function readText(path) {
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(path, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `~/.config/ponytail/config.json`, the file pi and OpenCode share. */
|
|
64
|
+
function configuredDefault() {
|
|
65
|
+
const raw = readText(join(CONFIG_DIR, "ponytail", "config.json"));
|
|
66
|
+
if (!raw) return undefined;
|
|
67
|
+
try {
|
|
68
|
+
return normalized(JSON.parse(raw)?.defaultMode);
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The level in force: the state file the plugin writes, else the shared default — `undefined` when
|
|
75
|
+
* neither exists, which is how an install without ponytail is told apart from one at its own default.
|
|
76
|
+
* shortcut: `PONYTAIL_DEFAULT_MODE` is deliberately not consulted — the package documents it as the
|
|
77
|
+
* variable to avoid (it outranks the config). Add it ahead of the config if that ever changes. */
|
|
78
|
+
function currentLevel() {
|
|
79
|
+
const state = normalized(readText(join(CONFIG_DIR, "opencode", ".ponytail-active")));
|
|
80
|
+
return state ?? configuredDefault();
|
|
81
|
+
}
|
|
82
|
+
export default Plugin.define({
|
|
83
|
+
id: "oc-ponytail-widget.tui",
|
|
84
|
+
setup(context) {
|
|
85
|
+
const themeColour = (...paths) => {
|
|
86
|
+
for (const path of paths) {
|
|
87
|
+
let node = context.theme;
|
|
88
|
+
for (const key of path) node = node?.[key];
|
|
89
|
+
if (node !== undefined && node !== null) return node;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
};
|
|
93
|
+
const GREEN = themeColour(["syntax", "string"], ["diff", "text", "added"], ["text", "feedback", "success", "base"]) ?? FALLBACK_GREEN;
|
|
94
|
+
const DIM = themeColour(["text", "muted"], ["text", "faint"]) ?? FALLBACK_DIM;
|
|
95
|
+
|
|
96
|
+
/** Runs in flight: several sessions can execute at once, so it is a count, not a flag. */
|
|
97
|
+
let running = 0;
|
|
98
|
+
/** Reactive, or the render would read the signal once and never repaint. */
|
|
99
|
+
const [active, setActive] = createRoot(() => createSignal(false));
|
|
100
|
+
/** Bumped when a run starts: the level lives in a file, and a new turn is the one moment it
|
|
101
|
+
* can have changed since we last looked. */
|
|
102
|
+
const [seen, setSeen] = createRoot(() => createSignal(0));
|
|
103
|
+
const level = createMemo(() => {
|
|
104
|
+
seen();
|
|
105
|
+
return currentLevel();
|
|
106
|
+
});
|
|
107
|
+
const stopStarted = context.data.on("session.execution.started", () => {
|
|
108
|
+
running += 1;
|
|
109
|
+
setSeen(count => count + 1);
|
|
110
|
+
if (running === 1) setActive(true);
|
|
111
|
+
});
|
|
112
|
+
const settle = () => {
|
|
113
|
+
running = Math.max(0, running - 1);
|
|
114
|
+
if (running === 0) setActive(false);
|
|
115
|
+
};
|
|
116
|
+
const stopSucceeded = context.data.on("session.execution.succeeded", settle);
|
|
117
|
+
const stopFailed = context.data.on("session.execution.failed", settle);
|
|
118
|
+
const stopInterrupted = context.data.on("session.execution.interrupted", settle);
|
|
119
|
+
|
|
120
|
+
/** Always visible, `off` included, once ponytail has a level to show: the badge is the setting,
|
|
121
|
+
* and seeing `off` is how you know the plugin is loaded and deliberately doing nothing. (pi
|
|
122
|
+
* hides it; the owner chose the cup's rule for both badges instead.) With no level file and no
|
|
123
|
+
* shared config there is no ponytail to report on, and a fallback `full` would be a claim
|
|
124
|
+
* about a plugin that is not installed — so nothing is drawn. */
|
|
125
|
+
const Badge = () => {
|
|
126
|
+
const value = level();
|
|
127
|
+
if (!value) return null;
|
|
128
|
+
return (() => {
|
|
129
|
+
var _el$ = _$createElement("box"),
|
|
130
|
+
_el$2 = _$createElement("text"),
|
|
131
|
+
_el$4 = _$createElement("text"),
|
|
132
|
+
_el$5 = _$createTextNode(` `);
|
|
133
|
+
_$insertNode(_el$, _el$2);
|
|
134
|
+
_$insertNode(_el$, _el$4);
|
|
135
|
+
_$setProp(_el$, "flexDirection", "row");
|
|
136
|
+
_$setProp(_el$, "flexShrink", 0);
|
|
137
|
+
_$insertNode(_el$2, _$createTextNode(``));
|
|
138
|
+
_$setProp(_el$2, "wrapMode", "none");
|
|
139
|
+
_$insertNode(_el$4, _el$5);
|
|
140
|
+
_$setProp(_el$4, "fg", DIM);
|
|
141
|
+
_$setProp(_el$4, "wrapMode", "none");
|
|
142
|
+
_$insert(_el$4, value, null);
|
|
143
|
+
_$effect(_$p => _$setProp(_el$2, "fg", active() ? GREEN : DIM, _$p));
|
|
144
|
+
return _el$;
|
|
145
|
+
})();
|
|
146
|
+
};
|
|
147
|
+
context.ui.slot({
|
|
148
|
+
append: "prompt.footer.status",
|
|
149
|
+
render: () => _$createComponent(Badge, {})
|
|
150
|
+
});
|
|
151
|
+
return () => {
|
|
152
|
+
stopStarted?.();
|
|
153
|
+
stopSucceeded?.();
|
|
154
|
+
stopFailed?.();
|
|
155
|
+
stopInterrupted?.();
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
});
|
package/tui.tsx
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
// oc-ponytail-widget — TUI half.
|
|
2
|
-
//
|
|
3
|
-
// The badge pi has and OpenCode was missing: the ponytail level in the prompt footer, to the right of
|
|
4
|
-
// the coffee cup — full. The **glyph** is green while a run is active and grey when idle, exactly
|
|
5
|
-
// like the cup; the **level word** stays dim and carries the state. The badge is **always visible,
|
|
6
|
-
// `off` included**: seeing `off` is how you know the plugin is loaded and deliberately doing nothing —
|
|
7
|
-
// the rule the cup follows, and a deliberate deviation from pi, which hides it.
|
|
8
|
-
// (pi's own extension publishes this as a status key, drawn by pi's footer; the OpenCode half of the
|
|
9
|
-
// package is **server-only** — no TUI part at all, verified 2026-10-17 — so this is ours.)
|
|
10
|
-
//
|
|
11
|
-
// md-horse_variant (U+F15C1). Nerd Fonts has **no** ponytail glyph (checked against its own
|
|
12
|
-
// `glyphnames.json`: nothing for pony/tail/hair/mane/braid), and the `fa-*` horses live at
|
|
13
|
-
// U+E000–U+F8FF, a range the owner's font does not cover in every rendering path — the `md-*` range
|
|
14
|
-
// (plane 15) it does.
|
|
15
|
-
//
|
|
16
|
-
// The level is read from the **same sources the package reads** (`hooks/ponytail-config.js`), in the
|
|
17
|
-
// same order: the state file the OpenCode plugin writes on `/ponytail <level>`, then the shared config
|
|
18
|
-
// (`~/.config/ponytail/config.json` — the one file pi and OpenCode both read), then the package's own
|
|
19
|
-
// hardcoded `full`. Nothing of ours is written: a badge that could contradict the ruleset actually in
|
|
20
|
-
// the prompt would be worse than no badge.
|
|
21
|
-
//
|
|
22
|
-
// shortcut: the state file is re-read when a run **starts**, not the moment it is written — a level set
|
|
23
|
-
// with `/ponytail lite` appears on the turn that command triggers, not before. Upgrade path: watch the
|
|
24
|
-
// file (`fs.watch`) instead of riding the run events.
|
|
25
|
-
import { Plugin } from "@opencode/plugin/tui"
|
|
26
|
-
import { readFileSync } from "node:fs"
|
|
27
|
-
import { homedir } from "node:os"
|
|
28
|
-
import { join } from "node:path"
|
|
29
|
-
import { createMemo, createRoot, createSignal } from "solid-js"
|
|
30
|
-
|
|
31
|
-
/** md-horse_variant (U+F15C1) — a horse, because the ponytail glyph does not exist. */
|
|
32
|
-
const GLYPH = "\u{f15c1}"
|
|
33
|
-
|
|
34
|
-
/** The package's runtime levels. `review` is session-only and can never be a persisted level. */
|
|
35
|
-
const LEVELS = ["off", "lite", "full", "ultra"]
|
|
36
|
-
|
|
37
|
-
/** Fallbacks, used only when the theme defines none of the candidates. */
|
|
38
|
-
const FALLBACK_GREEN = "#3fb950"
|
|
39
|
-
const FALLBACK_DIM = "#8b949e"
|
|
40
|
-
|
|
41
|
-
/** Where the package looks for its own files, `$XDG_CONFIG_HOME` first — same rule. */
|
|
42
|
-
const CONFIG_DIR = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
|
43
|
-
|
|
44
|
-
function normalized(value: unknown): string | undefined {
|
|
45
|
-
if (typeof value !== "string") return undefined
|
|
46
|
-
const candidate = value.trim().toLowerCase()
|
|
47
|
-
return LEVELS.includes(candidate) ? candidate : undefined
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function readText(path: string): string | undefined {
|
|
51
|
-
try {
|
|
52
|
-
return readFileSync(path, "utf8")
|
|
53
|
-
} catch {
|
|
54
|
-
return undefined
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** `~/.config/ponytail/config.json`, the file pi and OpenCode share. */
|
|
59
|
-
function configuredDefault(): string | undefined {
|
|
60
|
-
const raw = readText(join(CONFIG_DIR, "ponytail", "config.json"))
|
|
61
|
-
if (!raw) return undefined
|
|
62
|
-
try {
|
|
63
|
-
return normalized(JSON.parse(raw)?.defaultMode)
|
|
64
|
-
} catch {
|
|
65
|
-
return undefined
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** The level in force: the state file the plugin writes, else the shared default — `undefined` when
|
|
70
|
-
* neither exists, which is how an install without ponytail is told apart from one at its own default.
|
|
71
|
-
* shortcut: `PONYTAIL_DEFAULT_MODE` is deliberately not consulted — the package documents it as the
|
|
72
|
-
* variable to avoid (it outranks the config). Add it ahead of the config if that ever changes. */
|
|
73
|
-
function currentLevel(): string | undefined {
|
|
74
|
-
const state = normalized(readText(join(CONFIG_DIR, "opencode", ".ponytail-active")))
|
|
75
|
-
return state ?? configuredDefault()
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export default Plugin.define({
|
|
79
|
-
id: "oc-ponytail-widget.tui",
|
|
80
|
-
setup(context: any) {
|
|
81
|
-
const themeColour = (...paths: string[][]): any => {
|
|
82
|
-
for (const path of paths) {
|
|
83
|
-
let node: any = context.theme
|
|
84
|
-
for (const key of path) node = node?.[key]
|
|
85
|
-
if (node !== undefined && node !== null) return node
|
|
86
|
-
}
|
|
87
|
-
return undefined
|
|
88
|
-
}
|
|
89
|
-
const GREEN =
|
|
90
|
-
themeColour(["syntax", "string"], ["diff", "text", "added"], ["text", "feedback", "success", "base"]) ??
|
|
91
|
-
FALLBACK_GREEN
|
|
92
|
-
const DIM = themeColour(["text", "muted"], ["text", "faint"]) ?? FALLBACK_DIM
|
|
93
|
-
|
|
94
|
-
/** Runs in flight: several sessions can execute at once, so it is a count, not a flag. */
|
|
95
|
-
let running = 0
|
|
96
|
-
/** Reactive, or the render would read the signal once and never repaint. */
|
|
97
|
-
const [active, setActive] = createRoot(() => createSignal(false))
|
|
98
|
-
/** Bumped when a run starts: the level lives in a file, and a new turn is the one moment it
|
|
99
|
-
* can have changed since we last looked. */
|
|
100
|
-
const [seen, setSeen] = createRoot(() => createSignal(0))
|
|
101
|
-
|
|
102
|
-
const level = createMemo(() => {
|
|
103
|
-
seen()
|
|
104
|
-
return currentLevel()
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
const stopStarted = context.data.on("session.execution.started", () => {
|
|
108
|
-
running += 1
|
|
109
|
-
setSeen((count: number) => count + 1)
|
|
110
|
-
if (running === 1) setActive(true)
|
|
111
|
-
})
|
|
112
|
-
const settle = () => {
|
|
113
|
-
running = Math.max(0, running - 1)
|
|
114
|
-
if (running === 0) setActive(false)
|
|
115
|
-
}
|
|
116
|
-
const stopSucceeded = context.data.on("session.execution.succeeded", settle)
|
|
117
|
-
const stopFailed = context.data.on("session.execution.failed", settle)
|
|
118
|
-
const stopInterrupted = context.data.on("session.execution.interrupted", settle)
|
|
119
|
-
|
|
120
|
-
/** Always visible, `off` included, once ponytail has a level to show: the badge is the setting,
|
|
121
|
-
* and seeing `off` is how you know the plugin is loaded and deliberately doing nothing. (pi
|
|
122
|
-
* hides it; the owner chose the cup's rule for both badges instead.) With no level file and no
|
|
123
|
-
* shared config there is no ponytail to report on, and a fallback `full` would be a claim
|
|
124
|
-
* about a plugin that is not installed — so nothing is drawn. */
|
|
125
|
-
const Badge = () => {
|
|
126
|
-
const value = level()
|
|
127
|
-
if (!value) return null
|
|
128
|
-
return (
|
|
129
|
-
<box flexDirection="row" flexShrink={0}>
|
|
130
|
-
<text fg={active() ? GREEN : DIM} wrapMode="none">{GLYPH}</text>
|
|
131
|
-
<text fg={DIM} wrapMode="none"> {value}</text>
|
|
132
|
-
</box>
|
|
133
|
-
)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
context.ui.slot({ append: "prompt.footer.status", render: () => <Badge /> })
|
|
137
|
-
|
|
138
|
-
return () => {
|
|
139
|
-
stopStarted?.()
|
|
140
|
-
stopSucceeded?.()
|
|
141
|
-
stopFailed?.()
|
|
142
|
-
stopInterrupted?.()
|
|
143
|
-
}
|
|
144
|
-
},
|
|
145
|
-
})
|