Skip to content

Commit bf0b5c8

Browse files
committed
feat(main): replace readline with prompt_toolkit for file completions
1 parent c8d462d commit bf0b5c8

4 files changed

Lines changed: 315 additions & 64 deletions

File tree

iclaw/main.py

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,14 @@
11
#!/usr/bin/env python3
2+
import glob
23
import json
4+
import os
5+
import re
36
import sys
47
import time
58
from pathlib import Path
69

7-
try:
8-
import readline
9-
10-
COMMANDS = [
11-
"/model_provider",
12-
"/model",
13-
"/search_provider",
14-
"/copy",
15-
"/help",
16-
".exit",
17-
]
18-
19-
def completer(text, state):
20-
matches = [c for c in COMMANDS if c.startswith(text)]
21-
return matches[state] if state < len(matches) else None
22-
23-
readline.set_completer(completer)
24-
readline.parse_and_bind("tab: complete")
25-
except ImportError:
26-
pass
10+
from prompt_toolkit import PromptSession
11+
from prompt_toolkit.completion import Completer, Completion
2712

2813
from iclaw.github_api import chat, get_copilot_token
2914
from iclaw.web_search import web_search
@@ -33,6 +18,58 @@ def completer(text, state):
3318
from iclaw.commands.search_provider import handle_search_provider_command
3419
from iclaw.commands.utils import handle_copy_command
3520

