@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.
Files changed (3) hide show
  1. package/package.json +20 -5
  2. package/tui.js +158 -0
  3. 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.0",
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": ["*.ts", "*.tsx", "README.md"],
8
- "keywords": ["opencode", "opencode-plugin", "ponytail", "widget"],
9
- "publishConfig": { "access": "public" },
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.tsx"
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
- })