virtuai-cli 0.6.2__tar.gz → 0.7.0__tar.gz
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.
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/PKG-INFO +2 -2
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/pyproject.toml +2 -2
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/tui.py +67 -29
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/widgets.py +61 -1
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/executor.py +35 -6
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/PKG-INFO +2 -2
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/requires.txt +1 -1
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/README.md +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/setup.cfg +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/runner.py +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtuai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -19,7 +19,7 @@ Requires-Dist: certifi>=2024.0
|
|
|
19
19
|
Requires-Dist: keyring>=25.0
|
|
20
20
|
Requires-Dist: typer>=0.12
|
|
21
21
|
Requires-Dist: rich>=13.0
|
|
22
|
-
Requires-Dist: textual>=0.
|
|
22
|
+
Requires-Dist: textual>=0.86
|
|
23
23
|
|
|
24
24
|
# VirtuAI CLI
|
|
25
25
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "virtuai-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Run VirtuAI deep agents on your local machine"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -27,7 +27,7 @@ dependencies = [
|
|
|
27
27
|
"keyring>=25.0",
|
|
28
28
|
"typer>=0.12",
|
|
29
29
|
"rich>=13.0",
|
|
30
|
-
"textual>=0.
|
|
30
|
+
"textual>=0.86",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
33
|
[project.urls]
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.7.0"
|
|
@@ -11,12 +11,12 @@ from textual.app import App, ComposeResult
|
|
|
11
11
|
from textual.binding import Binding
|
|
12
12
|
from textual.containers import Vertical, VerticalScroll
|
|
13
13
|
from textual.reactive import reactive
|
|
14
|
-
from textual.widgets import Footer, Header,
|
|
14
|
+
from textual.widgets import Footer, Header, Static, TextArea
|
|
15
15
|
|
|
16
16
|
from virtuai_cli import runner as ws_runner
|
|
17
17
|
from virtuai_cli.chat.history import list_conversations, load_conversation
|
|
18
18
|
from virtuai_cli.chat.sse import stream_chat
|
|
19
|
-
from virtuai_cli.chat.widgets import AssistantTurn, UserBubble
|
|
19
|
+
from virtuai_cli.chat.widgets import AssistantTurn, ChatInput, TextSegment, UserBubble
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
# Slash command catalog — single source of truth used by /help, the
|
|
@@ -25,6 +25,7 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
|
25
25
|
("/help", "show this list"),
|
|
26
26
|
("/clear", "start a fresh conversation"),
|
|
27
27
|
("/new", "alias for /clear"),
|
|
28
|
+
("/copy", "copy the last assistant response to the clipboard"),
|
|
28
29
|
("/history", "list this agent's recent conversations"),
|
|
29
30
|
("/load", "load a past conversation: /load <session_id>"),
|
|
30
31
|
("/models", "list models available for this agent"),
|
|
@@ -63,12 +64,6 @@ class ChatApp(App):
|
|
|
63
64
|
|
|
64
65
|
#slash-hints.-visible { display: block; }
|
|
65
66
|
|
|
66
|
-
#input {
|
|
67
|
-
height: 3;
|
|
68
|
-
border: round $primary;
|
|
69
|
-
margin: 0 1 1 1;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
67
|
#placeholder {
|
|
73
68
|
margin: 1 2;
|
|
74
69
|
color: $text-muted;
|
|
@@ -79,6 +74,7 @@ class ChatApp(App):
|
|
|
79
74
|
Binding("escape", "cancel_stream", "Cancel", show=True),
|
|
80
75
|
Binding("ctrl+c", "quit", "Quit", show=True),
|
|
81
76
|
Binding("ctrl+l", "clear_conversation", "New chat", show=True),
|
|
77
|
+
Binding("ctrl+y", "copy_last", "Copy last", show=True, priority=True),
|
|
82
78
|
Binding("tab", "complete_slash", "Complete", show=False, priority=True),
|
|
83
79
|
]
|
|
84
80
|
|
|
@@ -119,11 +115,11 @@ class ChatApp(App):
|
|
|
119
115
|
yield Static(
|
|
120
116
|
f"[b]{self.agent_name}[/b] in {self.workspace_name}\n"
|
|
121
117
|
f"Workdir: {self.workdir}\n"
|
|
122
|
-
f"[dim]
|
|
118
|
+
f"[dim]Enter sends · Shift+Enter newline · Esc cancels · Ctrl+Y copies · /help for more.[/dim]",
|
|
123
119
|
id="placeholder",
|
|
124
120
|
)
|
|
125
121
|
yield Static("", id="slash-hints")
|
|
126
|
-
yield
|
|
122
|
+
yield ChatInput(id="input")
|
|
127
123
|
yield Footer()
|
|
128
124
|
|
|
129
125
|
def _initial_status(self) -> str:
|
|
@@ -134,7 +130,7 @@ class ChatApp(App):
|
|
|
134
130
|
async def on_mount(self) -> None:
|
|
135
131
|
self.title = "VirtuAI"
|
|
136
132
|
self.sub_title = f"{self.agent_name} · {self.workspace_name}"
|
|
137
|
-
self.query_one(
|
|
133
|
+
self.query_one(ChatInput).focus()
|
|
138
134
|
self._runner_task = asyncio.create_task(self._run_ws())
|
|
139
135
|
|
|
140
136
|
async def _run_ws(self) -> None:
|
|
@@ -190,25 +186,28 @@ class ChatApp(App):
|
|
|
190
186
|
hints.update("\n".join(rows))
|
|
191
187
|
hints.add_class("-visible")
|
|
192
188
|
|
|
193
|
-
@on(
|
|
194
|
-
def _on_input_changed(self, event:
|
|
195
|
-
|
|
189
|
+
@on(TextArea.Changed, "#input")
|
|
190
|
+
def _on_input_changed(self, event: TextArea.Changed) -> None:
|
|
191
|
+
# Slash hints only fire while the user is typing the FIRST line
|
|
192
|
+
# — long multi-line messages shouldn't trigger the panel.
|
|
193
|
+
value = event.text_area.text
|
|
194
|
+
first_line = value.split("\n", 1)[0]
|
|
195
|
+
self._refresh_slash_hints(first_line if "\n" not in value else "")
|
|
196
196
|
|
|
197
197
|
def action_complete_slash(self) -> None:
|
|
198
198
|
"""Tab — complete the current slash command from the hint list."""
|
|
199
199
|
try:
|
|
200
|
-
inp = self.query_one("#input",
|
|
200
|
+
inp = self.query_one("#input", ChatInput)
|
|
201
201
|
except Exception:
|
|
202
202
|
return
|
|
203
|
-
value = inp.
|
|
204
|
-
if not value.startswith("/") or " " in value:
|
|
205
|
-
return
|
|
203
|
+
value = inp.text
|
|
204
|
+
if "\n" in value or not value.startswith("/") or " " in value:
|
|
205
|
+
return
|
|
206
206
|
matches = self._matching_commands(value)
|
|
207
207
|
if not matches:
|
|
208
208
|
return
|
|
209
209
|
if len(matches) == 1:
|
|
210
|
-
inp.
|
|
211
|
-
inp.cursor_position = len(inp.value)
|
|
210
|
+
inp.text = matches[0][0] + " "
|
|
212
211
|
else:
|
|
213
212
|
# Multiple matches: extend to common prefix
|
|
214
213
|
names = [m[0] for m in matches]
|
|
@@ -219,20 +218,21 @@ class ChatApp(App):
|
|
|
219
218
|
i += 1
|
|
220
219
|
common = common[:i]
|
|
221
220
|
if len(common) > len(value):
|
|
222
|
-
inp.
|
|
223
|
-
inp.cursor_position = len(common)
|
|
221
|
+
inp.text = common
|
|
224
222
|
|
|
225
223
|
# ── Input submission ──────────────────────────────────────────────────
|
|
226
|
-
@on(
|
|
227
|
-
async def _on_submit(self, event:
|
|
228
|
-
|
|
229
|
-
|
|
224
|
+
@on(ChatInput.Submitted, "#input")
|
|
225
|
+
async def _on_submit(self, event: ChatInput.Submitted) -> None:
|
|
226
|
+
# Don't strip leading/trailing whitespace blindly — newlines inside
|
|
227
|
+
# the message are intentional. Only reject pure-whitespace inputs.
|
|
228
|
+
text = event.value
|
|
229
|
+
if not text.strip():
|
|
230
230
|
return
|
|
231
|
-
event.input.value = ""
|
|
232
231
|
self._refresh_slash_hints("") # hide hints after submit
|
|
233
232
|
|
|
234
|
-
|
|
235
|
-
|
|
233
|
+
first_line = text.split("\n", 1)[0].strip()
|
|
234
|
+
if first_line.startswith("/") and "\n" not in text:
|
|
235
|
+
await self._handle_slash(first_line)
|
|
236
236
|
return
|
|
237
237
|
|
|
238
238
|
if self._stream_task and not self._stream_task.done():
|
|
@@ -270,17 +270,33 @@ class ChatApp(App):
|
|
|
270
270
|
await self._show_history()
|
|
271
271
|
elif head == "/load":
|
|
272
272
|
await self._load_session(arg)
|
|
273
|
+
elif head == "/copy":
|
|
274
|
+
self.action_copy_last()
|
|
273
275
|
elif head == "/help":
|
|
274
276
|
await self._append(Static(
|
|
275
277
|
"[b]Commands[/b]\n"
|
|
276
278
|
" /help this list\n"
|
|
277
279
|
" /clear, /new start a fresh conversation\n"
|
|
280
|
+
" /copy copy the last assistant response to clipboard\n"
|
|
278
281
|
" /history list this agent's recent conversations\n"
|
|
279
282
|
" /load <id> load a past conversation by session_id\n"
|
|
280
283
|
" /models list models available for this agent\n"
|
|
281
284
|
" /model <id> switch the model for the next message\n"
|
|
282
285
|
" /exit, /quit close the TUI\n"
|
|
286
|
+
"\n"
|
|
287
|
+
"[b]Keys[/b]\n"
|
|
288
|
+
" Enter send message\n"
|
|
289
|
+
" Shift/Ctrl+Enter newline inside message\n"
|
|
283
290
|
" Esc cancel current response\n"
|
|
291
|
+
" Ctrl+L new conversation\n"
|
|
292
|
+
" Ctrl+Y copy last assistant response\n"
|
|
293
|
+
" Tab autocomplete slash command\n"
|
|
294
|
+
"\n"
|
|
295
|
+
"[b]Selecting text[/b]\n"
|
|
296
|
+
" The TUI captures the mouse, so click-drag doesn't select text.\n"
|
|
297
|
+
" Hold [b]Option[/b] (macOS Terminal/iTerm2) or [b]Shift[/b] (most Linux\n"
|
|
298
|
+
" terminals) while dragging to bypass the TUI and use your\n"
|
|
299
|
+
" terminal's native selection + copy."
|
|
284
300
|
))
|
|
285
301
|
else:
|
|
286
302
|
await self._append(Static(f"[red]Unknown command: {head}[/red] (try /help)"))
|
|
@@ -416,6 +432,28 @@ class ChatApp(App):
|
|
|
416
432
|
self._stream_task.cancel()
|
|
417
433
|
self._set_status("response cancelled")
|
|
418
434
|
|
|
435
|
+
def action_copy_last(self) -> None:
|
|
436
|
+
"""Copy the most recent assistant turn's text to the system clipboard."""
|
|
437
|
+
turns = list(self.query(AssistantTurn))
|
|
438
|
+
if not turns:
|
|
439
|
+
self._set_status("no assistant response yet")
|
|
440
|
+
return
|
|
441
|
+
# Walk newest-first so a /copy right after a streamed reply picks up
|
|
442
|
+
# the response the user is looking at, even if a turn is empty.
|
|
443
|
+
for turn in reversed(turns):
|
|
444
|
+
segments = list(turn.query(TextSegment))
|
|
445
|
+
text = "\n\n".join(s.content for s in segments if s.content).strip()
|
|
446
|
+
if text:
|
|
447
|
+
try:
|
|
448
|
+
self.copy_to_clipboard(text)
|
|
449
|
+
except Exception as exc:
|
|
450
|
+
self._set_status(f"clipboard error: {exc}")
|
|
451
|
+
return
|
|
452
|
+
preview = text[:60].replace("\n", " ")
|
|
453
|
+
self._set_status(f"copied: {preview}{'…' if len(text) > 60 else ''}")
|
|
454
|
+
return
|
|
455
|
+
self._set_status("no text to copy in the last response")
|
|
456
|
+
|
|
419
457
|
def _follow_bottom(self) -> None:
|
|
420
458
|
"""Scroll to the end of the conversation.
|
|
421
459
|
|
|
@@ -15,8 +15,68 @@ from typing import Any, Optional
|
|
|
15
15
|
|
|
16
16
|
from rich.markdown import Markdown
|
|
17
17
|
from rich.text import Text
|
|
18
|
+
from textual.binding import Binding
|
|
18
19
|
from textual.containers import Vertical
|
|
19
|
-
from textual.
|
|
20
|
+
from textual.message import Message
|
|
21
|
+
from textual.widgets import Static, TextArea
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChatInput(TextArea):
|
|
25
|
+
"""Multi-line input — Enter submits, Shift/Ctrl+Enter inserts a newline.
|
|
26
|
+
|
|
27
|
+
Height grows with content up to a cap so a long paste doesn't take over
|
|
28
|
+
the whole screen; once the cap is hit the widget scrolls internally.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
DEFAULT_CSS = """
|
|
32
|
+
ChatInput {
|
|
33
|
+
height: auto;
|
|
34
|
+
min-height: 3;
|
|
35
|
+
max-height: 14;
|
|
36
|
+
border: round $primary;
|
|
37
|
+
margin: 0 1 1 1;
|
|
38
|
+
padding: 0 1;
|
|
39
|
+
}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
BINDINGS = [
|
|
43
|
+
Binding("enter", "submit", "Submit", show=False, priority=True),
|
|
44
|
+
Binding("shift+enter", "insert_newline", "Newline", show=False, priority=True),
|
|
45
|
+
Binding("ctrl+enter", "insert_newline", "Newline", show=False, priority=True),
|
|
46
|
+
Binding("ctrl+j", "insert_newline", "Newline", show=False, priority=True),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
class Submitted(Message):
|
|
50
|
+
"""Posted when the user hits Enter on a non-empty buffer."""
|
|
51
|
+
def __init__(self, input_widget: "ChatInput", value: str) -> None:
|
|
52
|
+
self.input = input_widget
|
|
53
|
+
self.value = value
|
|
54
|
+
super().__init__()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def control(self) -> "ChatInput":
|
|
58
|
+
# Lets `@on(ChatInput.Submitted, "#input")` selectors work.
|
|
59
|
+
return self.input
|
|
60
|
+
|
|
61
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
62
|
+
super().__init__(**kwargs)
|
|
63
|
+
self.show_line_numbers = False
|
|
64
|
+
# We rely on Textual's soft-wrap so long single lines wrap visually
|
|
65
|
+
# instead of overflowing the input box.
|
|
66
|
+
try:
|
|
67
|
+
self.soft_wrap = True # type: ignore[attr-defined]
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def action_submit(self) -> None:
|
|
72
|
+
text = self.text
|
|
73
|
+
if not text.strip():
|
|
74
|
+
return
|
|
75
|
+
self.text = ""
|
|
76
|
+
self.post_message(self.Submitted(self, text))
|
|
77
|
+
|
|
78
|
+
def action_insert_newline(self) -> None:
|
|
79
|
+
self.insert("\n")
|
|
20
80
|
|
|
21
81
|
|
|
22
82
|
# Server-side displays that should render as an EditPreviewCard (path + diff)
|
|
@@ -11,6 +11,37 @@ from typing import Optional
|
|
|
11
11
|
from virtuai_cli.security import check_command, jail_path, scrub_env
|
|
12
12
|
|
|
13
13
|
_EXECUTE_TIMEOUT = 300 # seconds
|
|
14
|
+
# Hard cap on the combined stdout+stderr we ship back over the WebSocket.
|
|
15
|
+
# Anything larger gets head/tail-clipped with a marker so a runaway command
|
|
16
|
+
# (e.g. `cat /dev/urandom`, `find /`) can't OOM the runner or blow past the
|
|
17
|
+
# server-side frame-size limit and kill the connection.
|
|
18
|
+
_MAX_OUTPUT_BYTES = 2_000_000 # ~2 MB
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _clip_output(stdout: bytes, stderr: bytes) -> str:
|
|
22
|
+
"""Decode + concatenate stdout/stderr, head/tail-clipping if oversized."""
|
|
23
|
+
total = len(stdout) + len(stderr)
|
|
24
|
+
if total <= _MAX_OUTPUT_BYTES:
|
|
25
|
+
parts = []
|
|
26
|
+
if stdout:
|
|
27
|
+
parts.append(stdout.decode(errors="replace"))
|
|
28
|
+
if stderr:
|
|
29
|
+
parts.append(stderr.decode(errors="replace"))
|
|
30
|
+
return "\n".join(parts)
|
|
31
|
+
|
|
32
|
+
# Keep half the budget at the start and half at the end of the combined
|
|
33
|
+
# text — that's where users usually look for context vs error tails.
|
|
34
|
+
combined = (stdout + (b"\n" if stdout and stderr else b"") + stderr)
|
|
35
|
+
keep = _MAX_OUTPUT_BYTES // 2
|
|
36
|
+
head = combined[:keep].decode(errors="replace")
|
|
37
|
+
tail = combined[-keep:].decode(errors="replace")
|
|
38
|
+
dropped = len(combined) - 2 * keep
|
|
39
|
+
return (
|
|
40
|
+
f"{head}\n"
|
|
41
|
+
f"\n... [output truncated: {dropped:,} bytes dropped, "
|
|
42
|
+
f"{len(combined):,} bytes total] ...\n\n"
|
|
43
|
+
f"{tail}"
|
|
44
|
+
)
|
|
14
45
|
|
|
15
46
|
|
|
16
47
|
def _jail_wrap(command: str, workdir: Path) -> str:
|
|
@@ -49,12 +80,10 @@ def execute(command: str, workdir: Path, timeout: int = _EXECUTE_TIMEOUT, extra_
|
|
|
49
80
|
capture_output=True,
|
|
50
81
|
timeout=timeout,
|
|
51
82
|
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
parts.append(result.stderr.decode(errors="replace"))
|
|
57
|
-
return {"output": "\n".join(parts), "exit_code": result.returncode}
|
|
83
|
+
return {
|
|
84
|
+
"output": _clip_output(result.stdout or b"", result.stderr or b""),
|
|
85
|
+
"exit_code": result.returncode,
|
|
86
|
+
}
|
|
58
87
|
except subprocess.TimeoutExpired:
|
|
59
88
|
return {"output": f"command timed out after {timeout}s", "exit_code": 124}
|
|
60
89
|
except Exception as exc:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtuai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -19,7 +19,7 @@ Requires-Dist: certifi>=2024.0
|
|
|
19
19
|
Requires-Dist: keyring>=25.0
|
|
20
20
|
Requires-Dist: typer>=0.12
|
|
21
21
|
Requires-Dist: rich>=13.0
|
|
22
|
-
Requires-Dist: textual>=0.
|
|
22
|
+
Requires-Dist: textual>=0.86
|
|
23
23
|
|
|
24
24
|
# VirtuAI CLI
|
|
25
25
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|