ai-comm 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_comm/__init__.py +3 -0
- ai_comm/adapters/__init__.py +41 -0
- ai_comm/adapters/aider.py +63 -0
- ai_comm/adapters/base.py +132 -0
- ai_comm/adapters/claude.py +72 -0
- ai_comm/adapters/codex.py +50 -0
- ai_comm/adapters/cursor.py +71 -0
- ai_comm/adapters/gemini.py +56 -0
- ai_comm/adapters/generic.py +42 -0
- ai_comm/adapters/opencode.py +151 -0
- ai_comm/cli.py +94 -0
- ai_comm/commands/__init__.py +11 -0
- ai_comm/commands/response.py +122 -0
- ai_comm/commands/send.py +120 -0
- ai_comm/commands/window.py +42 -0
- ai_comm/kitten/ai_comm_kitten.py +254 -0
- ai_comm/kitten_client.py +199 -0
- ai_comm/parsers/__init__.py +27 -0
- ai_comm/parsers/base.py +108 -0
- ai_comm/parsers/utils.py +42 -0
- ai_comm/polling.py +88 -0
- ai_comm/registry.py +75 -0
- ai_comm/services/__init__.py +7 -0
- ai_comm/services/interaction.py +115 -0
- ai_comm-0.2.4.dist-info/METADATA +132 -0
- ai_comm-0.2.4.dist-info/RECORD +29 -0
- ai_comm-0.2.4.dist-info/WHEEL +4 -0
- ai_comm-0.2.4.dist-info/entry_points.txt +2 -0
- ai_comm-0.2.4.dist-info/licenses/LICENSE +21 -0
ai_comm/kitten_client.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Kitten client - wrapper for calling the simplified kitten via subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KittenError(Exception):
|
|
14
|
+
"""Base exception for kitten errors."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WindowNotFoundError(KittenError):
|
|
20
|
+
"""Window not found."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KittenCallError(KittenError):
|
|
26
|
+
"""Failed to call kitten."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class KittenResult:
|
|
33
|
+
"""Result from kitten call."""
|
|
34
|
+
|
|
35
|
+
status: str
|
|
36
|
+
data: dict[str, Any]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KittenClient:
|
|
40
|
+
"""Client for calling the simplified ai_comm_kitten."""
|
|
41
|
+
|
|
42
|
+
# Default kitten path - bundled within the package
|
|
43
|
+
KITTEN_PATH = Path(__file__).parent / "kitten" / "ai_comm_kitten.py"
|
|
44
|
+
|
|
45
|
+
def __init__(self, socket: str | None = None) -> None:
|
|
46
|
+
"""Initialize client.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
socket: Optional kitty socket path. If None, uses KITTY_LISTEN_ON env var.
|
|
50
|
+
"""
|
|
51
|
+
self.socket = socket or os.environ.get("KITTY_LISTEN_ON")
|
|
52
|
+
self._kitten_path = str(self.KITTEN_PATH.resolve())
|
|
53
|
+
|
|
54
|
+
def _call(self, *args: str, timeout: float = 15) -> KittenResult:
|
|
55
|
+
"""Call kitten and return result.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
*args: Arguments to pass to kitten
|
|
59
|
+
timeout: Subprocess timeout in seconds
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
KittenResult with status and data
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
KittenCallError: If call fails
|
|
66
|
+
WindowNotFoundError: If window not found
|
|
67
|
+
"""
|
|
68
|
+
cmd = ["kitty", "@"]
|
|
69
|
+
if self.socket:
|
|
70
|
+
cmd.extend(["--to", self.socket])
|
|
71
|
+
cmd.extend(["kitten", self._kitten_path, *args])
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
cmd,
|
|
76
|
+
check=False,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
text=True,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
)
|
|
81
|
+
except subprocess.TimeoutExpired as e:
|
|
82
|
+
raise KittenCallError(f"Kitten call timed out after {timeout}s") from e
|
|
83
|
+
except Exception as e:
|
|
84
|
+
raise KittenCallError(f"Failed to call kitten: {e}") from e
|
|
85
|
+
|
|
86
|
+
if result.returncode != 0:
|
|
87
|
+
stderr = result.stderr.strip()
|
|
88
|
+
if "i/o timeout" in stderr.lower():
|
|
89
|
+
raise KittenCallError("Kitty I/O timeout - kitten took too long")
|
|
90
|
+
raise KittenCallError(f"Kitten failed: {stderr or result.stdout}")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(result.stdout)
|
|
94
|
+
except json.JSONDecodeError as e:
|
|
95
|
+
raise KittenCallError(f"Invalid JSON from kitten: {result.stdout}") from e
|
|
96
|
+
|
|
97
|
+
if data.get("status") == "error":
|
|
98
|
+
msg = data.get("message", "Unknown error")
|
|
99
|
+
if "not found" in msg.lower():
|
|
100
|
+
raise WindowNotFoundError(msg)
|
|
101
|
+
raise KittenCallError(msg)
|
|
102
|
+
|
|
103
|
+
return KittenResult(status=data["status"], data=data)
|
|
104
|
+
|
|
105
|
+
def get_text(self, window_id: int, extent: str = "all") -> str:
|
|
106
|
+
"""Get text content from window.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
window_id: Target window ID
|
|
110
|
+
extent: "all" or "screen"
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Window text content
|
|
114
|
+
"""
|
|
115
|
+
result = self._call("get-text", "--window", str(window_id), "--extent", extent)
|
|
116
|
+
text: str = result.data.get("text", "")
|
|
117
|
+
return text
|
|
118
|
+
|
|
119
|
+
def get_text_hash(self, window_id: int, extent: str = "all") -> tuple[str, str]:
|
|
120
|
+
"""Get text content and hash from window.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
window_id: Target window ID
|
|
124
|
+
extent: "all" or "screen"
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Tuple of (text, hash)
|
|
128
|
+
"""
|
|
129
|
+
result = self._call("get-text", "--window", str(window_id), "--extent", extent)
|
|
130
|
+
text: str = result.data.get("text", "")
|
|
131
|
+
hash_val: str = result.data.get("hash", "")
|
|
132
|
+
return text, hash_val
|
|
133
|
+
|
|
134
|
+
def send_text(self, window_id: int, text: str) -> bool:
|
|
135
|
+
"""Send text to window.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
window_id: Target window ID
|
|
139
|
+
text: Text to send
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if successful
|
|
143
|
+
"""
|
|
144
|
+
result = self._call("send-text", "--window", str(window_id), text)
|
|
145
|
+
return result.status == "ok"
|
|
146
|
+
|
|
147
|
+
def send_key(self, window_id: int, key: str) -> bool:
|
|
148
|
+
"""Send key press to window.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
window_id: Target window ID
|
|
152
|
+
key: Key name (e.g., "enter", "escape")
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if successful
|
|
156
|
+
"""
|
|
157
|
+
result = self._call("send-key", "--window", str(window_id), key)
|
|
158
|
+
return result.status == "ok"
|
|
159
|
+
|
|
160
|
+
def check_idle(self, window_id: int, last_hash: str = "") -> tuple[bool, str]:
|
|
161
|
+
"""Check if window content has changed.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
window_id: Target window ID
|
|
165
|
+
last_hash: Previous content hash
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Tuple of (is_idle, current_hash)
|
|
169
|
+
"""
|
|
170
|
+
result = self._call(
|
|
171
|
+
"check-idle", "--window", str(window_id), "--last-hash", last_hash
|
|
172
|
+
)
|
|
173
|
+
return result.data.get("idle", False), result.data.get("hash", "")
|
|
174
|
+
|
|
175
|
+
def list_ai_windows(self) -> list[dict[str, Any]]:
|
|
176
|
+
"""List windows running AI CLIs.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
List of AI window info dicts with id, cli, cwd
|
|
180
|
+
"""
|
|
181
|
+
result = self._call("list-ai-windows")
|
|
182
|
+
ai_windows: list[dict[str, Any]] = result.data.get("ai_windows", [])
|
|
183
|
+
return ai_windows
|
|
184
|
+
|
|
185
|
+
def get_window_cli(self, window_id: int) -> str | None:
|
|
186
|
+
"""Get CLI type for a window.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
window_id: Target window ID
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
CLI name (e.g., "claude", "codex") or None if not an AI window
|
|
193
|
+
"""
|
|
194
|
+
ai_windows = self.list_ai_windows()
|
|
195
|
+
for w in ai_windows:
|
|
196
|
+
if w["id"] == window_id:
|
|
197
|
+
cli: str = w["cli"]
|
|
198
|
+
return cli
|
|
199
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Response parsers - low-level parsing utilities.
|
|
2
|
+
|
|
3
|
+
For AI CLI adapters, use ai_comm.adapters instead.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .base import ResponseCollector, ResponseParser
|
|
9
|
+
from .utils import (
|
|
10
|
+
clean_response_lines,
|
|
11
|
+
is_separator_line,
|
|
12
|
+
join_response,
|
|
13
|
+
remove_base_indent,
|
|
14
|
+
strip_leading_empty,
|
|
15
|
+
strip_trailing_empty,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ResponseCollector",
|
|
20
|
+
"ResponseParser",
|
|
21
|
+
"clean_response_lines",
|
|
22
|
+
"is_separator_line",
|
|
23
|
+
"join_response",
|
|
24
|
+
"remove_base_indent",
|
|
25
|
+
"strip_leading_empty",
|
|
26
|
+
"strip_trailing_empty",
|
|
27
|
+
]
|
ai_comm/parsers/base.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Base class for response parsers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from .utils import clean_response_lines, join_response, remove_base_indent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ResponseParser(ABC):
|
|
12
|
+
"""Abstract base class for AI CLI response parsers.
|
|
13
|
+
|
|
14
|
+
Subclasses should override:
|
|
15
|
+
- name: Parser identifier
|
|
16
|
+
- STATUS_INDICATORS: Strings that indicate status bar lines (filtered out)
|
|
17
|
+
- BASE_INDENT: Number of spaces for base indentation (stripped from content)
|
|
18
|
+
- extract_last_response(): Main parsing logic
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name: str = "base"
|
|
22
|
+
|
|
23
|
+
# Override in subclasses: strings that indicate status bar lines
|
|
24
|
+
STATUS_INDICATORS: ClassVar[list[str]] = []
|
|
25
|
+
|
|
26
|
+
# Override in subclasses: base indentation to strip (0 = no stripping)
|
|
27
|
+
BASE_INDENT: ClassVar[int] = 0
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def extract_last_response(self, text: str) -> str:
|
|
31
|
+
"""Extract the last response from the terminal output.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
text: Full terminal text content (screen + scrollback)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The extracted response text
|
|
38
|
+
"""
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
def is_status_line(self, line: str) -> bool:
|
|
42
|
+
"""Check if line is a status bar line (should be skipped)."""
|
|
43
|
+
if not self.STATUS_INDICATORS:
|
|
44
|
+
return False
|
|
45
|
+
stripped = line.strip()
|
|
46
|
+
return any(indicator in stripped for indicator in self.STATUS_INDICATORS)
|
|
47
|
+
|
|
48
|
+
def strip_indent(self, line: str) -> str:
|
|
49
|
+
"""Remove base indentation from line."""
|
|
50
|
+
return remove_base_indent(line, self.BASE_INDENT)
|
|
51
|
+
|
|
52
|
+
def finalize_response(self, lines: list[str]) -> str:
|
|
53
|
+
"""Clean up and join response lines."""
|
|
54
|
+
cleaned = clean_response_lines(lines)
|
|
55
|
+
return join_response(cleaned)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ResponseCollector:
|
|
59
|
+
"""Helper for collecting multi-block responses.
|
|
60
|
+
|
|
61
|
+
Common pattern used by most parsers:
|
|
62
|
+
- Track multiple response blocks
|
|
63
|
+
- Handle in_response state
|
|
64
|
+
- Save current block when new one starts or control element found
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self.responses: list[list[str]] = []
|
|
69
|
+
self.current: list[str] = []
|
|
70
|
+
self.in_response: bool = False
|
|
71
|
+
|
|
72
|
+
def start_new(self, first_line: str | None = None) -> None:
|
|
73
|
+
"""Start a new response block, saving current if exists."""
|
|
74
|
+
if self.in_response and self.current:
|
|
75
|
+
self.responses.append(self.current)
|
|
76
|
+
self.current = []
|
|
77
|
+
self.in_response = True
|
|
78
|
+
if first_line is not None:
|
|
79
|
+
self.current.append(first_line)
|
|
80
|
+
|
|
81
|
+
def end_current(self) -> None:
|
|
82
|
+
"""End current response block."""
|
|
83
|
+
if self.in_response and self.current:
|
|
84
|
+
self.responses.append(self.current)
|
|
85
|
+
self.current = []
|
|
86
|
+
self.in_response = False
|
|
87
|
+
|
|
88
|
+
def add_line(self, line: str) -> None:
|
|
89
|
+
"""Add line to current response if collecting."""
|
|
90
|
+
if self.in_response:
|
|
91
|
+
self.current.append(line)
|
|
92
|
+
|
|
93
|
+
def add_empty(self) -> None:
|
|
94
|
+
"""Add empty line to current response if collecting."""
|
|
95
|
+
if self.in_response:
|
|
96
|
+
self.current.append("")
|
|
97
|
+
|
|
98
|
+
def finalize(self) -> list[str]:
|
|
99
|
+
"""Get the last response block."""
|
|
100
|
+
# Don't forget current block
|
|
101
|
+
if self.in_response and self.current:
|
|
102
|
+
self.responses.append(self.current)
|
|
103
|
+
self.current = []
|
|
104
|
+
self.in_response = False
|
|
105
|
+
|
|
106
|
+
if self.responses:
|
|
107
|
+
return self.responses[-1]
|
|
108
|
+
return []
|
ai_comm/parsers/utils.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Common utilities for response parsers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_separator_line(line: str) -> bool:
|
|
7
|
+
"""Check if line is a separator (all ─ characters)."""
|
|
8
|
+
stripped = line.strip()
|
|
9
|
+
return bool(stripped) and all(c == "─" for c in stripped)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def strip_trailing_empty(lines: list[str]) -> list[str]:
|
|
13
|
+
"""Remove trailing empty lines from a list."""
|
|
14
|
+
result = lines.copy()
|
|
15
|
+
while result and not result[-1].strip():
|
|
16
|
+
result.pop()
|
|
17
|
+
return result
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def strip_leading_empty(lines: list[str]) -> list[str]:
|
|
21
|
+
"""Remove leading empty lines from a list."""
|
|
22
|
+
result = lines.copy()
|
|
23
|
+
while result and not result[0].strip():
|
|
24
|
+
result.pop(0)
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def clean_response_lines(lines: list[str]) -> list[str]:
|
|
29
|
+
"""Strip leading and trailing empty lines."""
|
|
30
|
+
return strip_leading_empty(strip_trailing_empty(lines))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def remove_base_indent(line: str, indent: int) -> str:
|
|
34
|
+
"""Remove base indentation while preserving relative indent."""
|
|
35
|
+
if indent > 0 and line.startswith(" " * indent):
|
|
36
|
+
return line[indent:]
|
|
37
|
+
return line
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def join_response(lines: list[str]) -> str:
|
|
41
|
+
"""Join response lines and strip result."""
|
|
42
|
+
return "\n".join(lines).strip()
|
ai_comm/polling.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Polling logic for waiting on window content changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PollingTimeoutError(Exception):
|
|
10
|
+
"""Raised when polling times out."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, timeout: float, elapsed: float) -> None:
|
|
13
|
+
super().__init__(f"Timeout after {elapsed:.1f}s (limit: {timeout}s)")
|
|
14
|
+
self.timeout = timeout
|
|
15
|
+
self.elapsed = elapsed
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def wait_for_idle(
|
|
19
|
+
check_fn: Callable[[str], tuple[bool, str]],
|
|
20
|
+
idle_seconds: int = 3,
|
|
21
|
+
timeout: float = 1800,
|
|
22
|
+
poll_interval: float = 1.0,
|
|
23
|
+
) -> float:
|
|
24
|
+
"""Wait for content to stabilize.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
check_fn: Function that takes last_hash and returns (is_same, current_hash)
|
|
28
|
+
idle_seconds: Number of consecutive stable checks required
|
|
29
|
+
timeout: Maximum wait time in seconds
|
|
30
|
+
poll_interval: Time between checks in seconds
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Total elapsed time in seconds
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
PollingTimeoutError: If timeout is reached
|
|
37
|
+
"""
|
|
38
|
+
start_time = time.time()
|
|
39
|
+
last_hash = ""
|
|
40
|
+
stable_count = 0
|
|
41
|
+
|
|
42
|
+
while True:
|
|
43
|
+
elapsed = time.time() - start_time
|
|
44
|
+
if elapsed >= timeout:
|
|
45
|
+
raise PollingTimeoutError(timeout, elapsed)
|
|
46
|
+
|
|
47
|
+
is_same, current_hash = check_fn(last_hash)
|
|
48
|
+
|
|
49
|
+
if is_same and last_hash:
|
|
50
|
+
stable_count += 1
|
|
51
|
+
if stable_count >= idle_seconds:
|
|
52
|
+
return elapsed
|
|
53
|
+
else:
|
|
54
|
+
stable_count = 0
|
|
55
|
+
last_hash = current_hash
|
|
56
|
+
|
|
57
|
+
time.sleep(poll_interval)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def poll_until(
|
|
61
|
+
condition_fn: Callable[[], bool],
|
|
62
|
+
timeout: float = 1800,
|
|
63
|
+
poll_interval: float = 1.0,
|
|
64
|
+
) -> float:
|
|
65
|
+
"""Poll until condition is true.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
condition_fn: Function that returns True when done
|
|
69
|
+
timeout: Maximum wait time in seconds
|
|
70
|
+
poll_interval: Time between checks in seconds
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Total elapsed time in seconds
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
PollingTimeoutError: If timeout is reached
|
|
77
|
+
"""
|
|
78
|
+
start_time = time.time()
|
|
79
|
+
|
|
80
|
+
while True:
|
|
81
|
+
elapsed = time.time() - start_time
|
|
82
|
+
if elapsed >= timeout:
|
|
83
|
+
raise PollingTimeoutError(timeout, elapsed)
|
|
84
|
+
|
|
85
|
+
if condition_fn():
|
|
86
|
+
return elapsed
|
|
87
|
+
|
|
88
|
+
time.sleep(poll_interval)
|
ai_comm/registry.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Unified CLI registry - single source of truth for CLI metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class CLIInfo:
|
|
10
|
+
"""Metadata for a supported AI CLI."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
display_name: str
|
|
14
|
+
aliases: tuple[str, ...]
|
|
15
|
+
adapter_module: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CLI_REGISTRY: dict[str, CLIInfo] = {
|
|
19
|
+
"claude": CLIInfo(
|
|
20
|
+
name="claude",
|
|
21
|
+
display_name="Claude Code",
|
|
22
|
+
aliases=("claude",),
|
|
23
|
+
adapter_module="ai_comm.adapters.claude",
|
|
24
|
+
),
|
|
25
|
+
"codex": CLIInfo(
|
|
26
|
+
name="codex",
|
|
27
|
+
display_name="Codex CLI",
|
|
28
|
+
aliases=("codex",),
|
|
29
|
+
adapter_module="ai_comm.adapters.codex",
|
|
30
|
+
),
|
|
31
|
+
"gemini": CLIInfo(
|
|
32
|
+
name="gemini",
|
|
33
|
+
display_name="Gemini CLI",
|
|
34
|
+
aliases=("gemini",),
|
|
35
|
+
adapter_module="ai_comm.adapters.gemini",
|
|
36
|
+
),
|
|
37
|
+
"aider": CLIInfo(
|
|
38
|
+
name="aider",
|
|
39
|
+
display_name="Aider",
|
|
40
|
+
aliases=("aider",),
|
|
41
|
+
adapter_module="ai_comm.adapters.aider",
|
|
42
|
+
),
|
|
43
|
+
"cursor": CLIInfo(
|
|
44
|
+
name="cursor",
|
|
45
|
+
display_name="Cursor",
|
|
46
|
+
aliases=("cursor", "cursor-cli", "cursor-agent"),
|
|
47
|
+
adapter_module="ai_comm.adapters.cursor",
|
|
48
|
+
),
|
|
49
|
+
"opencode": CLIInfo(
|
|
50
|
+
name="opencode",
|
|
51
|
+
display_name="OpenCode",
|
|
52
|
+
aliases=("opencode",),
|
|
53
|
+
adapter_module="ai_comm.adapters.opencode",
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_display_name(cli_name: str) -> str:
|
|
59
|
+
"""Get human-readable display name for CLI."""
|
|
60
|
+
info = CLI_REGISTRY.get(cli_name)
|
|
61
|
+
return info.display_name if info else cli_name.capitalize()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_canonical_name(process_name: str) -> str | None:
|
|
65
|
+
"""Map process name/alias to canonical CLI name."""
|
|
66
|
+
process_lower = process_name.lower()
|
|
67
|
+
for name, info in CLI_REGISTRY.items():
|
|
68
|
+
if process_lower in info.aliases:
|
|
69
|
+
return name
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_cli_names() -> list[str]:
|
|
74
|
+
"""List all registered CLI names."""
|
|
75
|
+
return list(CLI_REGISTRY.keys())
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Interaction service - unified send and response logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ai_comm.adapters import get_adapter
|
|
10
|
+
from ai_comm.polling import wait_for_idle
|
|
11
|
+
from ai_comm.registry import get_display_name
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ai_comm.kitten_client import KittenClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InteractionService:
|
|
18
|
+
"""Unified service for AI CLI interactions.
|
|
19
|
+
|
|
20
|
+
Handles message formatting, sending, waiting, and response fetching.
|
|
21
|
+
Accepts KittenClient via dependency injection.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, client: KittenClient) -> None:
|
|
25
|
+
"""Initialize with injected client."""
|
|
26
|
+
self.client = client
|
|
27
|
+
|
|
28
|
+
def get_sender_name(self) -> str | None:
|
|
29
|
+
"""Get the sender CLI name from current window."""
|
|
30
|
+
window_id_str = os.environ.get("KITTY_WINDOW_ID")
|
|
31
|
+
if not window_id_str:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
window_id = int(window_id_str)
|
|
35
|
+
cli_type = self.client.get_window_cli(window_id)
|
|
36
|
+
if cli_type:
|
|
37
|
+
return get_display_name(cli_type)
|
|
38
|
+
except (ValueError, Exception):
|
|
39
|
+
pass
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def send_message(
|
|
43
|
+
self,
|
|
44
|
+
window_id: int,
|
|
45
|
+
message: str,
|
|
46
|
+
add_sender_header: bool = True,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Format and send message to target window."""
|
|
49
|
+
cli_type = self.client.get_window_cli(window_id)
|
|
50
|
+
adapter = get_adapter(cli_type or "generic")
|
|
51
|
+
|
|
52
|
+
sender = self.get_sender_name() if add_sender_header else None
|
|
53
|
+
formatted = adapter.format_message(message, sender)
|
|
54
|
+
|
|
55
|
+
self.client.send_text(window_id, formatted)
|
|
56
|
+
time.sleep(0.1)
|
|
57
|
+
self.client.send_key(window_id, "enter")
|
|
58
|
+
|
|
59
|
+
def wait_for_response(
|
|
60
|
+
self,
|
|
61
|
+
window_id: int,
|
|
62
|
+
idle_seconds: int = 3,
|
|
63
|
+
timeout: float = 1800,
|
|
64
|
+
) -> float:
|
|
65
|
+
"""Wait for window content to stabilize."""
|
|
66
|
+
time.sleep(0.5)
|
|
67
|
+
|
|
68
|
+
def check_fn(last_hash: str) -> tuple[bool, str]:
|
|
69
|
+
return self.client.check_idle(window_id, last_hash)
|
|
70
|
+
|
|
71
|
+
return wait_for_idle(check_fn, idle_seconds=idle_seconds, timeout=timeout)
|
|
72
|
+
|
|
73
|
+
def get_response(
|
|
74
|
+
self,
|
|
75
|
+
window_id: int,
|
|
76
|
+
parser: str = "auto",
|
|
77
|
+
extent: str = "all",
|
|
78
|
+
raw: bool = False,
|
|
79
|
+
) -> tuple[str, str]:
|
|
80
|
+
"""Get parsed response from window.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Tuple of (response_text, effective_parser_name)
|
|
84
|
+
"""
|
|
85
|
+
effective_parser = parser
|
|
86
|
+
if parser == "auto":
|
|
87
|
+
cli_type = self.client.get_window_cli(window_id)
|
|
88
|
+
effective_parser = cli_type or "generic"
|
|
89
|
+
|
|
90
|
+
if raw:
|
|
91
|
+
return self.client.get_text(window_id, extent), effective_parser
|
|
92
|
+
|
|
93
|
+
adapter = get_adapter(effective_parser)
|
|
94
|
+
response = adapter.fetch_response(self.client, window_id, extent)
|
|
95
|
+
|
|
96
|
+
return response, effective_parser
|
|
97
|
+
|
|
98
|
+
def send_and_wait(
|
|
99
|
+
self,
|
|
100
|
+
window_id: int,
|
|
101
|
+
message: str,
|
|
102
|
+
idle_seconds: int = 3,
|
|
103
|
+
timeout: float = 1800,
|
|
104
|
+
parser: str = "auto",
|
|
105
|
+
raw: bool = False,
|
|
106
|
+
) -> tuple[str, float, str]:
|
|
107
|
+
"""Send message and wait for response.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Tuple of (response_text, elapsed_time, effective_parser_name)
|
|
111
|
+
"""
|
|
112
|
+
self.send_message(window_id, message)
|
|
113
|
+
elapsed = self.wait_for_response(window_id, idle_seconds, timeout)
|
|
114
|
+
response, effective_parser = self.get_response(window_id, parser, raw=raw)
|
|
115
|
+
return response, elapsed, effective_parser
|