21+
COMMANDS = [
22+
"/model_provider",
23+
"/model",
24+
"/search_provider",
25+
"/copy",
26+
"/help",
27+
".exit",
28+
]
29+
30+
31+
class IclawCompleter(Completer):
32+
"""Handles both / command completion and @ file mention completion."""
33+
34+
def get_completions(self, document, complete_event):
35+
text = document.text_before_cursor
36+
37+
# @ file mention: find the last @ not preceded by a non-space char
38+
at_pos = text.rfind("@")
39+
if at_pos != -1:
40+
prefix = text[at_pos + 1 :]
41+
if " " not in prefix:
42+
pattern = f"{prefix}*"
43+
matches = glob.glob(pattern) + glob.glob(
44+
os.path.join("**", pattern), recursive=True
45+
)
46+
seen = set()
47+
for path in sorted(matches)[:20]:
48+
if path in seen:
49+
continue
50+
seen.add(path)
51+
meta = "dir" if os.path.isdir(path) else "file"
52+
yield Completion(
53+
path,
54+
start_position=-len(prefix),
55+
display=path,
56+
display_meta=meta,
57+
)
58+
return
59+
60+
# / command completion at start of input
61+
stripped = text.lstrip()
62+
if stripped.startswith("/") or stripped == ".":
63+
word = stripped
64+
for cmd in COMMANDS:
65+
if cmd.startswith(word):
66+
yield Completion(
67+
cmd,
68+
start_position=-len(stripped),
69+
display=cmd,
70+
)
71+
72+
3673
COMMANDS_HELP = [
3774
("/model_provider", "Select and authenticate with the model provider"),
3875
("/model", "Select specific model from your provider"),
@@ -120,6 +157,24 @@ def load_github_token():
120157
return None
121158

122159

160+
def resolve_at_mentions(text):
161+
"""Extract @file references, return augmented text with file contents prepended."""
162+
mentions = re.findall(r"@(\S+)", text)
163+
if not mentions:
164+
return text
165+
parts = []
166+
for path in mentions:
167+
if os.path.isfile(path):
168+
try:
169+
contents = Path(path).read_text()
170+
parts.append(f'<file path="{path}">\n{contents}\n</file>')
171+
except OSError:
172+
pass
173+
if parts:
174+
return "\n".join(parts) + "\n\n" + text
175+
return text
176+
177+
123178
def main():
124179
github_token = load_github_token()
125180
copilot_token = None
@@ -145,9 +200,11 @@ def main():
145200
print(f" {cmd:<20} {desc}")
146201
print()
147202

203+
session = PromptSession(completer=IclawCompleter(), complete_while_typing=True)
204+
148205
while True:
149206
try:
150-
user_input = input("> ").strip()
207+
user_input = session.prompt("> ").strip()
151208
except (EOFError, KeyboardInterrupt):
152209
print("\nGoodbye!")
153210
break
@@ -190,7 +247,9 @@ def main():
190247
copilot_token = get_copilot_token(github_token)
191248
token_expiry = time.monotonic() + TOKEN_REFRESH_INTERVAL
192249

193-
messages.append({"role": "user", "content": user_input})
250+
messages.append(
251+
{"role": "user", "content": resolve_at_mentions(user_input)}
252+
)
194253
response_message = chat(messages, copilot_token, current_model, tools=TOOLS)
195254

196255
while response_message.get("tool_calls"):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ version = "0.1.0"
88
description = "Interactive CLI REPL for chatting with GitHub Copilot"
99
readme = "README.md"
1010
requires-python = ">=3.8"
11-
dependencies = ["requests>=2.32.0", "pyperclip>=1.8.0", "beautifulsoup4>=4.12.0", "readability-lxml>=0.8.1", "lxml>=5.0.0", "tavily-python>=0.3.0"]
11+
dependencies = ["requests>=2.32.0", "pyperclip>=1.8.0", "beautifulsoup4>=4.12.0", "readability-lxml>=0.8.1", "lxml>=5.0.0", "tavily-python>=0.3.0", "prompt-toolkit>=3.0.0"]
1212

1313
[project.scripts]
1414
iclaw = "iclaw.main:main"

tests/test_at_mention.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Integration tests for @ file mention and IclawCompleter."""
2+
3+
import os
4+
import tempfile
5+
import unittest
6+
from pathlib import Path
7+
from unittest.mock import patch
8+
9+
from prompt_toolkit.completion import CompleteEvent
10+
from prompt_toolkit.document import Document
11+
12+
from iclaw.main import IclawCompleter, resolve_at_mentions
13+
14+
15+
class TestResolveAtMentions(unittest.TestCase):
16+
def setUp(self):
17+
self.tmpdir = tempfile.mkdtemp()
18+
self.file1 = os.path.join(self.tmpdir, "hello.txt")
19+
Path(self.file1).write_text("hello world")
20+
self.file2 = os.path.join(self.tmpdir, "other.py")
21+
Path(self.file2).write_text("print('hi')")
22+
23+
def test_no_mentions_returns_original(self):
24+
text = "just a plain message"
25+
self.assertEqual(resolve_at_mentions(text), text)
26+
27+
def test_mention_nonexistent_file_returns_original(self):
28+
text = "look at @nonexistent_file.txt"
29+
self.assertEqual(resolve_at_mentions(text), text)
30+
31+
def test_mention_existing_file_prepends_contents(self):
32+
text = f"explain @{self.file1}"
33+
result = resolve_at_mentions(text)
34+
self.assertIn("hello world", result)
35+
self.assertIn(f'<file path="{self.file1}">', result)
36+
self.assertIn(text, result)
37+
38+
def test_mention_multiple_files(self):
39+
text = f"compare @{self.file1} and @{self.file2}"
40+
result = resolve_at_mentions(text)
41+
self.assertIn("hello world", result)
42+
self.assertIn("print('hi')", result)
43+
self.assertIn(text, result)
44+
45+
def test_file_contents_come_before_message(self):
46+
text = f"explain @{self.file1}"
47+
result = resolve_at_mentions(text)
48+
file_tag_pos = result.index("<file")
49+
msg_pos = result.index(text)
50+
self.assertLess(file_tag_pos, msg_pos)
51+
52+
def test_mention_directory_ignored(self):
53+
text = f"look at @{self.tmpdir}"
54+
# directories are not files, so no injection
55+
result = resolve_at_mentions(text)
56+
self.assertEqual(result, text)
57+
58+
def test_unreadable_file_skipped(self):
59+
text = f"explain @{self.file1}"
60+
with patch("iclaw.main.Path.read_text", side_effect=OSError("perm denied")):
61+
result = resolve_at_mentions(text)
62+
self.assertEqual(result, text)
63+
64+
65+
class TestIclawCompleter(unittest.TestCase):
66+
def setUp(self):
67+
self.completer = IclawCompleter()
68+
self.tmpdir = tempfile.mkdtemp()
69+
self.orig_cwd = os.getcwd()
70+
os.chdir(self.tmpdir)
71+
# Create test files in the temp dir
72+
Path("alpha.py").write_text("")
73+
Path("alpha_test.py").write_text("")
74+
Path("beta.txt").write_text("")
75+
os.makedirs("subdir", exist_ok=True)
76+
Path("subdir/gamma.py").write_text("")
77+
78+
def tearDown(self):
79+
os.chdir(self.orig_cwd)
80+
81+
def _completions(self, text):
82+
doc = Document(text)
83+
return list(self.completer.get_completions(doc, CompleteEvent()))
84+
85+
# --- @ file mention ---
86+
87+
def test_at_with_no_prefix_returns_files(self):
88+
completions = self._completions("@")
89+
paths = [c.text for c in completions]
90+
self.assertIn("alpha.py", paths)
91+
self.assertIn("beta.txt", paths)
92+
93+
def test_at_with_partial_prefix_filters(self):
94+
completions = self._completions("@alph")
95+
paths = [c.text for c in completions]
96+
self.assertTrue(all("alpha" in p for p in paths))
97+
self.assertNotIn("beta.txt", paths)
98+
99+
def test_at_completion_replaces_prefix(self):
100+
completions = self._completions("@alph")
101+
for c in completions:
102+
# start_position should be negative len of "alph"
103+
self.assertEqual(c.start_position, -len("alph"))
104+
105+
def test_at_meta_shows_file_or_dir(self):
106+
completions = self._completions("@")
107+
meta_map = {c.text: c.display_meta for c in completions}
108+
# display_meta is a FormattedText; convert to string for assertion
109+
for path, meta in meta_map.items():
110+
expected = "dir" if os.path.isdir(path) else "file"
111+
self.assertIn(expected, str(meta))
112+
113+
def test_at_mid_sentence(self):
114+
completions = self._completions("review the file @alph")
115+
paths = [c.text for c in completions]
116+
self.assertTrue(any("alpha" in p for p in paths))
117+
118+
def test_at_with_space_after_at_returns_nothing(self):
119+
# "@foo bar" — space in prefix means we're past the mention word
120+
completions = self._completions("@alpha.py bar")
121+
self.assertEqual(completions, [])
122+
123+
def test_at_limits_to_20_results(self):
124+
# Create 25 files
125+
for i in range(25):
126+
Path(f"zfile{i:02d}.py").write_text("")
127+
completions = self._completions("@zfile")
128+
self.assertLessEqual(len(completions), 20)
129+
130+
# --- / command completion ---
131+
132+
def test_slash_alone_returns_all_commands(self):
133+
completions = self._completions("/")
134+
texts = [c.text for c in completions]
135+
# "/" prefix only matches slash-prefixed commands, not ".exit"
136+
for cmd in ["/model_provider", "/model", "/search_provider", "/copy", "/help"]:
137+
self.assertIn(cmd, texts)
138+
self.assertNotIn(".exit", texts)
139+
140+
def test_slash_partial_filters_commands(self):
141+
completions = self._completions("/mod")
142+
texts = [c.text for c in completions]
143+
self.assertIn("/model", texts)
144+
self.assertIn("/model_provider", texts)
145+
self.assertNotIn("/copy", texts)
146+
self.assertNotIn("/help", texts)
147+
148+
def test_dot_exit_completion(self):
149+
completions = self._completions(".")
150+
texts = [c.text for c in completions]
151+
self.assertIn(".exit", texts)
152+
153+
def test_no_trigger_returns_nothing(self):
154+
completions = self._completions("hello world")
155+
self.assertEqual(completions, [])
156+
157+
def test_at_takes_priority_over_slash_in_same_input(self):
158+
# Input has both / at start and @ later — @ should win
159+
completions = self._completions("/help @alph")
160+
paths = [c.text for c in completions]
161+
self.assertTrue(any("alpha" in p for p in paths))
162+
163+
164+
if __name__ == "__main__":
165+
unittest.main()

0 commit comments

Comments
 (0)