Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ bash scripts/install-kwin-script.sh

Create your config files in `~/.config/shortcut-sage/`:

**config.toml**: Global settings (optional, uses defaults if not present)
```toml
[privacy]
# Enable window title capture for better context-aware suggestions
# Recommended: true for best results, false if privacy is a concern
capture_window_titles = false

# Optionally redact titles from logs (reduces audit trail quality)
redact_titles_from_logs = true

[engine]
buffer_size_seconds = 3
default_cooldown_seconds = 300
max_suggestions = 3

[overlay]
position = "top-left"
opacity = 0.9
```

See [config.toml](config.toml) for all available options.

**shortcuts.yaml**: Define your shortcuts
```yaml
version: "1.0"
Expand Down Expand Up @@ -152,9 +174,12 @@ See [implementation-plan.md](implementation-plan.md) for full roadmap.

- **No keylogging**: Only symbolic events (window focus, desktop switch)
- **Local processing**: No cloud, no telemetry
- **Redacted by default**: Window titles not logged
- **Flexible data capture**: Window titles configurable via config.toml for improved suggestions
- **Optional redaction**: Separate control for logging vs. suggestion logic (if privacy preferred)
- **Open source**: Audit the code yourself

> **Note**: For best results, enable `capture_window_titles = true` in config.toml. This allows more targeted, context-aware suggestions. Privacy controls are available but may reduce suggestion quality.

## Contributing

Contributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md) first.
Expand Down
63 changes: 63 additions & 0 deletions config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Shortcut Sage Configuration
# Place this file in your config directory (default: ~/.config/shortcut-sage/)

[general]
# Enable debug logging (verbose output)
debug = false

[privacy]
# Capture and use window titles (captions) in suggestion logic
# - true: Full window titles sent to daemon (e.g., "Mozilla Firefox - Stack Overflow")
# RECOMMENDED for better, more context-aware suggestions
# - false: Only application resource class sent (e.g., "firefox")
# Limits suggestion accuracy for privacy
#
# Window titles enable more targeted suggestions based on:
# - Document names (suggest Kate shortcuts when editing *.py files)
# - Web page context (suggest browser shortcuts on specific sites)
# - More nuanced workflow understanding
capture_window_titles = false

# Redact window titles from persistent logs (even if capture_window_titles = true)
# Set to false if you want full audit trails for debugging/improvement
redact_titles_from_logs = true

[engine]
# Ring buffer size in seconds (how far back to look for patterns)
buffer_size_seconds = 3

# Default cooldown in seconds (can be overridden per-rule in rules.yaml)
default_cooldown_seconds = 300

# Maximum number of suggestions to show simultaneously
max_suggestions = 3

[overlay]
# Show the suggestion overlay
enabled = true

# Overlay position (top-left, top-right, bottom-left, bottom-right)
position = "top-left"

# Overlay offset from screen edge in pixels
offset_x = 20
offset_y = 20

# Auto-hide overlay after N seconds (0 = never auto-hide)
auto_hide_seconds = 0

# Overlay transparency (0.0 = invisible, 1.0 = opaque)
opacity = 0.9

[logging]
# Log format: "json" or "ndjson"
format = "ndjson"

# Log rotation: maximum file size in MB before rotation
max_log_size_mb = 10

# Number of rotated log files to keep
max_log_files = 5

# Log level: "debug", "info", "warning", "error"
level = "info"
263 changes: 157 additions & 106 deletions kwin/event-monitor.js
Original file line number Diff line number Diff line change
@@ -1,125 +1,176 @@
/*
* Shortcut Sage Event Monitor - KWin Script
* Monitors KDE Plasma events and sends them to Shortcut Sage daemon
/**
* Shortcut Sage - KWin Event Monitor
*
* Monitors desktop events and sends them to the Shortcut Sage daemon via DBus.
*
* Events monitored:
* - Desktop/workspace switches
* - Window focus changes (including window titles for better context)
* - Show desktop state changes
*
* Dev shortcut: Meta+Shift+S sends a test event
*
* Window titles are sent to improve suggestion accuracy. The daemon's
* config.toml controls whether titles are used for suggestions and/or
* logged. Configure privacy.capture_window_titles to balance accuracy
* vs. privacy based on your preferences.
*/

// Configuration
const DAEMON_SERVICE = "org.shortcutsage.Daemon";
const DAEMON_PATH = "/org/shortcutsage/Daemon";
// DBus connection to Shortcut Sage daemon
const BUS_NAME = "org.shortcutsage.Daemon";
const OBJECT_PATH = "/org/shortcutsage/Daemon";
const INTERFACE = "org.shortcutsage.Daemon";

// Initialize DBus interface
function initDBus() {
try {
var dbusInterface = workspace.knownInterfaces[DAEMON_SERVICE];
if (dbusInterface) {
print("Found Shortcut Sage daemon interface");
return true;
} else {
print("Shortcut Sage daemon not available");
return false;
}
} catch (error) {
print("Failed to connect to Shortcut Sage daemon: " + error);
return false;
// Logging configuration
const DEBUG = false; // Set to true for verbose logging during development
const LOG_PREFIX = "[ShortcutSage]";

// Helper function for logging
function log(message) {
if (DEBUG) {
console.log(LOG_PREFIX + " " + message);
}
}

// Function to send event to daemon via DBus
function logError(message) {
console.error(LOG_PREFIX + " ERROR: " + message);
}

// Initialize the script
log("Initializing KWin Event Monitor");

/**
* Send an event to the daemon via DBus
* @param {string} type - Event type (e.g., "window_focus", "desktop_switch")
* @param {string} action - Action name (e.g., "show_desktop", "tile_left")
* @param {Object} metadata - Additional metadata (optional)
*/
function sendEvent(type, action, metadata) {
// Using DBus to call the daemon's SendEvent method
callDBus(
DAEMON_SERVICE,
DAEMON_PATH,
DAEMON_SERVICE,
"SendEvent",
JSON.stringify({
try {
// Build event object
const event = {
timestamp: new Date().toISOString(),
type: type,
action: action,
metadata: metadata || {}
})
);
}
};

// Monitor workspace events
function setupEventListeners() {
// Desktop switch events
workspace.clientDesktopChanged.connect(function(client, desktop) {
sendEvent("desktop_switch", "switch_desktop", {
window: client ? client.caption : "unknown",
desktop: desktop
});
});

// Window focus events
workspace.clientActivated.connect(function(client) {
if (client) {
sendEvent("window_focus", "window_focus", {
window: client.caption,
app: client.resourceClass ? client.resourceClass.toString() : "unknown"
});
}
});

// Screen edge activation (overview, etc.)
workspace.screenEdgeActivated.connect(function(edge, desktop) {
var action = "unknown";
if (edge === 0) action = "overview"; // Top edge usually shows overview
else if (edge === 2) action = "application_launcher"; // Bottom edge
else action = "screen_edge";

sendEvent("desktop_state", action, {
edge: edge,
desktop: desktop
});
});

// Window geometry changes (for tiling, maximizing, etc.)
workspace.clientStepUserMovedResized.connect(function(client, step) {
if (client && step) {
var action = "window_move";
if (client.maximizedHorizontally && client.maximizedVertically) {
action = "maximize";
} else if (!client.maximizedHorizontally && !client.maximizedVertically) {
action = "window_move";
}

sendEvent("window_state", action, {
window: client.caption,
maximized: client.maximizedHorizontally && client.maximizedVertically
});
}
});
const eventJson = JSON.stringify(event);
log("Sending event: " + eventJson);

// Call DBus method
callDBus(
BUS_NAME,
OBJECT_PATH,
INTERFACE,
"SendEvent",
eventJson
);
} catch (error) {
logError("Failed to send event: " + error);
}
}

// Register a test shortcut for development
function setupTestShortcut() {
registerShortcut(
"Shortcut Sage Test",
"Test shortcut for Shortcut Sage development",
"Ctrl+Alt+S",
function() {
sendEvent("test", "test_shortcut", {
source: "kwin_script"
});
}
);
/**
* Ping the daemon to check if it's alive
*/
function pingDaemon() {
try {
const result = callDBus(
BUS_NAME,
OBJECT_PATH,
INTERFACE,
"Ping"
);
log("Ping result: " + result);
return result === "pong";
} catch (error) {
logError("Daemon not responding to ping: " + error);
return false;
}
}

// Initialize when script loads
function init() {
print("Shortcut Sage KWin script initializing...");

if (initDBus()) {
setupEventListeners();
setupTestShortcut();
print("Shortcut Sage KWin script initialized successfully");
} else {
print("Shortcut Sage KWin script initialized in fallback mode - daemon not available");
// Still set up events but with fallback behavior if needed
setupTestShortcut();
// Track previous state to detect changes
Copy link

Copilot AI Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Variables previousDesktop and showingDesktop are declared with let at the script level. Consider using const for values that represent state, or add comments explaining why mutable global state is necessary for tracking desktop changes.

Suggested change
// Track previous state to detect changes
// Track previous state to detect changes
// These variables must be mutable (using `let`) because they are updated in event handlers

Copilot uses AI. Check for mistakes.
// These variables must be mutable (using `let`) because they are updated
// in event handlers to compare with new states
let previousDesktop = workspace.currentDesktop;
let showingDesktop = workspace.showingDesktop;

/**
* Monitor desktop/workspace switches
*/
workspace.currentDesktopChanged.connect(function(desktop, client) {
if (desktop !== previousDesktop) {
log("Desktop switched: " + previousDesktop + " -> " + desktop);
sendEvent(
"desktop_switch",
"switch_desktop",
{
from: previousDesktop,
to: desktop
}
);
previousDesktop = desktop;
Comment on lines +102 to +113

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track currentDesktopChanged using new desktop value

The handler treats the desktop argument of currentDesktopChanged as the destination desktop, but in KWin this signal passes the previous desktop while the new desktop must be read from workspace.currentDesktop. As a result the first desktop switch is never reported and subsequent events emit from/to values for the prior transition (e.g. switching 2→3 sends {from: 1, to: 2}). This breaks the feature’s core purpose of reporting desktop switches. Capture const newDesktop = workspace.currentDesktop before emitting the event and update previousDesktop to that value instead of the desktop parameter.

Useful? React with 👍 / 👎.

}
});

/**
* Monitor "Show Desktop" state changes
*/
workspace.showingDesktopChanged.connect(function(showing) {
if (showing !== showingDesktop) {
log("Show desktop changed: " + showing);
const action = showing ? "show_desktop" : "hide_desktop";
sendEvent(
"show_desktop",
action,
{ showing: showing }
);
showingDesktop = showing;
}
});

/**
* Monitor active window (focus) changes
*/
workspace.clientActivated.connect(function(client) {
if (client) {
log("Window activated: " + client.caption);
sendEvent(
"window_focus",
"window_activated",
{
caption: client.caption,
resourceClass: client.resourceClass || "unknown"
}
);
}
});

/**
* Dev shortcut: Meta+Shift+S to send a test event
*/
registerShortcut(
"ShortcutSage: Test Event",
"ShortcutSage: Send Test Event (Meta+Shift+S)",
"Meta+Shift+S",
function() {
log("Test event triggered");
sendEvent(
"test",
"test_event",
{ source: "dev_shortcut" }
);
}
);

// Ping daemon on startup to verify connection
log("Pinging daemon...");
if (pingDaemon()) {
log("Successfully connected to daemon");
} else {
logError("Could not connect to daemon - is it running?");
logError("Start the daemon with: shortcut-sage daemon <config_dir>");
}

// Run initialization
init();
log("KWin Event Monitor initialized successfully");
Loading
Loading