tunacode-cli 0.0.76.1__py3-none-any.whl → 0.0.76.3__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/system.py +1 -1
- tunacode/cli/commands/registry.py +13 -2
- tunacode/cli/main.py +1 -1
- tunacode/cli/repl.py +11 -7
- tunacode/cli/repl_components/error_recovery.py +2 -2
- tunacode/cli/repl_components/tool_executor.py +1 -1
- tunacode/constants.py +4 -1
- tunacode/core/agents/__init__.py +39 -2
- tunacode/core/agents/agent_components/__init__.py +5 -0
- tunacode/core/agents/agent_components/task_completion.py +15 -6
- tunacode/core/agents/main.py +529 -347
- tunacode/core/agents/utils.py +1 -129
- tunacode/core/state.py +15 -0
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/react.py +153 -0
- tunacode/ui/completers.py +75 -27
- tunacode/utils/fuzzy_utils.py +33 -0
- tunacode/utils/models_registry.py +59 -29
- {tunacode_cli-0.0.76.1.dist-info → tunacode_cli-0.0.76.3.dist-info}/METADATA +14 -5
- {tunacode_cli-0.0.76.1.dist-info → tunacode_cli-0.0.76.3.dist-info}/RECORD +24 -21
- {tunacode_cli-0.0.76.1.dist-info → tunacode_cli-0.0.76.3.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.76.1.dist-info → tunacode_cli-0.0.76.3.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.76.1.dist-info → tunacode_cli-0.0.76.3.dist-info}/licenses/LICENSE +0 -0
tunacode/core/agents/utils.py
CHANGED
|
@@ -3,9 +3,8 @@ import importlib
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
|
-
import re
|
|
7
6
|
from collections.abc import Iterator
|
|
8
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime
|
|
9
8
|
from typing import Any
|
|
10
9
|
|
|
11
10
|
from tunacode.constants import (
|
|
@@ -16,11 +15,8 @@ from tunacode.constants import (
|
|
|
16
15
|
)
|
|
17
16
|
from tunacode.exceptions import ToolBatchingJSONError
|
|
18
17
|
from tunacode.types import (
|
|
19
|
-
ErrorMessage,
|
|
20
18
|
StateManager,
|
|
21
19
|
ToolCallback,
|
|
22
|
-
ToolCallId,
|
|
23
|
-
ToolName,
|
|
24
20
|
)
|
|
25
21
|
from tunacode.ui import console as ui
|
|
26
22
|
from tunacode.utils.retry import retry_json_parse_async
|
|
@@ -267,127 +263,3 @@ async def parse_json_tool_calls(
|
|
|
267
263
|
except Exception as e:
|
|
268
264
|
if state_manager.session.show_thoughts:
|
|
269
265
|
await ui.error(f"Error executing fallback tool {tool_name}: {e!s}")
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
async def extract_and_execute_tool_calls(
|
|
273
|
-
text: str, tool_callback: ToolCallback | None, state_manager: StateManager
|
|
274
|
-
):
|
|
275
|
-
"""Extract tool calls from text content and execute them.
|
|
276
|
-
Supports multiple formats for maximum compatibility.
|
|
277
|
-
"""
|
|
278
|
-
if not tool_callback:
|
|
279
|
-
return
|
|
280
|
-
|
|
281
|
-
# Format 2: Tool calls in code blocks
|
|
282
|
-
code_block_pattern = r'```json\s*(\{(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*"tool"(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*\})\s*```'
|
|
283
|
-
code_matches = re.findall(code_block_pattern, text, re.MULTILINE | re.DOTALL)
|
|
284
|
-
remaining_text = re.sub(code_block_pattern, "", text)
|
|
285
|
-
|
|
286
|
-
for match in code_matches:
|
|
287
|
-
try:
|
|
288
|
-
# Use retry logic for JSON parsing in code blocks
|
|
289
|
-
tool_data = await retry_json_parse_async(
|
|
290
|
-
match,
|
|
291
|
-
max_retries=JSON_PARSE_MAX_RETRIES,
|
|
292
|
-
base_delay=JSON_PARSE_BASE_DELAY,
|
|
293
|
-
max_delay=JSON_PARSE_MAX_DELAY,
|
|
294
|
-
)
|
|
295
|
-
if "tool" in tool_data and "args" in tool_data:
|
|
296
|
-
|
|
297
|
-
class MockToolCall:
|
|
298
|
-
def __init__(self, tool_name: str, args: dict):
|
|
299
|
-
self.tool_name = tool_name
|
|
300
|
-
self.args = args
|
|
301
|
-
self.tool_call_id = f"codeblock_{datetime.now().timestamp()}"
|
|
302
|
-
|
|
303
|
-
class MockNode:
|
|
304
|
-
pass
|
|
305
|
-
|
|
306
|
-
mock_call = MockToolCall(tool_data["tool"], tool_data["args"])
|
|
307
|
-
mock_node = MockNode()
|
|
308
|
-
|
|
309
|
-
await tool_callback(mock_call, mock_node)
|
|
310
|
-
|
|
311
|
-
if state_manager.session.show_thoughts:
|
|
312
|
-
await ui.muted(f"FALLBACK: Executed {tool_data['tool']} from code block")
|
|
313
|
-
|
|
314
|
-
except json.JSONDecodeError as e:
|
|
315
|
-
# After all retries failed
|
|
316
|
-
logger.error(
|
|
317
|
-
f"Code block JSON parsing failed after {JSON_PARSE_MAX_RETRIES} retries: {e}"
|
|
318
|
-
)
|
|
319
|
-
if state_manager.session.show_thoughts:
|
|
320
|
-
await ui.error(
|
|
321
|
-
f"Failed to parse code block tool JSON after {JSON_PARSE_MAX_RETRIES} retries"
|
|
322
|
-
)
|
|
323
|
-
# Raise custom exception for better error handling
|
|
324
|
-
raise ToolBatchingJSONError(
|
|
325
|
-
json_content=match,
|
|
326
|
-
retry_count=JSON_PARSE_MAX_RETRIES,
|
|
327
|
-
original_error=e,
|
|
328
|
-
) from e
|
|
329
|
-
except (KeyError, Exception) as e:
|
|
330
|
-
if state_manager.session.show_thoughts:
|
|
331
|
-
await ui.error(f"Error parsing code block tool call: {e!s}")
|
|
332
|
-
|
|
333
|
-
# Format 1: {"tool": "name", "args": {...}}
|
|
334
|
-
await parse_json_tool_calls(remaining_text, tool_callback, state_manager)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def patch_tool_messages(
|
|
338
|
-
error_message: ErrorMessage = "Tool operation failed",
|
|
339
|
-
state_manager: StateManager = None,
|
|
340
|
-
):
|
|
341
|
-
"""Find any tool calls without responses and add synthetic error responses for them.
|
|
342
|
-
Takes an error message to use in the synthesized tool response.
|
|
343
|
-
|
|
344
|
-
Ignores tools that have corresponding retry prompts as the model is already
|
|
345
|
-
addressing them.
|
|
346
|
-
"""
|
|
347
|
-
if state_manager is None:
|
|
348
|
-
raise ValueError("state_manager is required for patch_tool_messages")
|
|
349
|
-
|
|
350
|
-
messages = state_manager.session.messages
|
|
351
|
-
|
|
352
|
-
if not messages:
|
|
353
|
-
return
|
|
354
|
-
|
|
355
|
-
# Map tool calls to their tool returns
|
|
356
|
-
tool_calls: dict[ToolCallId, ToolName] = {} # tool_call_id -> tool_name
|
|
357
|
-
tool_returns: set[ToolCallId] = set() # set of tool_call_ids with returns
|
|
358
|
-
retry_prompts: set[ToolCallId] = set() # set of tool_call_ids with retry prompts
|
|
359
|
-
|
|
360
|
-
for message in messages:
|
|
361
|
-
if hasattr(message, "parts"):
|
|
362
|
-
for part in message.parts:
|
|
363
|
-
if (
|
|
364
|
-
hasattr(part, "part_kind")
|
|
365
|
-
and hasattr(part, "tool_call_id")
|
|
366
|
-
and part.tool_call_id
|
|
367
|
-
):
|
|
368
|
-
if part.part_kind == "tool-call":
|
|
369
|
-
tool_calls[part.tool_call_id] = part.tool_name
|
|
370
|
-
elif part.part_kind == "tool-return":
|
|
371
|
-
tool_returns.add(part.tool_call_id)
|
|
372
|
-
elif part.part_kind == "retry-prompt":
|
|
373
|
-
retry_prompts.add(part.tool_call_id)
|
|
374
|
-
|
|
375
|
-
# Identify orphaned tools (those without responses and not being retried)
|
|
376
|
-
for tool_call_id, tool_name in list(tool_calls.items()):
|
|
377
|
-
if tool_call_id not in tool_returns and tool_call_id not in retry_prompts:
|
|
378
|
-
# Import ModelRequest and ToolReturnPart lazily
|
|
379
|
-
model_request_cls, tool_return_part_cls, _ = get_model_messages()
|
|
380
|
-
messages.append(
|
|
381
|
-
model_request_cls(
|
|
382
|
-
parts=[
|
|
383
|
-
tool_return_part_cls(
|
|
384
|
-
tool_name=tool_name,
|
|
385
|
-
content=error_message,
|
|
386
|
-
tool_call_id=tool_call_id,
|
|
387
|
-
timestamp=datetime.now(timezone.utc),
|
|
388
|
-
part_kind="tool-return",
|
|
389
|
-
)
|
|
390
|
-
],
|
|
391
|
-
kind="request",
|
|
392
|
-
)
|
|
393
|
-
)
|
tunacode/core/state.py
CHANGED
|
@@ -52,6 +52,10 @@ class SessionState:
|
|
|
52
52
|
input_sessions: InputSessions = field(default_factory=dict)
|
|
53
53
|
current_task: Optional[Any] = None
|
|
54
54
|
todos: list[TodoItem] = field(default_factory=list)
|
|
55
|
+
# CLAUDE_ANCHOR[react-scratchpad]: Session scratchpad for ReAct tooling
|
|
56
|
+
react_scratchpad: dict[str, Any] = field(default_factory=lambda: {"timeline": []})
|
|
57
|
+
react_forced_calls: int = 0
|
|
58
|
+
react_guidance: list[str] = field(default_factory=list)
|
|
55
59
|
# Operation state tracking
|
|
56
60
|
operation_cancelled: bool = False
|
|
57
61
|
# Enhanced tracking for thoughts display
|
|
@@ -174,6 +178,17 @@ class StateManager:
|
|
|
174
178
|
def clear_todos(self) -> None:
|
|
175
179
|
self._session.todos = []
|
|
176
180
|
|
|
181
|
+
# React scratchpad helpers
|
|
182
|
+
def get_react_scratchpad(self) -> dict[str, Any]:
|
|
183
|
+
return self._session.react_scratchpad
|
|
184
|
+
|
|
185
|
+
def append_react_entry(self, entry: dict[str, Any]) -> None:
|
|
186
|
+
timeline = self._session.react_scratchpad.setdefault("timeline", [])
|
|
187
|
+
timeline.append(entry)
|
|
188
|
+
|
|
189
|
+
def clear_react_scratchpad(self) -> None:
|
|
190
|
+
self._session.react_scratchpad = {"timeline": []}
|
|
191
|
+
|
|
177
192
|
def reset_session(self) -> None:
|
|
178
193
|
"""Reset the session to a fresh state."""
|
|
179
194
|
self._session = SessionState()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<tool>
|
|
2
|
+
<description>
|
|
3
|
+
Record a ReAct-style think/observe timeline, retrieve it, or clear it for the current session.
|
|
4
|
+
</description>
|
|
5
|
+
<parameters>
|
|
6
|
+
<parameter name="action" required="true">
|
|
7
|
+
<type>string</type>
|
|
8
|
+
<description>One of think, observe, get, clear.</description>
|
|
9
|
+
</parameter>
|
|
10
|
+
<parameter name="thoughts" required="false">
|
|
11
|
+
<type>string</type>
|
|
12
|
+
<description>Reasoning text for think entries.</description>
|
|
13
|
+
</parameter>
|
|
14
|
+
<parameter name="next_action" required="false">
|
|
15
|
+
<type>string</type>
|
|
16
|
+
<description>Planned action to pair with think entries.</description>
|
|
17
|
+
</parameter>
|
|
18
|
+
<parameter name="result" required="false">
|
|
19
|
+
<type>string</type>
|
|
20
|
+
<description>Observation details for observe entries.</description>
|
|
21
|
+
</parameter>
|
|
22
|
+
</parameters>
|
|
23
|
+
</tool>
|
tunacode/tools/react.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Lightweight ReAct-style scratchpad tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Literal
|
|
7
|
+
|
|
8
|
+
import defusedxml.ElementTree as ET
|
|
9
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
10
|
+
|
|
11
|
+
from tunacode.core.state import StateManager
|
|
12
|
+
from tunacode.types import ToolResult, UILogger
|
|
13
|
+
|
|
14
|
+
from .base import BaseTool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# CLAUDE_ANCHOR[react-tool]: Minimal ReAct scratchpad tool surface
|
|
18
|
+
class ReactTool(BaseTool):
|
|
19
|
+
"""Minimal ReAct scratchpad for tracking think/observe steps."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, state_manager: StateManager, ui_logger: UILogger | None = None):
|
|
22
|
+
super().__init__(ui_logger)
|
|
23
|
+
self.state_manager = state_manager
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def tool_name(self) -> str:
|
|
27
|
+
return "react"
|
|
28
|
+
|
|
29
|
+
async def _execute(
|
|
30
|
+
self,
|
|
31
|
+
action: Literal["think", "observe", "get", "clear"],
|
|
32
|
+
thoughts: str | None = None,
|
|
33
|
+
next_action: str | None = None,
|
|
34
|
+
result: str | None = None,
|
|
35
|
+
) -> ToolResult:
|
|
36
|
+
scratchpad = self._ensure_scratchpad()
|
|
37
|
+
|
|
38
|
+
if action == "think":
|
|
39
|
+
if not thoughts:
|
|
40
|
+
raise ModelRetry("Provide thoughts when using react think action")
|
|
41
|
+
if not next_action:
|
|
42
|
+
raise ModelRetry("Specify next_action when recording react thoughts")
|
|
43
|
+
|
|
44
|
+
entry = {
|
|
45
|
+
"type": "think",
|
|
46
|
+
"thoughts": thoughts,
|
|
47
|
+
"next_action": next_action,
|
|
48
|
+
}
|
|
49
|
+
self.state_manager.append_react_entry(entry)
|
|
50
|
+
return "Recorded think step"
|
|
51
|
+
|
|
52
|
+
if action == "observe":
|
|
53
|
+
if not result:
|
|
54
|
+
raise ModelRetry("Provide result when using react observe action")
|
|
55
|
+
|
|
56
|
+
entry = {
|
|
57
|
+
"type": "observe",
|
|
58
|
+
"result": result,
|
|
59
|
+
}
|
|
60
|
+
self.state_manager.append_react_entry(entry)
|
|
61
|
+
return "Recorded observation"
|
|
62
|
+
|
|
63
|
+
if action == "get":
|
|
64
|
+
timeline = scratchpad.get("timeline", [])
|
|
65
|
+
if not timeline:
|
|
66
|
+
return "React scratchpad is empty"
|
|
67
|
+
|
|
68
|
+
formatted = [
|
|
69
|
+
f"{index + 1}. {item['type']}: {self._format_entry(item)}"
|
|
70
|
+
for index, item in enumerate(timeline)
|
|
71
|
+
]
|
|
72
|
+
return "\n".join(formatted)
|
|
73
|
+
|
|
74
|
+
if action == "clear":
|
|
75
|
+
self.state_manager.clear_react_scratchpad()
|
|
76
|
+
return "React scratchpad cleared"
|
|
77
|
+
|
|
78
|
+
raise ModelRetry("Invalid react action. Use one of: think, observe, get, clear")
|
|
79
|
+
|
|
80
|
+
def _format_entry(self, item: Dict[str, Any]) -> str:
|
|
81
|
+
if item["type"] == "think":
|
|
82
|
+
return f"thoughts='{item['thoughts']}', next_action='{item['next_action']}'"
|
|
83
|
+
if item["type"] == "observe":
|
|
84
|
+
return f"result='{item['result']}'"
|
|
85
|
+
return str(item)
|
|
86
|
+
|
|
87
|
+
def _ensure_scratchpad(self) -> dict[str, Any]:
|
|
88
|
+
scratchpad = self.state_manager.get_react_scratchpad()
|
|
89
|
+
scratchpad.setdefault("timeline", [])
|
|
90
|
+
return scratchpad
|
|
91
|
+
|
|
92
|
+
def _get_base_prompt(self) -> str:
|
|
93
|
+
prompt_file = Path(__file__).parent / "prompts" / "react_prompt.xml"
|
|
94
|
+
if prompt_file.exists():
|
|
95
|
+
try:
|
|
96
|
+
tree = ET.parse(prompt_file)
|
|
97
|
+
root = tree.getroot()
|
|
98
|
+
description = root.find("description")
|
|
99
|
+
if description is not None and description.text:
|
|
100
|
+
return description.text.strip()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return "Use this tool to record think/observe notes and manage the react scratchpad"
|
|
104
|
+
|
|
105
|
+
def _get_parameters_schema(self) -> Dict[str, Any]:
|
|
106
|
+
prompt_file = Path(__file__).parent / "prompts" / "react_prompt.xml"
|
|
107
|
+
if prompt_file.exists():
|
|
108
|
+
try:
|
|
109
|
+
tree = ET.parse(prompt_file)
|
|
110
|
+
root = tree.getroot()
|
|
111
|
+
parameters = root.find("parameters")
|
|
112
|
+
if parameters is not None:
|
|
113
|
+
schema: Dict[str, Any] = {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {},
|
|
116
|
+
"required": ["action"],
|
|
117
|
+
}
|
|
118
|
+
for param in parameters.findall("parameter"):
|
|
119
|
+
name = param.get("name")
|
|
120
|
+
param_type = param.find("type")
|
|
121
|
+
description = param.find("description")
|
|
122
|
+
if name and param_type is not None:
|
|
123
|
+
schema["properties"][name] = {
|
|
124
|
+
"type": param_type.text.strip(),
|
|
125
|
+
"description": description.text.strip()
|
|
126
|
+
if description is not None and description.text
|
|
127
|
+
else "",
|
|
128
|
+
}
|
|
129
|
+
return schema
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
return {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {
|
|
135
|
+
"action": {
|
|
136
|
+
"type": "string",
|
|
137
|
+
"description": "react operation to perform",
|
|
138
|
+
},
|
|
139
|
+
"thoughts": {
|
|
140
|
+
"type": "string",
|
|
141
|
+
"description": "Thought content for think action",
|
|
142
|
+
},
|
|
143
|
+
"next_action": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"description": "Planned next action for think action",
|
|
146
|
+
},
|
|
147
|
+
"result": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"description": "Observation message for observe action",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
"required": ["action"],
|
|
153
|
+
}
|
tunacode/ui/completers.py
CHANGED
|
@@ -11,6 +11,8 @@ from prompt_toolkit.completion import (
|
|
|
11
11
|
)
|
|
12
12
|
from prompt_toolkit.document import Document
|
|
13
13
|
|
|
14
|
+
from ..utils.fuzzy_utils import find_fuzzy_matches
|
|
15
|
+
|
|
14
16
|
if TYPE_CHECKING:
|
|
15
17
|
from ..cli.commands import CommandRegistry
|
|
16
18
|
from ..utils.models_registry import ModelInfo, ModelsRegistry
|
|
@@ -73,7 +75,12 @@ class FileReferenceCompleter(Completer):
|
|
|
73
75
|
def get_completions(
|
|
74
76
|
self, document: Document, _complete_event: CompleteEvent
|
|
75
77
|
) -> Iterable[Completion]:
|
|
76
|
-
"""Get completions for @file references.
|
|
78
|
+
"""Get completions for @file references.
|
|
79
|
+
|
|
80
|
+
Favors file matches before directory matches and supports fuzzy
|
|
81
|
+
matching for near-miss filenames. Order:
|
|
82
|
+
exact files > fuzzy files > exact dirs > fuzzy dirs
|
|
83
|
+
"""
|
|
77
84
|
# Get the word before cursor
|
|
78
85
|
word_before_cursor = document.get_word_before_cursor(WORD=True)
|
|
79
86
|
|
|
@@ -94,34 +101,75 @@ class FileReferenceCompleter(Completer):
|
|
|
94
101
|
dir_path = "."
|
|
95
102
|
prefix = path_part
|
|
96
103
|
|
|
97
|
-
#
|
|
104
|
+
# If prefix itself is an existing directory (without trailing slash),
|
|
105
|
+
# treat it as browsing inside that directory
|
|
106
|
+
candidate_dir = os.path.join(dir_path, prefix) if dir_path != "." else prefix
|
|
107
|
+
if prefix and os.path.isdir(candidate_dir) and not path_part.endswith("/"):
|
|
108
|
+
dir_path = candidate_dir
|
|
109
|
+
prefix = ""
|
|
110
|
+
|
|
111
|
+
# Get matching files with fuzzy support
|
|
98
112
|
try:
|
|
99
113
|
if os.path.exists(dir_path) and os.path.isdir(dir_path):
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
114
|
+
items = sorted(os.listdir(dir_path))
|
|
115
|
+
|
|
116
|
+
# Separate files vs dirs; skip hidden unless explicitly requested
|
|
117
|
+
show_hidden = prefix.startswith(".")
|
|
118
|
+
files: List[str] = []
|
|
119
|
+
dirs: List[str] = []
|
|
120
|
+
for item in items:
|
|
121
|
+
if item.startswith(".") and not show_hidden:
|
|
122
|
+
continue
|
|
123
|
+
full_item_path = os.path.join(dir_path, item) if dir_path != "." else item
|
|
124
|
+
if os.path.isdir(full_item_path):
|
|
125
|
+
dirs.append(item)
|
|
126
|
+
else:
|
|
127
|
+
files.append(item)
|
|
128
|
+
|
|
129
|
+
# Exact prefix matches (case-insensitive)
|
|
130
|
+
prefix_lower = prefix.lower()
|
|
131
|
+
exact_files = [f for f in files if f.lower().startswith(prefix_lower)]
|
|
132
|
+
exact_dirs = [d for d in dirs if d.lower().startswith(prefix_lower)]
|
|
133
|
+
|
|
134
|
+
# Fuzzy matches (exclude items already matched exactly)
|
|
135
|
+
fuzzy_file_candidates = [f for f in files if f not in exact_files]
|
|
136
|
+
fuzzy_dir_candidates = [d for d in dirs if d not in exact_dirs]
|
|
137
|
+
|
|
138
|
+
fuzzy_files = (
|
|
139
|
+
find_fuzzy_matches(prefix, fuzzy_file_candidates, n=10, cutoff=0.75)
|
|
140
|
+
if prefix
|
|
141
|
+
else []
|
|
142
|
+
)
|
|
143
|
+
fuzzy_dirs = (
|
|
144
|
+
find_fuzzy_matches(prefix, fuzzy_dir_candidates, n=10, cutoff=0.75)
|
|
145
|
+
if prefix
|
|
146
|
+
else []
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Compose ordered results
|
|
150
|
+
ordered: List[tuple[str, str]] = (
|
|
151
|
+
[("file", name) for name in exact_files]
|
|
152
|
+
+ [("file", name) for name in fuzzy_files]
|
|
153
|
+
+ [("dir", name) for name in exact_dirs]
|
|
154
|
+
+ [("dir", name) for name in fuzzy_dirs]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
start_position = -len(path_part)
|
|
158
|
+
for kind, name in ordered:
|
|
159
|
+
full_path = os.path.join(dir_path, name) if dir_path != "." else name
|
|
160
|
+
if kind == "dir":
|
|
161
|
+
display = name + "/"
|
|
162
|
+
completion_text = full_path + "/"
|
|
163
|
+
else:
|
|
164
|
+
display = name
|
|
165
|
+
completion_text = full_path
|
|
166
|
+
|
|
167
|
+
yield Completion(
|
|
168
|
+
text=completion_text,
|
|
169
|
+
start_position=start_position,
|
|
170
|
+
display=display,
|
|
171
|
+
display_meta="dir" if kind == "dir" else "file",
|
|
172
|
+
)
|
|
125
173
|
except (OSError, PermissionError):
|
|
126
174
|
# Silently ignore inaccessible directories
|
|
127
175
|
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Shared fuzzy matching utilities.
|
|
2
|
+
|
|
3
|
+
Minimal helper to provide consistent fuzzy matching behavior across
|
|
4
|
+
components (commands, file completers, etc.) without introducing new
|
|
5
|
+
dependencies. Reuses difflib.get_close_matches with a default cutoff of 0.75
|
|
6
|
+
matching the command registry behavior.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from difflib import get_close_matches
|
|
10
|
+
from typing import List, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_fuzzy_matches(
|
|
14
|
+
query: str,
|
|
15
|
+
choices: Sequence[str],
|
|
16
|
+
*,
|
|
17
|
+
n: int = 5,
|
|
18
|
+
cutoff: float = 0.75,
|
|
19
|
+
) -> List[str]:
|
|
20
|
+
"""Return up to ``n`` fuzzy matches from ``choices`` for ``query``.
|
|
21
|
+
|
|
22
|
+
The return order is the order produced by ``difflib.get_close_matches``.
|
|
23
|
+
Matching is case-insensitive; original casing is preserved in the result.
|
|
24
|
+
"""
|
|
25
|
+
if not query or not choices:
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
# Map lower-case to original to do case-insensitive matching while
|
|
29
|
+
# returning original items.
|
|
30
|
+
lower_to_original = {c.lower(): c for c in choices}
|
|
31
|
+
candidates = list(lower_to_original.keys())
|
|
32
|
+
matches_lower = get_close_matches(query.lower(), candidates, n=n, cutoff=cutoff)
|
|
33
|
+
return [lower_to_original[m] for m in matches_lower]
|