opencode-buddy 0.2.0

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/src/tmux.js ADDED
@@ -0,0 +1,90 @@
1
+ // tmux integration: detect session, split a side pane, capture main pane
2
+ // content for activity inference.
3
+ //
4
+ // All tmux interaction goes through `tmux -t <target> <command>` so we don't
5
+ // have to manage a child process. We parse plain text output.
6
+
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ export function inTmux() {
10
+ return Boolean(process.env.TMUX);
11
+ }
12
+
13
+ export function currentPaneId() {
14
+ const r = spawnSync("tmux", ["display-message", "-p", "#{pane_id}"], {
15
+ encoding: "utf8",
16
+ });
17
+ if (r.status !== 0) return null;
18
+ return r.stdout.trim() || null;
19
+ }
20
+
21
+ export function sessionName() {
22
+ const r = spawnSync("tmux", ["display-message", "-p", "#{session_name}"], {
23
+ encoding: "utf8",
24
+ });
25
+ if (r.status !== 0) return null;
26
+ return r.stdout.trim() || null;
27
+ }
28
+
29
+ export function splitRight(command, size = 28) {
30
+ // -h horizontal split (side-by-side), -l size in columns, -d don't focus
31
+ const r = spawnSync(
32
+ "tmux",
33
+ [
34
+ "split-window",
35
+ "-h",
36
+ "-l",
37
+ String(size),
38
+ "-d",
39
+ "-P",
40
+ "-F",
41
+ "#{pane_id}",
42
+ command,
43
+ ],
44
+ { encoding: "utf8" },
45
+ );
46
+ if (r.status !== 0) {
47
+ throw new Error(`tmux split-window failed: ${r.stderr || r.stdout}`);
48
+ }
49
+ return r.stdout.trim();
50
+ }
51
+
52
+ export function captureMainPane(lines = 80) {
53
+ const pane = currentPaneId();
54
+ if (!pane) return "";
55
+ const r = spawnSync(
56
+ "tmux",
57
+ ["capture-pane", "-t", pane, "-p", "-S", `-${lines}`],
58
+ { encoding: "utf8" },
59
+ );
60
+ if (r.status !== 0) return "";
61
+ return r.stdout;
62
+ }
63
+
64
+ // Heuristic activity inference from captured pane text.
65
+ // Returns one of: "working" | "idle" | "error" | "unknown"
66
+ export function inferActivity(paneText) {
67
+ if (!paneText) return "unknown";
68
+ const tail = paneText.split("\n").slice(-40).join("\n");
69
+
70
+ // Opencode-specific: while generating, it shows a spinner phrase
71
+ if (/\bgenerating\b|\besc to cancel\b|\bthinking\b/i.test(tail))
72
+ return "working";
73
+
74
+ // Error markers
75
+ if ((/\berror\b|\bfailed\b|\bexception\b/i.test(tail)) &&
76
+ !/no error/i.test(tail)) {
77
+ return "error";
78
+ }
79
+
80
+ // Idle: looks like an empty prompt waiting for input
81
+ if (/^\s*>\s*$/m.test(tail) || /\bctrl\+t to view\b/i.test(tail))
82
+ return "idle";
83
+
84
+ return "unknown";
85
+ }
86
+
87
+ export function killBuddyPane(paneId) {
88
+ if (!paneId) return;
89
+ spawnSync("tmux", ["kill-pane", "-t", paneId], { encoding: "utf8" });
90
+ }
package/src/tui.js ADDED
@@ -0,0 +1,164 @@
1
+ // TUI renderer. Pure ANSI, no third-party deps.
2
+ //
3
+ // Lifecycle:
4
+ // const tui = createTui()
5
+ // tui.start()
6
+ // ... on every tick:
7
+ // tui.render({ buddy, status, hint })
8
+ // tui.stop()
9
+ //
10
+ // Input handling: registers a keypress listener. Keys are mapped by
11
+ // `bindKey()` to user-defined callbacks.
12
+
13
+ import readline from "node:readline";
14
+ import { paint, frameWidth, frameHeight, FLAVOR } from "./buddy/species.js";
15
+ import { describe } from "./buddy/state.js";
16
+
17
+ const ESC = "\x1b";
18
+ const HIDE_CURSOR = `${ESC}[?25l`;
19
+ const SHOW_CURSOR = `${ESC}[?25h`;
20
+ const ALT_SCREEN_ON = `${ESC}[?1049h`;
21
+ const ALT_SCREEN_OFF = `${ESC}[?1049l`;
22
+ const CLEAR = `${ESC}[2J${ESC}[H`;
23
+ const RESET = `${ESC}[0m`;
24
+ const BOLD = `${ESC}[1m`;
25
+ const DIM = `${ESC}[2m`;
26
+ const CYAN = `${ESC}[36m`;
27
+ const YELLOW = `${ESC}[33m`;
28
+ const GREEN = `${ESC}[32m`;
29
+ const MAGENTA = `${ESC}[35m`;
30
+ const GRAY = `${ESC}[90m`;
31
+ const BRIGHT_WHITE = `${ESC}[97m`;
32
+
33
+ const FRAME_INTERVAL_MS = 200;
34
+
35
+ function pickFlavor(species, seed) {
36
+ const lines = FLAVOR[species] || ["..."];
37
+ return lines[seed % lines.length];
38
+ }
39
+
40
+ function bars(value, width = 20, colorFn = (i) => GREEN) {
41
+ const filled = Math.round((value / 100) * width);
42
+ return colorFn(0) + "█".repeat(filled) + GRAY + "░".repeat(width - filled) + RESET;
43
+ }
44
+
45
+ function buildFrame(state, opts = {}) {
46
+ const width = frameWidth(state.species);
47
+ const height = frameHeight(state.species);
48
+ const art = paint(state.species, state.state);
49
+
50
+ const lines = [];
51
+ // Top border
52
+ lines.push(
53
+ `${CYAN}┌${"─".repeat(width + 2)}┐${RESET}`,
54
+ );
55
+ // Art lines, padded to a consistent width
56
+ for (let i = 0; i < height; i++) {
57
+ const row = art[i] || " ".repeat(width);
58
+ lines.push(`${CYAN}│${RESET} ${row} ${CYAN}│${RESET}`);
59
+ }
60
+ // Bottom border
61
+ lines.push(
62
+ `${CYAN}└${"─".repeat(width + 2)}┘${RESET}`,
63
+ );
64
+ // Status block
65
+ const stateLabel = state.state.toUpperCase();
66
+ const reason = state.stateReason ? ` ${DIM}(${state.stateReason})${RESET}` : "";
67
+ lines.push(
68
+ ` ${BOLD}${state.name}${RESET} ${DIM}the${RESET} ${MAGENTA}${state.species}${RESET} ${DIM}·${RESET} ${YELLOW}${stateLabel}${RESET}${reason}`,
69
+ );
70
+ lines.push(` ${DIM}Lv${RESET} ${state.level} ${DIM}xp${RESET} ${state.xp}/${state.level * 50}`);
71
+ lines.push(
72
+ ` ${DIM}hunger${RESET} ${bars(state.hunger)} ${Math.floor(state.hunger)}`,
73
+ );
74
+ lines.push(
75
+ ` ${DIM}happy${RESET} ${bars(state.happiness, 20, () => MAGENTA)} ${Math.floor(state.happiness)}`,
76
+ );
77
+ lines.push(
78
+ ` ${DIM}energy${RESET} ${bars(state.energy, 20, () => CYAN)} ${Math.floor(state.energy)}`,
79
+ );
80
+ lines.push(
81
+ ` ${DIM}flavor${RESET} ${BRIGHT_WHITE}${pickFlavor(state.species, Math.floor(Date.now() / 4000))}${RESET}`,
82
+ );
83
+ if (opts.hint) {
84
+ lines.push("");
85
+ lines.push(` ${DIM}${opts.hint}${RESET}`);
86
+ }
87
+ return lines;
88
+ }
89
+
90
+ export function createTui({ onKey, onResize } = {}) {
91
+ let running = false;
92
+ let timer = null;
93
+ let lastState = null;
94
+ let lastHint = "";
95
+ let stdinRaw = false;
96
+
97
+ function ensureRawMode() {
98
+ if (!process.stdin.isTTY) return;
99
+ if (stdinRaw) return;
100
+ readline.emitKeypressEvents(process.stdin);
101
+ process.stdin.setRawMode(true);
102
+ process.stdin.resume();
103
+ stdinRaw = true;
104
+ }
105
+
106
+ function onData(buf) {
107
+ const key = buf.toString("utf8");
108
+ // ctrl-c: signal stop, but let main handle the actual exit
109
+ if (key === "\u0003") {
110
+ if (onKey) onKey("ctrl-c");
111
+ return;
112
+ }
113
+ if (onKey) onKey(key);
114
+ }
115
+
116
+ function onResize() {
117
+ if (onResize) onResize();
118
+ }
119
+
120
+ function render() {
121
+ if (!lastState) return;
122
+ const out = buildFrame(lastState, { hint: lastHint }).join("\n") + "\n";
123
+ process.stdout.write(CLEAR + out);
124
+ }
125
+
126
+ return {
127
+ start() {
128
+ if (running) return;
129
+ running = true;
130
+ ensureRawMode();
131
+ process.stdin.on("data", onData);
132
+ process.stdout.on("resize", onResize);
133
+ process.stdout.write(ALT_SCREEN_ON + HIDE_CURSOR + CLEAR);
134
+ timer = setInterval(render, FRAME_INTERVAL_MS);
135
+ render();
136
+ },
137
+
138
+ stop() {
139
+ if (!running) return;
140
+ running = false;
141
+ if (timer) clearInterval(timer);
142
+ timer = null;
143
+ process.stdin.off("data", onData);
144
+ process.stdout.off("resize", onResize);
145
+ if (stdinRaw && process.stdin.isTTY) {
146
+ process.stdin.setRawMode(false);
147
+ stdinRaw = false;
148
+ }
149
+ process.stdout.write(SHOW_CURSOR + ALT_SCREEN_OFF);
150
+ },
151
+
152
+ render(state, hint = "") {
153
+ lastState = state;
154
+ lastHint = hint;
155
+ render();
156
+ },
157
+
158
+ // Returns the natural frame width (so callers can size the tmux pane)
159
+ naturalWidth: () => (lastState ? frameWidth(lastState.species) + 4 : 26),
160
+ naturalHeight: () => (lastState ? frameHeight(lastState.species) + 12 : 18),
161
+ };
162
+ }
163
+
164
+ export { describe };