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/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/opencode-buddy.js +45 -0
- package/package.json +61 -0
- package/src/buddy/species.js +399 -0
- package/src/buddy/state.js +161 -0
- package/src/index.js +302 -0
- package/src/install.js +157 -0
- package/src/persistence.js +28 -0
- package/src/plugin.js +178 -0
- package/src/tmux.js +90 -0
- package/src/tui.js +164 -0
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 };
|