Compare commits

..
1 Commits
Author SHA1 Message Date
thread c89ff2b68e LLMIFY THIS MOFO
CI / Test (push) Successful in 58s
2026-09-01 01:09:59 -04:00
9 changed files with 44 additions and 129 deletions
+1 -4
View File
@@ -486,15 +486,12 @@ backends = ["clipboard-paste", "ydotool", "wtype", "clipboard"] # Ordered fallb
ydotool_timeout = "5s" ydotool_timeout = "5s"
wtype_timeout = "5s" wtype_timeout = "5s"
clipboard_timeout = "3s" clipboard_timeout = "3s"
ctrl_shift_v_classes = ["ghostty"] # Window-class substrings that paste with Ctrl+Shift+V
``` ```
### Injection Backends ### Injection Backends
- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps. - **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
- **`clipboard-paste`**: Temporarily copies text to the regular clipboard, sends Ctrl+V through wtype, then restores the prior text clipboard. This works in browsers and Electron apps and is layout-independent; recommended for Dvorak/Colemak users. Requires `wl-clipboard` and `wtype`. - **`clipboard-paste`**: Copies text to the primary selection then sends Shift+Insert through ydotool. Fast, layout-independent, and preserves the regular clipboard; recommended for Dvorak/Colemak users.
`ctrl_shift_v_classes` selects applications that need Ctrl+Shift+V instead of Ctrl+V. Matching is case-insensitive against Hyprland's active-window data, and the default `"ghostty"` handles Ghostty. Add a terminal's class substring to this list when needed.
- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug). - **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug).
- **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste. - **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste.
+4 -5
View File
@@ -112,10 +112,9 @@ func (c *Config) IsLLMEnabled() bool {
func (c *Config) ToInjectionConfig() injection.Config { func (c *Config) ToInjectionConfig() injection.Config {
return injection.Config{ return injection.Config{
Backends: c.Injection.Backends, Backends: c.Injection.Backends,
YdotoolTimeout: c.Injection.YdotoolTimeout, YdotoolTimeout: c.Injection.YdotoolTimeout,
WtypeTimeout: c.Injection.WtypeTimeout, WtypeTimeout: c.Injection.WtypeTimeout,
ClipboardTimeout: c.Injection.ClipboardTimeout, ClipboardTimeout: c.Injection.ClipboardTimeout,
CtrlShiftVClasses: c.Injection.CtrlShiftVClasses,
} }
} }
+4 -5
View File
@@ -20,11 +20,10 @@ func DefaultConfig() *Config {
Threads: 0, Threads: 0,
}, },
Injection: InjectionConfig{ Injection: InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, Backends: []string{"ydotool", "wtype", "clipboard"},
YdotoolTimeout: 5 * time.Second, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second,
ClipboardTimeout: 3 * time.Second, ClipboardTimeout: 3 * time.Second,
CtrlShiftVClasses: []string{"ghostty"},
}, },
Notifications: NotificationsConfig{ Notifications: NotificationsConfig{
Enabled: false, Enabled: false,
+1 -10
View File
@@ -120,14 +120,6 @@ func Save(cfg *Config) error {
sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String())) sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String()))
sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String())) sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String()))
sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String())) sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String()))
sb.WriteString(" ctrl_shift_v_classes = [")
for i, class := range cfg.Injection.CtrlShiftVClasses {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%q", class))
}
sb.WriteString("]\n")
sb.WriteString("\n") sb.WriteString("\n")
// Notifications // Notifications
@@ -344,8 +336,7 @@ keywords = []
# - "llama-swap": Any chat model configured in your LlamaSwap server # - "llama-swap": Any chat model configured in your LlamaSwap server
# #
# Injection backends: # Injection backends:
# - "clipboard-paste": Temporarily uses the regular clipboard + wtype Ctrl+V, then restores prior text clipboard. # - "clipboard-paste": Uses primary selection + ydotool Shift+Insert; fast, layout-independent, and preserves clipboard.
# - ctrl_shift_v_classes: Window-class substrings that use Ctrl+Shift+V instead (default: ["ghostty"]).
# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps. # - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps.
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps. # - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps.
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste). # - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
+4 -5
View File
@@ -71,11 +71,10 @@ type TranscriptionConfig struct {
} }
type InjectionConfig struct { type InjectionConfig struct {
Backends []string `toml:"backends"` Backends []string `toml:"backends"`
YdotoolTimeout time.Duration `toml:"ydotool_timeout"` YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
WtypeTimeout time.Duration `toml:"wtype_timeout"` WtypeTimeout time.Duration `toml:"wtype_timeout"`
ClipboardTimeout time.Duration `toml:"clipboard_timeout"` ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
CtrlShiftVClasses []string `toml:"ctrl_shift_v_classes"`
} }
type NotificationsConfig struct { type NotificationsConfig struct {
+24 -89
View File
@@ -2,28 +2,27 @@ package injection
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
) )
// clipboardPasteBackend temporarily puts text on the regular clipboard, then // clipboardPasteBackend copies text to the primary selection and pastes it
// uses wtype to issue Ctrl+V. This works in applications that do not support // with ydotool's layout-independent Shift+Insert key codes.
// the primary selection, such as browsers and Electron apps. The previous //
// text clipboard is restored after the paste has been delivered. // This preserves the regular clipboard, is independent of the active keyboard
// layout, and delivers the whole string at once.
type clipboardPasteBackend struct { type clipboardPasteBackend struct {
clipboard *clipboardBackend clipboard *clipboardBackend
wtype *wtypeBackend ydotool *ydotoolBackend
ctrlShiftVClasses []string
} }
func NewClipboardPasteBackend(ctrlShiftVClasses []string) Backend { func NewClipboardPasteBackend() Backend {
return &clipboardPasteBackend{ return &clipboardPasteBackend{
clipboard: NewClipboardBackend().(*clipboardBackend), clipboard: NewClipboardBackend().(*clipboardBackend),
wtype: NewWtypeBackend().(*wtypeBackend), ydotool: NewYdotoolBackend().(*ydotoolBackend),
ctrlShiftVClasses: ctrlShiftVClasses,
} }
} }
@@ -35,7 +34,7 @@ func (c *clipboardPasteBackend) Available() error {
if err := c.clipboard.Available(); err != nil { if err := c.clipboard.Available(); err != nil {
return err return err
} }
if err := c.wtype.Available(); err != nil { if err := c.ydotool.Available(); err != nil {
return err return err
} }
return nil return nil
@@ -48,18 +47,12 @@ func (c *clipboardPasteBackend) Inject(ctx context.Context, text string, timeout
if err := c.Available(); err != nil { if err := c.Available(); err != nil {
return err return err
} }
// wl-paste only needs to keep the clipboard owner alive while it reads the // Shift+Insert pastes the primary selection, leaving the user's regular
// selection, so this command returns with the exact existing text content. // clipboard untouched.
// --no-newline prevents wl-paste from adding a terminal-friendly newline. primaryCopy := exec.CommandContext(ctx, "wl-copy", "--primary")
previousClipboard, restore, err := c.readClipboard(ctx) primaryCopy.Stdin = strings.NewReader(text)
if err != nil { if err := primaryCopy.Run(); err != nil {
return err return fmt.Errorf("copy transcription to primary selection: %w", err)
}
copyText := exec.CommandContext(ctx, "wl-copy")
copyText.Stdin = strings.NewReader(text)
if err := copyText.Run(); err != nil {
return fmt.Errorf("copy transcription to clipboard: %w", err)
} }
// wl-copy returns before every client has observed the new selection. Give // wl-copy returns before every client has observed the new selection. Give
// the compositor a short chance to publish it; this is still perceived as // the compositor a short chance to publish it; this is still perceived as
@@ -70,72 +63,14 @@ func (c *clipboardPasteBackend) Inject(ctx context.Context, text string, timeout
return ctx.Err() return ctx.Err()
} }
// wtype resolves the keysym itself, avoiding ydotool's physical-keycode // Linux input event codes: KEY_LEFTSHIFT=42, KEY_INSERT=110. These keys do
// layout problem on Dvorak and Colemak setups. Terminal emulators typically // not depend on the alphabetic layout, and Shift+Insert is widely supported.
// reserve Ctrl+V, so configured window classes use Ctrl+Shift+V instead. cmd := exec.CommandContext(ctx, "ydotool", "key", "42:1", "110:1", "110:0", "42:0")
args := []string{"-M", "ctrl"} if socketPath := c.ydotool.getSocketPath(); socketPath != "" {
if c.shouldUseCtrlShiftV(ctx) { cmd.Env = append(os.Environ(), "YDOTOOL_SOCKET="+socketPath)
args = append(args, "-M", "shift", "-k", "v", "-m", "shift")
} else {
args = append(args, "-k", "v")
} }
args = append(args, "-m", "ctrl")
cmd := exec.CommandContext(ctx, "wtype", args...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("paste clipboard with wtype: %w", err) return fmt.Errorf("paste primary selection with ydotool: %w", err)
}
if !restore {
return nil
}
// Receiving a Wayland paste is asynchronous. Leave the transcription
// available briefly, then put the user's prior clipboard back.
select {
case <-time.After(300 * time.Millisecond):
case <-ctx.Done():
return ctx.Err()
}
restoreClipboard := exec.CommandContext(ctx, "wl-copy")
restoreClipboard.Stdin = strings.NewReader(previousClipboard)
if err := restoreClipboard.Run(); err != nil {
return fmt.Errorf("restore previous clipboard: %w", err)
} }
return nil return nil
} }
func (c *clipboardPasteBackend) shouldUseCtrlShiftV(ctx context.Context) bool {
if len(c.ctrlShiftVClasses) == 0 {
return false
}
cmd := exec.CommandContext(ctx, "hyprctl", "activewindow", "-j")
output, err := cmd.Output()
if err != nil {
return false
}
var activeWindow struct {
Class string `json:"class"`
}
if err := json.Unmarshal(output, &activeWindow); err != nil {
return false
}
class := strings.ToLower(activeWindow.Class)
for _, match := range c.ctrlShiftVClasses {
if match = strings.TrimSpace(strings.ToLower(match)); match != "" && strings.Contains(class, match) {
return true
}
}
return false
}
func (c *clipboardPasteBackend) readClipboard(ctx context.Context) (text string, restore bool, err error) {
cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline")
output, err := cmd.Output()
if err != nil {
// No clipboard owner is normal. There is simply nothing to restore.
if _, ok := err.(*exec.ExitError); ok {
return "", false, nil
}
return "", false, fmt.Errorf("read existing clipboard: %w", err)
}
return string(output), true, nil
}
+5 -6
View File
@@ -12,11 +12,10 @@ type Injector interface {
} }
type Config struct { type Config struct {
Backends []string // Ordered list: "clipboard-paste", "ydotool", "wtype", "clipboard" Backends []string // Ordered list: "clipboard-paste", "ydotool", "wtype", "clipboard"
YdotoolTimeout time.Duration // Timeout for ydotool commands YdotoolTimeout time.Duration // Timeout for ydotool commands
WtypeTimeout time.Duration // Timeout for wtype commands WtypeTimeout time.Duration // Timeout for wtype commands
ClipboardTimeout time.Duration // Timeout for clipboard operations ClipboardTimeout time.Duration // Timeout for clipboard operations
CtrlShiftVClasses []string // Hyprland window-class substrings that paste with Ctrl+Shift+V
} }
type injector struct { type injector struct {
@@ -36,7 +35,7 @@ func NewInjector(config Config) Injector {
case "clipboard": case "clipboard":
backends = append(backends, NewClipboardBackend()) backends = append(backends, NewClipboardBackend())
case "clipboard-paste": case "clipboard-paste":
backends = append(backends, NewClipboardPasteBackend(config.CtrlShiftVClasses)) backends = append(backends, NewClipboardPasteBackend())
default: default:
log.Printf("Injection: unknown backend %q, skipping", name) log.Printf("Injection: unknown backend %q, skipping", name)
} }
+1 -1
View File
@@ -204,7 +204,7 @@ func TestClipboardBackend(t *testing.T) {
} }
func TestClipboardPasteBackend(t *testing.T) { func TestClipboardPasteBackend(t *testing.T) {
backend := NewClipboardPasteBackend([]string{"ghostty"}) backend := NewClipboardPasteBackend()
if backend.Name() != "clipboard-paste" { if backend.Name() != "clipboard-paste" {
t.Errorf("Name() = %s, want clipboard-paste", backend.Name()) t.Errorf("Name() = %s, want clipboard-paste", backend.Name())
} }
-4
View File
@@ -344,10 +344,6 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re
} }
return r return r
}, textToInject) }, textToInject)
// Speech APIs occasionally include leading or trailing whitespace (for
// example Whisper commonly returns a leading space). It is transport
// formatting rather than dictated content, so never inject it.
textToInject = strings.TrimSpace(textToInject)
injector := p.injectorFactory(p.config.ToInjectionConfig()) injector := p.injectorFactory(p.config.ToInjectionConfig())
if err := injector.Inject(ctx, textToInject); err != nil { if err := injector.Inject(ctx, textToInject); err != nil {