Commit 7ef5f2a4af89

Vincent Demeester <vincent@sbr.pm>
2026-02-10 14:01:51
feat(kitty): added custom hints for Jira issues and word actions
Added Python-powered custom hints with keybindings: - Jira issue detection (SRVKP-123 pattern) with open/copy actions - Word selection with define and translate actions via Wiktionary and Google Translate
1 parent 89f1c1c
Changed files (3)
dots
pi
agent
extensions
home
common
desktop
dots/pi/agent/extensions/jira/index.ts
@@ -17,6 +17,7 @@
  */
 
 import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
 import { Text } from "@mariozechner/pi-tui";
 import { Type } from "@sinclair/typebox";
 import { StringEnum } from "@mariozechner/pi-ai";
@@ -442,6 +443,17 @@ export default function (pi: ExtensionAPI) {
 
 			const issues = parseIssueListJSON(result.stdout);
 
+			// Track recent issues for auto-completion
+			for (const issue of issues) {
+				if (!recentIssues.includes(issue.key)) {
+					recentIssues.push(issue.key);
+				}
+			}
+			// Keep only last 20
+			if (recentIssues.length > 20) {
+				recentIssues = recentIssues.slice(-20);
+			}
+
 			// Display results directly (like org-todos)
 			const lines: string[] = [];
 			lines.push("## 📋 My Open Issues (SRVKP)");
@@ -473,6 +485,25 @@ export default function (pi: ExtensionAPI) {
 	// /jira-view <key> - View specific issue
 	pi.registerCommand("jira-view", {
 		description: "View a Jira issue (e.g., /jira-view SRVKP-1234)",
+		getArgumentCompletions: (prefix: string) => {
+			// Suggest recent issues from session
+			if (recentIssues.length === 0) return null;
+
+			const normalizedPrefix = prefix.trim().toUpperCase();
+
+			const items = recentIssues.map((key) => ({
+				value: key,
+				label: `${key} - Recent issue`,
+			}));
+
+			// If no prefix, show all. Otherwise filter by prefix.
+			if (!normalizedPrefix) {
+				return items;
+			}
+
+			const filtered = items.filter((i) => i.value.startsWith(normalizedPrefix));
+			return filtered.length > 0 ? filtered : null;
+		},
 		handler: async (args, ctx) => {
 			if (!args) {
 				ctx.ui.notify("Usage: /jira-view ISSUE-KEY", "error");
@@ -495,6 +526,15 @@ export default function (pi: ExtensionAPI) {
 				return;
 			}
 
+			// Track for auto-completion
+			if (!recentIssues.includes(issueKey)) {
+				recentIssues.push(issueKey);
+			}
+			// Keep only last 20
+			if (recentIssues.length > 20) {
+				recentIssues = recentIssues.slice(-20);
+			}
+
 			// Display result directly
 			pi.sendMessage({
 				customType: "jira-view",
@@ -507,6 +547,139 @@ export default function (pi: ExtensionAPI) {
 	// /jira-search <query> - Search with JQL
 	pi.registerCommand("jira-search", {
 		description: "Search Jira with JQL (defaults to SRVKP project)",
+		getArgumentCompletions: (prefix: string) => {
+			// DON'T trim - we need to know if there's a trailing space
+			const hasTrailingSpace = prefix.endsWith(" ");
+			const trimmed = prefix.trim();
+			
+			// Get the last word
+			const words = trimmed.split(/\s+/);
+			const lastWord = hasTrailingSpace ? "" : (words[words.length - 1] || "");
+			const beforeLastWord = lastWord ? trimmed.substring(0, trimmed.length - lastWord.length) : trimmed + " ";
+
+			// Field completions (start of query or after AND/OR)
+			const fields = [
+				{ value: "status=", label: "status= - Issue status" },
+				{ value: "assignee=", label: "assignee= - Assigned to" },
+				{ value: "priority=", label: "priority= - Priority level" },
+				{ value: "type=", label: "type= - Issue type" },
+				{ value: "created", label: "created - Creation date" },
+				{ value: "updated", label: "updated - Last update" },
+				{ value: "labels=", label: "labels= - Issue labels" },
+				{ value: '"Epic Link"=', label: '"Epic Link"= - Epic parent' },
+				{ value: "reporter=", label: "reporter= - Who reported" },
+			];
+
+			// Status values
+			const statuses = [
+				{ value: "status=Blocked", label: "Blocked" },
+				{ value: 'status="Code Review"', label: "Code Review" },
+				{ value: 'status="In Progress"', label: "In Progress" },
+				{ value: 'status="To Do"', label: "To Do" },
+				{ value: "status=Done", label: "Done" },
+				{ value: "status=Waiting", label: "Waiting" },
+			];
+
+			// Assignee values
+			const assignees = [
+				{ value: "assignee=currentUser()", label: "Current user (me)" },
+				{ value: "assignee=EMPTY", label: "Unassigned" },
+			];
+
+			// Priority values
+			const priorities = [
+				{ value: "priority=Blocker", label: "Blocker" },
+				{ value: "priority=Critical", label: "Critical" },
+				{ value: "priority=Major", label: "Major" },
+				{ value: "priority IN (Blocker,Critical)", label: "High priority (Blocker,Critical)" },
+			];
+
+			// Type values
+			const types = [
+				{ value: "type=Bug", label: "Bug" },
+				{ value: "type=Epic", label: "Epic" },
+				{ value: "type=Task", label: "Task" },
+				{ value: "type=Story", label: "Story" },
+				{ value: 'type IN (Bug,Task)', label: "Bugs and tasks" },
+			];
+
+			// Time shortcuts
+			const times = [
+				{ value: "created >= -7d", label: "Created in last 7 days" },
+				{ value: "created >= -30d", label: "Created in last 30 days" },
+				{ value: "updated >= -7d", label: "Updated in last 7 days" },
+				{ value: "updated <= -30d", label: "Stale (30+ days)" },
+			];
+
+			// Logical operators
+			const operators = [
+				{ value: "AND ", label: "AND - Both conditions" },
+				{ value: "OR ", label: "OR - Either condition" },
+			];
+
+			// Combine all completions
+			let completions: AutocompleteItem[] = [];
+
+			// Context-aware suggestions
+			if (lastWord.toLowerCase().startsWith("status")) {
+				completions = statuses.map((s) => ({
+					value: beforeLastWord + s.value,
+					label: s.label,
+				}));
+			} else if (lastWord.toLowerCase().startsWith("assignee")) {
+				completions = assignees.map((a) => ({
+					value: beforeLastWord + a.value,
+					label: a.label,
+				}));
+			} else if (lastWord.toLowerCase().startsWith("priority")) {
+				completions = priorities.map((p) => ({
+					value: beforeLastWord + p.value,
+					label: p.label,
+				}));
+			} else if (lastWord.toLowerCase().startsWith("type")) {
+				completions = types.map((t) => ({
+					value: beforeLastWord + t.value,
+					label: t.label,
+				}));
+			} else if (lastWord.toLowerCase().startsWith("created") || lastWord.toLowerCase().startsWith("updated")) {
+				completions = times
+					.filter((t) => t.value.toLowerCase().startsWith(lastWord.toLowerCase()))
+					.map((t) => ({
+						value: beforeLastWord + t.value,
+						label: t.label,
+					}));
+			} else if (lastWord.length === 0 && trimmed.length > 0) {
+				// Just finished a word (ends with space), suggest fields for next condition
+				completions = fields.map((f) => ({
+					value: trimmed + f.value,
+					label: f.label,
+				}));
+			} else if (trimmed.length > 0 && !trimmed.endsWith(" ")) {
+				// In the middle of typing something - suggest operators after current word
+				completions = operators.map((o) => ({
+					value: trimmed + " " + o.value,
+					label: o.label,
+				}));
+			} else {
+				// Empty or start of query, suggest fields
+				completions = fields.map((f) => ({
+					value: f.value,
+					label: f.label,
+				}));
+			}
+
+			// Filter by what user has typed (only if lastWord is not empty to avoid matching everything)
+			if (lastWord.length > 0) {
+				const filtered = completions.filter(
+					(c) =>
+						c.value.toLowerCase().includes(lastWord.toLowerCase()) ||
+						c.label.toLowerCase().includes(lastWord.toLowerCase()),
+				);
+				return filtered.length > 0 ? filtered : null;
+			}
+
+			return completions.length > 0 ? completions : null;
+		},
 		handler: async (args, ctx) => {
 			if (!args) {
 				ctx.ui.notify("Usage: /jira-search <JQL query>", "error");
@@ -532,6 +705,17 @@ export default function (pi: ExtensionAPI) {
 
 			const issues = parseIssueListJSON(result.stdout);
 
+			// Track recent issues for auto-completion
+			for (const issue of issues) {
+				if (!recentIssues.includes(issue.key)) {
+					recentIssues.push(issue.key);
+				}
+			}
+			// Keep only last 20
+			if (recentIssues.length > 20) {
+				recentIssues = recentIssues.slice(-20);
+			}
+
 			// Display results directly
 			const lines: string[] = [];
 			lines.push(`## 🔎 Search Results`);
@@ -593,6 +777,17 @@ export default function (pi: ExtensionAPI) {
 
 			const issues = parseIssueListJSON(result.stdout);
 
+			// Track recent issues for auto-completion
+			for (const issue of issues) {
+				if (!recentIssues.includes(issue.key)) {
+					recentIssues.push(issue.key);
+				}
+			}
+			// Keep only last 20
+			if (recentIssues.length > 20) {
+				recentIssues = recentIssues.slice(-20);
+			}
+
 			// Display results directly
 			const lines: string[] = [];
 			lines.push("## 👤 My Issues (SRVKP)");
@@ -651,6 +846,17 @@ export default function (pi: ExtensionAPI) {
 
 			const issues = parseIssueListJSON(result.stdout);
 
+			// Track recent issues for auto-completion
+			for (const issue of issues) {
+				if (!recentIssues.includes(issue.key)) {
+					recentIssues.push(issue.key);
+				}
+			}
+			// Keep only last 20
+			if (recentIssues.length > 20) {
+				recentIssues = recentIssues.slice(-20);
+			}
+
 			// Display results directly
 			const lines: string[] = [];
 			lines.push("## 🚫 Blocked Issues (SRVKP)");
dots/pi/agent/extensions/jira/README.md
@@ -108,6 +108,10 @@ The extension provides several slash commands for **instant results** (no LLM in
 
 **All commands execute directly** - they call the jira CLI and display results immediately without going through the LLM!
 
+**Auto-completion support:**
+- `/jira-view` - Press Tab to see recent issues from your session
+- `/jira-search` - Press Tab to see common JQL patterns
+
 ### Examples
 
 ```bash
home/common/desktop/kitty.nix
@@ -105,6 +105,19 @@
       # URL hints - open URLs (already exists via kitty_mod+e but added for completeness)
       # map kitty_mod+u mkh --type url
 
+      # ============================================================================
+      # CUSTOM HINTS (Python-powered)
+      # ============================================================================
+
+      # Jira issue tracker - detect and open issues (SRVKP-123, JIRA-456, etc.)
+      map kitty_mod+j kitten hints --customize-processing ~/.config/kitty/jira-hints.py open
+      map kitty_mod+alt+j kitten hints --customize-processing ~/.config/kitty/jira-hints.py copy
+
+      # Word selection hints with multiple actions
+      # Copy word to clipboard (default word hint already at kitty_mod+d)
+      map kitty_mod+shift+d kitten hints --customize-processing ~/.config/kitty/word-hints.py define
+      map kitty_mod+t kitten hints --customize-processing ~/.config/kitty/word-hints.py translate
+
       # ============================================================================
       # KITTY POPUPS (tmux-style overlays)
       # ============================================================================
@@ -120,6 +133,117 @@
   # Create automatic theme files for dark/light mode switching
   # Kitty will automatically use these based on GNOME's color-scheme setting
   xdg.configFile = {
+    # Custom hint scripts
+    "kitty/jira-hints.py".text = ''
+      """
+      Kitty hints for Jira issue detection.
+      Detects patterns like SRVKP-123, JIRA-456, etc.
+
+      Actions (via extra_cli_args):
+        - 'open': Open issue in browser on issues.redhat.com (default)
+        - 'copy': Copy issue key to clipboard
+      """
+      import re
+
+
+      def mark(text, args, Mark, extra_cli_args, *a):
+          """Find all Jira issue references in the visible text."""
+          # Match common Jira issue patterns: PROJECT-123
+          # Supports uppercase project keys (2-10 chars) followed by hyphen and numbers
+          pattern = r'\b([A-Z]{2,10}-\d+)\b'
+          
+          for idx, m in enumerate(re.finditer(pattern, text)):
+              start, end = m.span()
+              issue_key = text[start:end].replace('\n', ''').replace('\0', ''')
+              
+              # Store the issue key for use in handle_result
+              yield Mark(idx, start, end, issue_key, {'issue_key': issue_key})
+
+
+      def handle_result(args, data, target_window_id, boss, extra_cli_args, *a):
+          """Perform action on selected Jira issue."""
+          matches, groupdicts = [], []
+          
+          for m, g in zip(data['match'], data['groupdicts']):
+              if m:
+                  matches.append(m)
+                  groupdicts.append(g)
+          
+          # Determine action from extra_cli_args
+          action = extra_cli_args[0] if extra_cli_args else 'open'
+          
+          for issue_key, match_data in zip(matches, groupdicts):
+              if action == 'copy':
+                  # Copy issue key to clipboard
+                  from kitty.fast_data_types import set_clipboard_string
+                  set_clipboard_string(issue_key)
+              else:
+                  # Open in browser (default)
+                  url = f'https://issues.redhat.com/browse/{issue_key}'
+                  boss.open_url(url)
+    '';
+
+    "kitty/word-hints.py".text = ''
+      """
+      Kitty hints for word selection with multiple actions.
+      Actions can be passed via extra_cli_args:
+        - 'copy': Copy to clipboard (default)
+        - 'define': Look up definition in dictionary
+        - 'translate': Translate word (English <-> French)
+      """
+      import re
+      import sys
+
+
+      def mark(text, args, Mark, extra_cli_args, *a):
+          """Find all words in the visible text."""
+          # Match words (including hyphenated and apostrophes)
+          pattern = r"\b[a-zA-ZÀ-ÿ][a-zA-ZÀ-ÿ'\-]*\b"
+          
+          for idx, m in enumerate(re.finditer(pattern, text)):
+              start, end = m.span()
+              word = text[start:end].replace('\n', ''').replace('\0', ''')
+              
+              # Skip very short words (single letters) unless it's "a" or "I"
+              if len(word) > 1 or word in ('a', 'A', 'I'):
+                  yield Mark(idx, start, end, word, {'word': word})
+
+
+      def handle_result(args, data, target_window_id, boss, extra_cli_args, *a):
+          """Perform action on selected word."""
+          matches, groupdicts = [], []
+          
+          for m, g in zip(data['match'], data['groupdicts']):
+              if m:
+                  matches.append(m)
+                  groupdicts.append(g)
+          
+          # Determine action from extra_cli_args
+          action = extra_cli_args[0] if extra_cli_args else 'copy'
+          
+          for word, match_data in zip(matches, groupdicts):
+              if action == 'define':
+                  # Open definition in dictionary (using Wiktionary)
+                  url = f'https://en.wiktionary.org/wiki/{word.lower()}'
+                  boss.open_url(url)
+                  
+              elif action == 'translate':
+                  # Open Google Translate
+                  url = f'https://translate.google.com/?sl=auto&tl=fr&text={word}'
+                  boss.open_url(url)
+                  
+              elif action == 'copy':
+                  # Copy to clipboard (default)
+                  # Use kitty's set-clipboard functionality
+                  from kitty.fast_data_types import set_clipboard_string
+                  set_clipboard_string(word)
+                  
+              else:
+                  # Unknown action - just copy
+                  from kitty.fast_data_types import set_clipboard_string
+                  set_clipboard_string(word)
+    '';
+
     "kitty/dark-theme.auto.conf".source =
       "${pkgs.kitty-themes}/share/kitty-themes/themes/Modus_Vivendi.conf";