cortex-llm 1.0.9__py3-none-any.whl → 1.0.11__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.
- cortex/__init__.py +1 -1
- cortex/config.py +46 -10
- cortex/inference_engine.py +69 -32
- cortex/tools/__init__.py +5 -0
- cortex/tools/errors.py +9 -0
- cortex/tools/fs_ops.py +182 -0
- cortex/tools/protocol.py +76 -0
- cortex/tools/search.py +135 -0
- cortex/tools/tool_runner.py +204 -0
- cortex/ui/box_rendering.py +97 -0
- cortex/ui/cli.py +65 -1071
- cortex/ui/cli_commands.py +61 -0
- cortex/ui/cli_prompt.py +96 -0
- cortex/ui/help_ui.py +66 -0
- cortex/ui/input_box.py +205 -0
- cortex/ui/model_ui.py +408 -0
- cortex/ui/status_ui.py +78 -0
- cortex/ui/tool_activity.py +82 -0
- {cortex_llm-1.0.9.dist-info → cortex_llm-1.0.11.dist-info}/METADATA +3 -1
- {cortex_llm-1.0.9.dist-info → cortex_llm-1.0.11.dist-info}/RECORD +24 -10
- {cortex_llm-1.0.9.dist-info → cortex_llm-1.0.11.dist-info}/WHEEL +0 -0
- {cortex_llm-1.0.9.dist-info → cortex_llm-1.0.11.dist-info}/entry_points.txt +0 -0
- {cortex_llm-1.0.9.dist-info → cortex_llm-1.0.11.dist-info}/licenses/LICENSE +0 -0
- {cortex_llm-1.0.9.dist-info → cortex_llm-1.0.11.dist-info}/top_level.txt +0 -0
cortex/tools/search.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Search utilities for repo tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from cortex.tools.errors import ToolError, ValidationError
|
|
13
|
+
from cortex.tools.fs_ops import RepoFS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RepoSearch:
|
|
17
|
+
"""Search helper constrained to a repo root."""
|
|
18
|
+
|
|
19
|
+
_DEFAULT_SKIP_DIRS = {
|
|
20
|
+
".git",
|
|
21
|
+
".cortex",
|
|
22
|
+
".eggs",
|
|
23
|
+
".mypy_cache",
|
|
24
|
+
".pytest_cache",
|
|
25
|
+
".ruff_cache",
|
|
26
|
+
".tox",
|
|
27
|
+
".venv",
|
|
28
|
+
"__pycache__",
|
|
29
|
+
"build",
|
|
30
|
+
"dist",
|
|
31
|
+
"node_modules",
|
|
32
|
+
"venv",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def __init__(self, repo_fs: RepoFS) -> None:
|
|
36
|
+
self.repo_fs = repo_fs
|
|
37
|
+
|
|
38
|
+
def search(self, query: str, path: str = ".", use_regex: bool = True, max_results: int = 100) -> Dict[str, List[Dict[str, object]]]:
|
|
39
|
+
if not isinstance(query, str) or not query:
|
|
40
|
+
raise ValidationError("query must be a non-empty string")
|
|
41
|
+
if isinstance(max_results, bool) or not isinstance(max_results, int):
|
|
42
|
+
raise ValidationError("max_results must be an int")
|
|
43
|
+
if max_results < 1:
|
|
44
|
+
raise ValidationError("max_results must be >= 1")
|
|
45
|
+
if not isinstance(use_regex, bool):
|
|
46
|
+
raise ValidationError("use_regex must be a bool")
|
|
47
|
+
target = self.repo_fs.resolve_path(path)
|
|
48
|
+
if not target.exists():
|
|
49
|
+
raise ValidationError("path does not exist")
|
|
50
|
+
|
|
51
|
+
if shutil.which("rg"):
|
|
52
|
+
return {"results": self._rg_search(query, target, use_regex, max_results)}
|
|
53
|
+
return {"results": self._python_search(query, target, use_regex, max_results)}
|
|
54
|
+
|
|
55
|
+
def _rg_search(self, query: str, target: Path, use_regex: bool, max_results: int) -> List[Dict[str, object]]:
|
|
56
|
+
args = ["rg", "--line-number", "--with-filename", "--no-heading"]
|
|
57
|
+
if not use_regex:
|
|
58
|
+
args.append("-F")
|
|
59
|
+
args.extend(["-e", query, str(target)])
|
|
60
|
+
result = subprocess.run(args, cwd=self.repo_fs.root, capture_output=True, text=True)
|
|
61
|
+
if result.returncode not in (0, 1):
|
|
62
|
+
raise ToolError(f"rg failed: {result.stderr.strip()}")
|
|
63
|
+
matches: List[Dict[str, object]] = []
|
|
64
|
+
for line in result.stdout.splitlines():
|
|
65
|
+
try:
|
|
66
|
+
file_path, line_no, text = line.split(":", 2)
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
matches.append({"path": file_path, "line": int(line_no), "text": text})
|
|
70
|
+
if len(matches) >= max_results:
|
|
71
|
+
break
|
|
72
|
+
return matches
|
|
73
|
+
|
|
74
|
+
def _python_search(self, query: str, target: Path, use_regex: bool, max_results: int) -> List[Dict[str, object]]:
|
|
75
|
+
pattern: Optional[re.Pattern[str]] = None
|
|
76
|
+
if use_regex:
|
|
77
|
+
try:
|
|
78
|
+
pattern = re.compile(query)
|
|
79
|
+
except re.error as e:
|
|
80
|
+
raise ValidationError(f"invalid regex: {e}") from e
|
|
81
|
+
results: List[Dict[str, object]] = []
|
|
82
|
+
|
|
83
|
+
if target.is_file():
|
|
84
|
+
if self._looks_binary(target):
|
|
85
|
+
return results
|
|
86
|
+
self._scan_file(target, pattern, query, results, max_results)
|
|
87
|
+
return results
|
|
88
|
+
|
|
89
|
+
skip_dirs = set(self._DEFAULT_SKIP_DIRS)
|
|
90
|
+
if target.name in skip_dirs:
|
|
91
|
+
skip_dirs.remove(target.name)
|
|
92
|
+
|
|
93
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
94
|
+
dirnames[:] = [d for d in dirnames if d not in skip_dirs]
|
|
95
|
+
for name in filenames:
|
|
96
|
+
path = Path(dirpath) / name
|
|
97
|
+
if self._looks_binary(path):
|
|
98
|
+
continue
|
|
99
|
+
if self._scan_file(path, pattern, query, results, max_results):
|
|
100
|
+
return results
|
|
101
|
+
return results
|
|
102
|
+
|
|
103
|
+
def _scan_file(
|
|
104
|
+
self,
|
|
105
|
+
path: Path,
|
|
106
|
+
pattern: Optional[re.Pattern[str]],
|
|
107
|
+
query: str,
|
|
108
|
+
results: List[Dict[str, object]],
|
|
109
|
+
max_results: int,
|
|
110
|
+
) -> bool:
|
|
111
|
+
try:
|
|
112
|
+
with path.open("r", encoding="utf-8", errors="ignore") as handle:
|
|
113
|
+
for idx, line in enumerate(handle, start=1):
|
|
114
|
+
found = bool(pattern.search(line)) if pattern else (query in line)
|
|
115
|
+
if found:
|
|
116
|
+
results.append(
|
|
117
|
+
{
|
|
118
|
+
"path": str(path.relative_to(self.repo_fs.root)),
|
|
119
|
+
"line": idx,
|
|
120
|
+
"text": line.rstrip("\n"),
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
if len(results) >= max_results:
|
|
124
|
+
return True
|
|
125
|
+
except OSError:
|
|
126
|
+
return False
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def _looks_binary(self, path: Path, sniff_bytes: int = 4096) -> bool:
|
|
130
|
+
try:
|
|
131
|
+
with path.open("rb") as handle:
|
|
132
|
+
chunk = handle.read(sniff_bytes)
|
|
133
|
+
except OSError:
|
|
134
|
+
return True
|
|
135
|
+
return b"\x00" in chunk
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Tool runner and specifications for Cortex."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from cortex.tools.errors import ToolError, ValidationError
|
|
13
|
+
from cortex.tools.fs_ops import RepoFS
|
|
14
|
+
from cortex.tools.search import RepoSearch
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ConfirmCallback = Callable[[str], bool]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolRunner:
|
|
21
|
+
"""Execute tool calls with safety checks."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, root: Path, confirm_callback: Optional[ConfirmCallback] = None) -> None:
|
|
24
|
+
self.fs = RepoFS(root)
|
|
25
|
+
self.search = RepoSearch(self.fs)
|
|
26
|
+
self.confirm_callback = confirm_callback
|
|
27
|
+
|
|
28
|
+
def set_confirm_callback(self, callback: ConfirmCallback) -> None:
|
|
29
|
+
self.confirm_callback = callback
|
|
30
|
+
|
|
31
|
+
def tool_spec(self) -> Dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"list_dir": {"args": {"path": "string", "recursive": "bool", "max_depth": "int", "max_entries": "int"}},
|
|
34
|
+
"read_file": {"args": {"path": "string", "start_line": "int", "end_line": "int", "max_bytes": "int"}},
|
|
35
|
+
"search": {"args": {"query": "string", "path": "string", "use_regex": "bool", "max_results": "int"}},
|
|
36
|
+
"write_file": {"args": {"path": "string", "content": "string", "expected_sha256": "string"}},
|
|
37
|
+
"create_file": {"args": {"path": "string", "content": "string", "overwrite": "bool"}},
|
|
38
|
+
"delete_file": {"args": {"path": "string"}},
|
|
39
|
+
"replace_in_file": {"args": {"path": "string", "old": "string", "new": "string", "expected_replacements": "int"}},
|
|
40
|
+
"insert_after": {"args": {"path": "string", "anchor": "string", "content": "string", "expected_matches": "int"}},
|
|
41
|
+
"insert_before": {"args": {"path": "string", "anchor": "string", "content": "string", "expected_matches": "int"}},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def tool_instructions(self) -> str:
|
|
45
|
+
spec = json.dumps(self.tool_spec(), ensure_ascii=True, indent=2)
|
|
46
|
+
repo_root = str(self.fs.root)
|
|
47
|
+
return (
|
|
48
|
+
"[CORTEX_TOOL_INSTRUCTIONS v3]\n"
|
|
49
|
+
"You have access to repo-scoped file tools.\n"
|
|
50
|
+
"Use tools ONLY when the user asks for repo file operations or when repo data is required to answer.\n"
|
|
51
|
+
"Never use tools for general conversation, creative writing, or questions unrelated to the repo.\n"
|
|
52
|
+
"If a tool is required, respond ONLY with a <tool_calls> JSON block.\n"
|
|
53
|
+
"Do not include any other text when calling tools.\n"
|
|
54
|
+
f"Repo root: {repo_root}\n"
|
|
55
|
+
"All paths must be relative to the repo root (use '.' for root). Do not use absolute paths or ~.\n"
|
|
56
|
+
"For create/write/replace/insert/delete, paths must be file paths (not '.' or directories).\n"
|
|
57
|
+
"If you are unsure about paths, call list_dir with path '.' first.\n"
|
|
58
|
+
"Format:\n"
|
|
59
|
+
"<tool_calls>{\"calls\":[{\"id\":\"call_1\",\"name\":\"tool_name\",\"arguments\":{...}}]}</tool_calls>\n"
|
|
60
|
+
"Available tools:\n"
|
|
61
|
+
f"{spec}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def run_calls(self, calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
65
|
+
results: List[Dict[str, Any]] = []
|
|
66
|
+
for call in calls:
|
|
67
|
+
call_id = call.get("id", "unknown")
|
|
68
|
+
name = call.get("name")
|
|
69
|
+
args = call.get("arguments") or {}
|
|
70
|
+
try:
|
|
71
|
+
if name == "list_dir":
|
|
72
|
+
result = self.fs.list_dir(**args)
|
|
73
|
+
elif name == "read_file":
|
|
74
|
+
result = self.fs.read_text(**args)
|
|
75
|
+
elif name == "search":
|
|
76
|
+
result = self.search.search(**args)
|
|
77
|
+
elif name == "write_file":
|
|
78
|
+
result = self._write_file(**args)
|
|
79
|
+
elif name == "create_file":
|
|
80
|
+
result = self._create_file(**args)
|
|
81
|
+
elif name == "delete_file":
|
|
82
|
+
result = self._delete_file(**args)
|
|
83
|
+
elif name == "replace_in_file":
|
|
84
|
+
result = self._replace_in_file(**args)
|
|
85
|
+
elif name == "insert_after":
|
|
86
|
+
result = self._insert_relative(after=True, **args)
|
|
87
|
+
elif name == "insert_before":
|
|
88
|
+
result = self._insert_relative(after=False, **args)
|
|
89
|
+
else:
|
|
90
|
+
raise ValidationError(f"unknown tool: {name}")
|
|
91
|
+
results.append({"id": call_id, "name": name, "ok": True, "result": result, "error": None})
|
|
92
|
+
except Exception as e:
|
|
93
|
+
results.append({"id": call_id, "name": name, "ok": False, "result": None, "error": str(e)})
|
|
94
|
+
return results
|
|
95
|
+
|
|
96
|
+
def _write_file(self, path: str, content: str, expected_sha256: Optional[str] = None) -> Dict[str, Any]:
|
|
97
|
+
self._validate_str("content", content)
|
|
98
|
+
expected_sha256 = self._validate_sha256(expected_sha256)
|
|
99
|
+
before = self.fs.read_full_text(path)
|
|
100
|
+
self._confirm_change(path, before, content, "write")
|
|
101
|
+
return self.fs.write_text(path, content, expected_sha256=expected_sha256)
|
|
102
|
+
|
|
103
|
+
def _create_file(self, path: str, content: str, overwrite: bool = False) -> Dict[str, Any]:
|
|
104
|
+
target = self._validate_create_path(path)
|
|
105
|
+
self._validate_str("content", content)
|
|
106
|
+
self._validate_bool("overwrite", overwrite)
|
|
107
|
+
if target.exists() and not overwrite:
|
|
108
|
+
raise ValidationError("path already exists")
|
|
109
|
+
before = self.fs.read_full_text(path) if target.exists() else ""
|
|
110
|
+
self._confirm_change(path, before, content, "create")
|
|
111
|
+
return self.fs.create_text(path, content, overwrite=overwrite)
|
|
112
|
+
|
|
113
|
+
def _delete_file(self, path: str) -> Dict[str, Any]:
|
|
114
|
+
target = self.fs.resolve_path(path)
|
|
115
|
+
if not target.exists() or not target.is_file():
|
|
116
|
+
raise ValidationError("path does not exist or is not a file")
|
|
117
|
+
if not self.fs.is_git_tracked(target):
|
|
118
|
+
raise ToolError("delete blocked: file is not tracked by git")
|
|
119
|
+
before = self.fs.read_full_text(path)
|
|
120
|
+
self._confirm_change(path, before, "", "delete")
|
|
121
|
+
return self.fs.delete_file(path)
|
|
122
|
+
|
|
123
|
+
def _replace_in_file(self, path: str, old: str, new: str, expected_replacements: int = 1) -> Dict[str, Any]:
|
|
124
|
+
self._validate_non_empty_str("old", old)
|
|
125
|
+
self._validate_str("new", new)
|
|
126
|
+
expected_replacements = self._validate_int("expected_replacements", expected_replacements, minimum=1)
|
|
127
|
+
content = self.fs.read_full_text(path)
|
|
128
|
+
count = content.count(old)
|
|
129
|
+
if count != expected_replacements:
|
|
130
|
+
raise ToolError(f"expected {expected_replacements} replacements, found {count}")
|
|
131
|
+
updated = content.replace(old, new)
|
|
132
|
+
self._confirm_change(path, content, updated, "replace")
|
|
133
|
+
return self.fs.write_text(path, updated)
|
|
134
|
+
|
|
135
|
+
def _insert_relative(self, path: str, anchor: str, content: str, expected_matches: int = 1, after: bool = True) -> Dict[str, Any]:
|
|
136
|
+
self._validate_non_empty_str("anchor", anchor)
|
|
137
|
+
self._validate_str("content", content)
|
|
138
|
+
expected_matches = self._validate_int("expected_matches", expected_matches, minimum=1)
|
|
139
|
+
original = self.fs.read_full_text(path)
|
|
140
|
+
count = original.count(anchor)
|
|
141
|
+
if count != expected_matches:
|
|
142
|
+
raise ToolError(f"expected {expected_matches} matches, found {count}")
|
|
143
|
+
insert_text = anchor + content if after else content + anchor
|
|
144
|
+
updated = original.replace(anchor, insert_text, count if expected_matches > 1 else 1)
|
|
145
|
+
self._confirm_change(path, original, updated, "insert")
|
|
146
|
+
return self.fs.write_text(path, updated)
|
|
147
|
+
|
|
148
|
+
def _confirm_change(self, path: str, before: str, after: str, action: str) -> None:
|
|
149
|
+
if self.confirm_callback is None:
|
|
150
|
+
raise ToolError("confirmation required but no callback configured")
|
|
151
|
+
if before == after:
|
|
152
|
+
raise ToolError("no changes to apply")
|
|
153
|
+
diff = "\n".join(
|
|
154
|
+
difflib.unified_diff(
|
|
155
|
+
before.splitlines(),
|
|
156
|
+
after.splitlines(),
|
|
157
|
+
fromfile=f"{path} (before)",
|
|
158
|
+
tofile=f"{path} (after)",
|
|
159
|
+
lineterm="",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
prompt = f"Apply {action} to {path}?\n{diff}\n"
|
|
163
|
+
if not self.confirm_callback(prompt):
|
|
164
|
+
raise ToolError("change declined by user")
|
|
165
|
+
|
|
166
|
+
def _validate_str(self, name: str, value: object) -> None:
|
|
167
|
+
if not isinstance(value, str):
|
|
168
|
+
raise ValidationError(f"{name} must be a string")
|
|
169
|
+
|
|
170
|
+
def _validate_non_empty_str(self, name: str, value: object) -> None:
|
|
171
|
+
if not isinstance(value, str) or not value:
|
|
172
|
+
raise ValidationError(f"{name} must be a non-empty string")
|
|
173
|
+
|
|
174
|
+
def _validate_bool(self, name: str, value: object) -> None:
|
|
175
|
+
if not isinstance(value, bool):
|
|
176
|
+
raise ValidationError(f"{name} must be a bool")
|
|
177
|
+
|
|
178
|
+
def _validate_int(self, name: str, value: object, minimum: int = 0) -> int:
|
|
179
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
180
|
+
raise ValidationError(f"{name} must be an int")
|
|
181
|
+
if value < minimum:
|
|
182
|
+
raise ValidationError(f"{name} must be >= {minimum}")
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
def _validate_sha256(self, value: Optional[str]) -> Optional[str]:
|
|
186
|
+
if value is None:
|
|
187
|
+
return None
|
|
188
|
+
if not isinstance(value, str):
|
|
189
|
+
raise ValidationError("expected_sha256 must be a string")
|
|
190
|
+
normalized = value.lower()
|
|
191
|
+
if not re.fullmatch(r"[0-9a-f]{64}", normalized):
|
|
192
|
+
raise ValidationError("expected_sha256 must be a 64-character hex string")
|
|
193
|
+
return normalized
|
|
194
|
+
|
|
195
|
+
def _validate_create_path(self, path: str) -> Path:
|
|
196
|
+
self._validate_non_empty_str("path", path)
|
|
197
|
+
if path in {".", ""}:
|
|
198
|
+
raise ValidationError("path must be a file path, not a directory")
|
|
199
|
+
if path.endswith(("/", os.sep)):
|
|
200
|
+
raise ValidationError("path must be a file path, not a directory")
|
|
201
|
+
target = self.fs.resolve_path(path)
|
|
202
|
+
if target.exists() and target.is_dir():
|
|
203
|
+
raise ValidationError("path already exists and is a directory")
|
|
204
|
+
return target
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Box rendering utilities for CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_visible_length(text: str) -> int:
|
|
11
|
+
"""Get visible length of text, ignoring ANSI escape codes and accounting for wide characters."""
|
|
12
|
+
ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
|
|
13
|
+
visible_text = ansi_escape.sub("", text)
|
|
14
|
+
|
|
15
|
+
display_width = 0
|
|
16
|
+
for char in visible_text:
|
|
17
|
+
width = unicodedata.east_asian_width(char)
|
|
18
|
+
if width in ("W", "F"):
|
|
19
|
+
display_width += 2
|
|
20
|
+
elif width == "A" and char in "●○":
|
|
21
|
+
display_width += 1
|
|
22
|
+
else:
|
|
23
|
+
display_width += 1
|
|
24
|
+
|
|
25
|
+
return display_width
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def print_box_line(content: str, width: int, align: str = "left") -> None:
|
|
29
|
+
"""Print a single line in a box with proper padding."""
|
|
30
|
+
visible_len = get_visible_length(content)
|
|
31
|
+
padding = width - visible_len - 2
|
|
32
|
+
|
|
33
|
+
if align == "center":
|
|
34
|
+
left_pad = padding // 2
|
|
35
|
+
right_pad = padding - left_pad
|
|
36
|
+
print(f"│{' ' * left_pad}{content}{' ' * right_pad}│")
|
|
37
|
+
else:
|
|
38
|
+
print(f"│{content}{' ' * padding}│")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_box_header(title: str, width: int) -> None:
|
|
42
|
+
"""Print a box header with title."""
|
|
43
|
+
if title:
|
|
44
|
+
title_with_color = f" \033[96m{title}\033[0m "
|
|
45
|
+
visible_len = get_visible_length(title_with_color)
|
|
46
|
+
padding = width - visible_len - 3
|
|
47
|
+
print(f"╭─{title_with_color}" + "─" * padding + "╮")
|
|
48
|
+
else:
|
|
49
|
+
print("╭" + "─" * (width - 2) + "╮")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_box_footer(width: int) -> None:
|
|
53
|
+
"""Print a box footer."""
|
|
54
|
+
print("╰" + "─" * (width - 2) + "╯")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def print_box_separator(width: int) -> None:
|
|
58
|
+
"""Print a separator line inside a box."""
|
|
59
|
+
print("├" + "─" * (width - 2) + "┤")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_empty_line(width: int) -> None:
|
|
63
|
+
"""Print an empty line inside a box."""
|
|
64
|
+
print("│" + " " * (width - 2) + "│")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_box(
|
|
68
|
+
lines: List[str],
|
|
69
|
+
*,
|
|
70
|
+
width: Optional[int],
|
|
71
|
+
terminal_width: int,
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Create a box with Unicode borders."""
|
|
74
|
+
if width is None:
|
|
75
|
+
width = min(terminal_width - 2, 80)
|
|
76
|
+
|
|
77
|
+
top_left = "╭"
|
|
78
|
+
top_right = "╮"
|
|
79
|
+
bottom_left = "╰"
|
|
80
|
+
bottom_right = "╯"
|
|
81
|
+
horizontal = "─"
|
|
82
|
+
vertical = "│"
|
|
83
|
+
|
|
84
|
+
inner_width = width - 4
|
|
85
|
+
|
|
86
|
+
result = []
|
|
87
|
+
result.append(top_left + horizontal * (width - 2) + top_right)
|
|
88
|
+
|
|
89
|
+
for line in lines:
|
|
90
|
+
visible_len = get_visible_length(line)
|
|
91
|
+
padding_needed = inner_width - visible_len
|
|
92
|
+
padded = f" {line}{' ' * padding_needed} "
|
|
93
|
+
result.append(vertical + padded + vertical)
|
|
94
|
+
|
|
95
|
+
result.append(bottom_left + horizontal * (width - 2) + bottom_right)
|
|
96
|
+
|
|
97
|
+
return "\n".join(result)
|