Commit 7ef5f2a4af89
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";