feat: implement agent-notify CLI with hooks and tmux passthrough
Add OSC 777 notification sender, Cursor/Claude hook adapters, config/install commands, and README for Ghostty + tmux setup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Events Events `toml:"events"`
|
||||
Notify Notify `toml:"notify"`
|
||||
}
|
||||
|
||||
type Events struct {
|
||||
Stop bool `toml:"stop"`
|
||||
Idle bool `toml:"idle"`
|
||||
Tool bool `toml:"tool"`
|
||||
}
|
||||
|
||||
type Notify struct {
|
||||
Protocol string `toml:"protocol"`
|
||||
TitleTemplate string `toml:"title_template"`
|
||||
BodyStop string `toml:"body_stop"`
|
||||
BodyIdle string `toml:"body_idle"`
|
||||
BodyTool string `toml:"body_tool"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Events: Events{Stop: true, Idle: false, Tool: false},
|
||||
Notify: Notify{
|
||||
Protocol: "osc777",
|
||||
TitleTemplate: "{agent} — {context}",
|
||||
BodyStop: "等待输入",
|
||||
BodyIdle: "空闲 60s+,等待输入",
|
||||
BodyTool: "工具执行完成",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultPath() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".config", "agent-notify", "config.toml")
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
if path == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func LoadDefault() (Config, error) {
|
||||
return Load(DefaultPath())
|
||||
}
|
||||
|
||||
func (c Config) EventEnabled(event string) bool {
|
||||
switch strings.ToLower(event) {
|
||||
case "stop":
|
||||
return c.Events.Stop
|
||||
case "idle":
|
||||
return c.Events.Idle
|
||||
case "tool":
|
||||
return c.Events.Tool
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) BodyForEvent(event string) string {
|
||||
switch strings.ToLower(event) {
|
||||
case "idle":
|
||||
return c.Notify.BodyIdle
|
||||
case "tool":
|
||||
return c.Notify.BodyTool
|
||||
default:
|
||||
return c.Notify.BodyStop
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) WriteDefault(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return toml.NewEncoder(f).Encode(Default())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := Default()
|
||||
if !cfg.Events.Stop {
|
||||
t.Fatal("expected stop=true by default")
|
||||
}
|
||||
if cfg.Events.Idle {
|
||||
t.Fatal("expected idle=false by default")
|
||||
}
|
||||
if cfg.Notify.Protocol != "osc777" {
|
||||
t.Fatalf("expected osc777, got %q", cfg.Notify.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
content := `
|
||||
[events]
|
||||
stop = false
|
||||
tool = true
|
||||
|
||||
[notify]
|
||||
body_stop = "custom stop"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Events.Stop {
|
||||
t.Fatal("expected stop=false")
|
||||
}
|
||||
if !cfg.Events.Tool {
|
||||
t.Fatal("expected tool=true")
|
||||
}
|
||||
if cfg.Notify.BodyStop != "custom stop" {
|
||||
t.Fatalf("got %q", cfg.Notify.BodyStop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventEnabled(t *testing.T) {
|
||||
cfg := Default()
|
||||
if !cfg.EventEnabled("stop") {
|
||||
t.Fatal("expected stop enabled by default")
|
||||
}
|
||||
if !cfg.EventEnabled("STOP") {
|
||||
t.Fatal("expected case-insensitive match")
|
||||
}
|
||||
cfg.Events.Stop = false
|
||||
if cfg.EventEnabled("stop") {
|
||||
t.Fatal("expected stop disabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Meta struct {
|
||||
Agent string
|
||||
CWD string
|
||||
Context string
|
||||
Event string
|
||||
}
|
||||
|
||||
func Render(tmpl string, m Meta) string {
|
||||
ctx := m.ResolveContext(m.Context)
|
||||
out := strings.ReplaceAll(tmpl, "{agent}", m.Agent)
|
||||
out = strings.ReplaceAll(out, "{context}", ctx)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Meta) ResolveContext(window string) string {
|
||||
if window != "" {
|
||||
return window
|
||||
}
|
||||
if m.Context != "" {
|
||||
return m.Context
|
||||
}
|
||||
cwd := m.CWD
|
||||
if cwd == "" {
|
||||
cwd, _ = os.Getwd()
|
||||
}
|
||||
if cwd == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return filepath.Base(cwd)
|
||||
}
|
||||
|
||||
func TmuxWindowName() string {
|
||||
if os.Getenv("TMUX") == "" {
|
||||
return ""
|
||||
}
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "#{window_name}").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func MetaFromEnv(agent, event string) Meta {
|
||||
cwd := os.Getenv("AGENT_NOTIFY_CWD")
|
||||
if cwd == "" {
|
||||
cwd, _ = os.Getwd()
|
||||
}
|
||||
if a := os.Getenv("AGENT_NOTIFY_AGENT"); a != "" {
|
||||
agent = a
|
||||
}
|
||||
if e := os.Getenv("AGENT_NOTIFY_EVENT"); e != "" {
|
||||
event = e
|
||||
}
|
||||
return Meta{
|
||||
Agent: agent,
|
||||
CWD: cwd,
|
||||
Context: TmuxWindowName(),
|
||||
Event: event,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package context
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRenderTitle(t *testing.T) {
|
||||
meta := Meta{Agent: "Cursor", Context: "myapp"}
|
||||
got := Render("{agent} — {context}", meta)
|
||||
want := "Cursor — myapp"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextFromCWD(t *testing.T) {
|
||||
meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"}
|
||||
if meta.ResolveContext("") != "myapp" {
|
||||
t.Fatalf("got %q", meta.ResolveContext(""))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPrefersWindow(t *testing.T) {
|
||||
meta := Meta{Agent: "Claude", CWD: "/home/user/code/myapp"}
|
||||
if meta.ResolveContext("tmux-win") != "tmux-win" {
|
||||
t.Fatalf("expected window name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/context"
|
||||
"github.com/longbin/agent-notify/internal/notify"
|
||||
)
|
||||
|
||||
type claudePayload struct {
|
||||
StopHookActive bool `json:"stop_hook_active"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type claudeResponse struct {
|
||||
TerminalSequence string `json:"terminalSequence,omitempty"`
|
||||
}
|
||||
|
||||
func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error {
|
||||
if !cfg.EventEnabled(event) {
|
||||
_, err := io.WriteString(w, "{}\n")
|
||||
return err
|
||||
}
|
||||
var payload claudePayload
|
||||
_ = json.NewDecoder(r).Decode(&payload)
|
||||
if event == "stop" && payload.StopHookActive {
|
||||
_, err := io.WriteString(w, "{}\n")
|
||||
return err
|
||||
}
|
||||
|
||||
meta := context.MetaFromEnv("Claude", event)
|
||||
title := context.Render(cfg.Notify.TitleTemplate, meta)
|
||||
body := cfg.BodyForEvent(event)
|
||||
seq := notify.BuildSequence(cfg.Notify.Protocol, title, body)
|
||||
resp := claudeResponse{TerminalSequence: seq}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/context"
|
||||
"github.com/longbin/agent-notify/internal/notify"
|
||||
)
|
||||
|
||||
type cursorPayload struct {
|
||||
WorkspaceRoots []string `json:"workspace_roots"`
|
||||
}
|
||||
|
||||
func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error {
|
||||
if !cfg.EventEnabled(event) {
|
||||
return nil
|
||||
}
|
||||
var payload cursorPayload
|
||||
_ = json.NewDecoder(r).Decode(&payload)
|
||||
|
||||
meta := context.MetaFromEnv("Cursor", event)
|
||||
if len(payload.WorkspaceRoots) > 0 {
|
||||
meta.CWD = payload.WorkspaceRoots[0]
|
||||
}
|
||||
title := context.Render(cfg.Notify.TitleTemplate, meta)
|
||||
body := cfg.BodyForEvent(event)
|
||||
return notify.SendAuto(cfg.Notify.Protocol, title, body)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
)
|
||||
|
||||
func TestCursorStopHookDisabled(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Events.Stop = false
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeStopOutputsTerminalSequence(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
var out bytes.Buffer
|
||||
err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(resp["terminalSequence"], "777;notify") {
|
||||
t.Fatalf("bad sequence: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeStopHookActiveSkips(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
var out bytes.Buffer
|
||||
err := RunClaude(strings.NewReader(`{"stop_hook_active":true}`), cfg, "stop", &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "{}" {
|
||||
t.Fatalf("expected {}, got %q", out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
)
|
||||
|
||||
const cursorHookCmd = "agent-notify hook cursor stop"
|
||||
const cursorToolCmd = "agent-notify hook cursor tool"
|
||||
const claudeStopCmd = "agent-notify hook claude stop"
|
||||
const claudeIdleCmd = "agent-notify hook claude idle"
|
||||
|
||||
func CursorHooksPath() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".cursor", "hooks.json")
|
||||
}
|
||||
|
||||
func ClaudeSettingsPath() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".claude", "settings.json")
|
||||
}
|
||||
|
||||
func MergeCursorHooks(path string, force bool) error {
|
||||
doc := map[string]any{"version": 1, "hooks": map[string]any{}}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(data, &doc)
|
||||
}
|
||||
hooks, _ := doc["hooks"].(map[string]any)
|
||||
if hooks == nil {
|
||||
hooks = map[string]any{}
|
||||
doc["hooks"] = hooks
|
||||
}
|
||||
addHook(hooks, "stop", cursorHookCmd, force)
|
||||
cfg, _ := config.LoadDefault()
|
||||
if cfg.Events.Tool {
|
||||
addHook(hooks, "afterShellExecution", cursorToolCmd, force)
|
||||
}
|
||||
return writeJSON(path, doc)
|
||||
}
|
||||
|
||||
func addHook(hooks map[string]any, name, command string, force bool) {
|
||||
if existing, ok := hooks[name]; ok && !force {
|
||||
_ = existing
|
||||
return
|
||||
}
|
||||
hooks[name] = []any{map[string]string{"command": command}}
|
||||
}
|
||||
|
||||
func MergeClaudeSettings(path string, force bool) error {
|
||||
doc := map[string]any{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(data, &doc)
|
||||
}
|
||||
hooks, _ := doc["hooks"].(map[string]any)
|
||||
if hooks == nil {
|
||||
hooks = map[string]any{}
|
||||
doc["hooks"] = hooks
|
||||
}
|
||||
setClaudeHook(hooks, "Stop", claudeStopCmd, force)
|
||||
cfg, _ := config.LoadDefault()
|
||||
if cfg.Events.Idle {
|
||||
setClaudeHook(hooks, "Notification", claudeIdleCmd, force)
|
||||
}
|
||||
return writeJSON(path, doc)
|
||||
}
|
||||
|
||||
func setClaudeHook(hooks map[string]any, event, command string, force bool) {
|
||||
if _, ok := hooks[event]; ok && !force {
|
||||
return
|
||||
}
|
||||
hooks[event] = []any{
|
||||
map[string]any{
|
||||
"hooks": []any{
|
||||
map[string]string{
|
||||
"type": "command",
|
||||
"command": command,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(path string, doc any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
func InstallAll(force bool) error {
|
||||
if err := config.Default().WriteDefault(config.DefaultPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := MergeCursorHooks(CursorHooksPath(), force); err != nil {
|
||||
return err
|
||||
}
|
||||
return MergeClaudeSettings(ClaudeSettingsPath(), force)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeCursorHooks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hooks.json")
|
||||
existing := `{"version":1,"hooks":{"beforeShellExecution":[{"command":"other"}]}}`
|
||||
os.WriteFile(path, []byte(existing), 0644)
|
||||
|
||||
if err := MergeCursorHooks(path, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
var doc map[string]any
|
||||
json.Unmarshal(data, &doc)
|
||||
hooks := doc["hooks"].(map[string]any)
|
||||
stop := hooks["stop"].([]any)
|
||||
if len(stop) != 1 {
|
||||
t.Fatalf("expected stop hook added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCursorHooksNoOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hooks.json")
|
||||
existing := `{"version":1,"hooks":{"stop":[{"command":"existing"}]}}`
|
||||
os.WriteFile(path, []byte(existing), 0644)
|
||||
MergeCursorHooks(path, false)
|
||||
data, _ := os.ReadFile(path)
|
||||
var doc map[string]any
|
||||
json.Unmarshal(data, &doc)
|
||||
hooks := doc["hooks"].(map[string]any)
|
||||
stop := hooks["stop"].([]any)
|
||||
entry := stop[0].(map[string]any)
|
||||
if entry["command"] != "existing" {
|
||||
t.Fatal("should not overwrite existing stop hook without force")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package notify
|
||||
|
||||
import "strings"
|
||||
|
||||
func BuildSequence(protocol, title, body string) string {
|
||||
switch protocol {
|
||||
case "osc9":
|
||||
return "\033]9;" + escapeOSCField(body) + "\007"
|
||||
default:
|
||||
return "\033]777;notify;" + escapeOSCField(title) + ";" + escapeOSCField(body) + "\007"
|
||||
}
|
||||
}
|
||||
|
||||
func escapeOSCField(s string) string {
|
||||
return strings.ReplaceAll(s, ";", "\\;")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package notify
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildOSC777(t *testing.T) {
|
||||
seq := BuildSequence("osc777", "Claude — proj", "等待输入")
|
||||
want := "\033]777;notify;Claude — proj;等待输入\007"
|
||||
if seq != want {
|
||||
t.Fatalf("got %q want %q", seq, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOSC9(t *testing.T) {
|
||||
seq := BuildSequence("osc9", "ignored", "等待输入")
|
||||
want := "\033]9;等待输入\007"
|
||||
if seq != want {
|
||||
t.Fatalf("got %q want %q", seq, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemicolonInTitleEscaped(t *testing.T) {
|
||||
seq := BuildSequence("osc777", "a;b", "body")
|
||||
if seq != "\033]777;notify;a\\;b;body\007" {
|
||||
t.Fatalf("unexpected escape: %q", seq)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/tmux"
|
||||
)
|
||||
|
||||
type SendOptions struct {
|
||||
Protocol string
|
||||
Title string
|
||||
Body string
|
||||
Writer io.Writer
|
||||
InTmux bool
|
||||
Layers int
|
||||
ClientTTY string
|
||||
}
|
||||
|
||||
func Send(opts SendOptions) error {
|
||||
seq := BuildSequence(opts.Protocol, opts.Title, opts.Body)
|
||||
w := opts.Writer
|
||||
if w == nil {
|
||||
w = os.Stdout
|
||||
}
|
||||
|
||||
if opts.InTmux && opts.ClientTTY != "" {
|
||||
f, err := os.OpenFile(opts.ClientTTY, os.O_WRONLY, 0)
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
_, err = io.WriteString(f, seq)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
out := seq
|
||||
if opts.InTmux {
|
||||
layers := opts.Layers
|
||||
if layers <= 0 {
|
||||
layers = 1
|
||||
}
|
||||
out = tmux.WrapPassthroughLayers(seq, layers)
|
||||
}
|
||||
_, err := io.WriteString(w, out)
|
||||
return err
|
||||
}
|
||||
|
||||
func SendAuto(protocol, title, body string) error {
|
||||
inTmux := tmux.InTmux()
|
||||
clientTTY, _ := tmux.ClientTTY()
|
||||
layers := 0
|
||||
if inTmux {
|
||||
layers = 1
|
||||
}
|
||||
return Send(SendOptions{
|
||||
Protocol: protocol,
|
||||
Title: title,
|
||||
Body: body,
|
||||
InTmux: inTmux,
|
||||
Layers: layers,
|
||||
ClientTTY: clientTTY,
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotification(title, body string) error {
|
||||
if err := SendAuto("osc777", title, body); err != nil {
|
||||
return fmt.Errorf("send test notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendDirect(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := Send(SendOptions{
|
||||
Protocol: "osc777",
|
||||
Title: "Cursor — app",
|
||||
Body: "等待输入",
|
||||
Writer: &buf,
|
||||
InTmux: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("777;notify")) {
|
||||
t.Fatalf("missing osc777: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTmuxUsesPassthroughWhenNoTTY(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := Send(SendOptions{
|
||||
Protocol: "osc777",
|
||||
Title: "t",
|
||||
Body: "b",
|
||||
Writer: &buf,
|
||||
InTmux: true,
|
||||
Layers: 1,
|
||||
ClientTTY: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.HasPrefix(buf.Bytes(), []byte("\033Ptmux;")) {
|
||||
t.Fatalf("expected passthrough prefix, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package tmux
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func InTmux() bool {
|
||||
return os.Getenv("TMUX") != ""
|
||||
}
|
||||
|
||||
func ClientTTY() (string, error) {
|
||||
if !InTmux() {
|
||||
return "", nil
|
||||
}
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "#{client_tty}").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func WrapPassthrough(seq string) string {
|
||||
return "\033Ptmux;\033" + seq + "\033\\"
|
||||
}
|
||||
|
||||
func WrapPassthroughLayers(seq string, layers int) string {
|
||||
out := seq
|
||||
for i := 0; i < layers; i++ {
|
||||
out = WrapPassthrough(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func AllowPassthroughEnabled() (bool, string, error) {
|
||||
if !InTmux() {
|
||||
return true, "", nil
|
||||
}
|
||||
out, err := exec.Command("tmux", "show-option", "-gv", "allow-passthrough").Output()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
val := strings.TrimSpace(string(out))
|
||||
return val == "on" || val == "all", val, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tmux
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInTmux(t *testing.T) {
|
||||
t.Setenv("TMUX", "/tmp/tmux-123,1,0")
|
||||
if !InTmux() {
|
||||
t.Fatal("expected InTmux true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapPassthroughSingle(t *testing.T) {
|
||||
inner := "\033]777;notify;t;b\007"
|
||||
got := WrapPassthrough(inner)
|
||||
want := "\033Ptmux;\033" + inner + "\033\\"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapPassthroughNested(t *testing.T) {
|
||||
inner := "\033]777;notify;t;b\007"
|
||||
got := WrapPassthroughLayers(inner, 2)
|
||||
once := WrapPassthrough(inner)
|
||||
twice := WrapPassthrough(once)
|
||||
if got != twice {
|
||||
t.Fatalf("nested wrap mismatch")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user