pari-cli 0.1.0__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.
- pari/__init__.py +3 -0
- pari/agent/__init__.py +14 -0
- pari/agent/loop.py +35 -0
- pari/agent/reflection.py +39 -0
- pari/agent/runtime.py +91 -0
- pari/agent/subagent.py +34 -0
- pari/cli.py +132 -0
- pari/config.py +23 -0
- pari/context/__init__.py +15 -0
- pari/context/cache.py +15 -0
- pari/context/chunks.py +40 -0
- pari/context/compaction.py +21 -0
- pari/context/epoch.py +32 -0
- pari/context/warmer.py +32 -0
- pari/edit/__init__.py +20 -0
- pari/edit/cascade.py +22 -0
- pari/edit/diff.py +8 -0
- pari/edit/dmp.py +15 -0
- pari/edit/exact.py +37 -0
- pari/edit/git_osr.py +14 -0
- pari/edit/indenter.py +12 -0
- pari/eval/__init__.py +14 -0
- pari/eval/judge.py +21 -0
- pari/eval/logging.py +19 -0
- pari/eval/metrics.py +22 -0
- pari/eval/runner.py +34 -0
- pari/fs/__init__.py +16 -0
- pari/fs/atomic.py +53 -0
- pari/fs/path.py +28 -0
- pari/fs/queue.py +22 -0
- pari/intelligence/__init__.py +15 -0
- pari/intelligence/ast.py +25 -0
- pari/intelligence/cache.py +21 -0
- pari/intelligence/ppr.py +21 -0
- pari/intelligence/repomap.py +21 -0
- pari/intelligence/search.py +33 -0
- pari/output/__init__.py +14 -0
- pari/output/parser.py +14 -0
- pari/output/spooler.py +33 -0
- pari/output/truncation.py +20 -0
- pari/providers/__init__.py +24 -0
- pari/providers/anthropic.py +23 -0
- pari/providers/base.py +56 -0
- pari/providers/deepseek.py +24 -0
- pari/providers/mock.py +46 -0
- pari/providers/ollama.py +25 -0
- pari/providers/openai.py +23 -0
- pari/routing/__init__.py +16 -0
- pari/routing/anti_verbosity.py +13 -0
- pari/routing/classifier.py +24 -0
- pari/routing/router.py +23 -0
- pari/sandbox/__init__.py +16 -0
- pari/sandbox/base.py +74 -0
- pari/sandbox/bwrap.py +39 -0
- pari/sandbox/dispatcher.py +31 -0
- pari/sandbox/e2b.py +32 -0
- pari/sandbox/process.py +38 -0
- pari/sandbox/seatbelt.py +32 -0
- pari/session/__init__.py +15 -0
- pari/session/checkpoint.py +24 -0
- pari/session/dag.py +34 -0
- pari/session/events.py +50 -0
- pari/session/logger.py +41 -0
- pari/session/undo.py +99 -0
- pari/tools/__init__.py +14 -0
- pari/tools/base.py +35 -0
- pari/tools/bash.py +24 -0
- pari/tools/fs.py +53 -0
- pari/tools/registry.py +36 -0
- pari/tools/search.py +38 -0
- pari/tui/__init__.py +28 -0
- pari/tui/app.py +355 -0
- pari/tui/commands.py +45 -0
- pari/tui/diff_view.py +17 -0
- pari/tui/status.py +65 -0
- pari_cli-0.1.0.dist-info/METADATA +167 -0
- pari_cli-0.1.0.dist-info/RECORD +81 -0
- pari_cli-0.1.0.dist-info/WHEEL +5 -0
- pari_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pari_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- pari_cli-0.1.0.dist-info/top_level.txt +1 -0
pari/tools/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""PARI Standard Tool System.
|
|
2
|
+
|
|
3
|
+
Bridges model tool definitions to sandbox-contained, concurrency-safe execution layers.
|
|
4
|
+
|
|
5
|
+
Available Tools:
|
|
6
|
+
- bash: Execute shell commands inside the Layer 0 sandbox with output spooling
|
|
7
|
+
- read_file: Safe workspace-relative file reading
|
|
8
|
+
- edit_file: Atomic, Stop-on-Ambiguity search and replace
|
|
9
|
+
- write_file: Atomic file creation / overwrite
|
|
10
|
+
- find_files: Workspace file globbing respecting ignore boundaries
|
|
11
|
+
- grep_search: Substring and regex text search across repository files
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# Placeholder imports will be exported once implementations are added
|
pari/tools/base.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""PARI Tools: Base Interfaces and JSON Schema Definitions.
|
|
2
|
+
|
|
3
|
+
Converts Python tool implementations into standard OpenAI/Anthropic compatible
|
|
4
|
+
tool call schemas.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ToolDefinition:
|
|
14
|
+
name: str
|
|
15
|
+
description: str
|
|
16
|
+
parameters: Dict[str, Any]
|
|
17
|
+
|
|
18
|
+
def to_schema(self) -> Dict[str, Any]:
|
|
19
|
+
"""Format definition as standard tool calling schema."""
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Tool(ABC):
|
|
24
|
+
"""Abstract base class for all agent-callable tools."""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def definition(self) -> ToolDefinition:
|
|
29
|
+
"""Tool metadata and argument JSON schema."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def execute(self, **kwargs: Any) -> Any:
|
|
34
|
+
"""Execute tool action safely."""
|
|
35
|
+
pass
|
pari/tools/bash.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""PARI Tools: Sandboxed Bash Execution.
|
|
2
|
+
|
|
3
|
+
Routes command execution through Layer 0 sandbox (Bubblewrap / Seatbelt / Process Group)
|
|
4
|
+
and spools large outputs through Layer 7 ManagedOutputSpooler.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
from pari.tools.base import Tool, ToolDefinition
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BashTool(Tool):
|
|
13
|
+
"""Executes a shell command inside the kernel sandbox."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, workspace: Path):
|
|
16
|
+
self.workspace = workspace
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def definition(self) -> ToolDefinition:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
async def execute(self, command: str, timeout: float = 30.0) -> Dict[str, Any]:
|
|
23
|
+
"""Execute command inside sandbox with output spooling."""
|
|
24
|
+
raise NotImplementedError
|
pari/tools/fs.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""PARI Tools: Filesystem and Editing Tools.
|
|
2
|
+
|
|
3
|
+
Integrates Layer 6 safe path resolution and atomic writes with Layer 5 edit cascade.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
from pari.tools.base import Tool, ToolDefinition
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReadFileTool(Tool):
|
|
12
|
+
"""Safely reads file contents within workspace boundary."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, workspace: Path):
|
|
15
|
+
self.workspace = workspace
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def definition(self) -> ToolDefinition:
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
async def execute(self, path: str) -> str:
|
|
22
|
+
"""Read target file."""
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WriteFileTool(Tool):
|
|
27
|
+
"""Safely and atomically writes or creates a file."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, workspace: Path):
|
|
30
|
+
self.workspace = workspace
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def definition(self) -> ToolDefinition:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
async def execute(self, path: str, content: str) -> str:
|
|
37
|
+
"""Atomically write file."""
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EditFileTool(Tool):
|
|
42
|
+
"""Safely edits an existing file using multi-tier fuzzy search/replace cascade."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, workspace: Path):
|
|
45
|
+
self.workspace = workspace
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def definition(self) -> ToolDefinition:
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
async def execute(self, path: str, search: str, replace: str) -> Dict[str, Any]:
|
|
52
|
+
"""Apply edit using cascade."""
|
|
53
|
+
raise NotImplementedError
|
pari/tools/registry.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""PARI Tools: Central Registry and Dispatcher.
|
|
2
|
+
|
|
3
|
+
Manages tool registration, schema exportation for model prompts, and dispatch execution.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
from pari.tools.base import Tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolRegistry:
|
|
12
|
+
"""Registry maintaining active agent tools."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._tools: Dict[str, Tool] = {}
|
|
16
|
+
|
|
17
|
+
def register(self, tool: Tool):
|
|
18
|
+
"""Register a new tool instance."""
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
def get(self, name: str) -> Optional[Tool]:
|
|
22
|
+
"""Look up tool by name."""
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def get_schemas(self) -> List[Dict[str, Any]]:
|
|
26
|
+
"""Return list of JSON schemas for all registered tools."""
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
async def execute(self, name: str, arguments: Dict[str, Any]) -> Any:
|
|
30
|
+
"""Dispatch tool execution by name with given arguments."""
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_default_registry(workspace: Path) -> ToolRegistry:
|
|
35
|
+
"""Instantiate and register the standard PARI tool suite for a given workspace."""
|
|
36
|
+
raise NotImplementedError
|
pari/tools/search.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""PARI Tools: Codebase Search and Discovery Tools.
|
|
2
|
+
|
|
3
|
+
Routes globbing and text grep queries through Layer 3 intelligence search.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
from pari.tools.base import Tool, ToolDefinition
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FindFilesTool(Tool):
|
|
12
|
+
"""Finds files matching glob patterns while respecting ignore rules."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, workspace: Path):
|
|
15
|
+
self.workspace = workspace
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def definition(self) -> ToolDefinition:
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
async def execute(self, pattern: str = "*", max_results: int = 50) -> List[str]:
|
|
22
|
+
"""Find files matching pattern."""
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GrepTool(Tool):
|
|
27
|
+
"""Searches workspace files for regex or substring pattern matches."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, workspace: Path):
|
|
30
|
+
self.workspace = workspace
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def definition(self) -> ToolDefinition:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
async def execute(self, query: str, max_matches: int = 50) -> List[Dict[str, Any]]:
|
|
37
|
+
"""Search file contents for pattern."""
|
|
38
|
+
raise NotImplementedError
|
pari/tui/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""PARI Layer 9: Terminal Interface & Differential TUI.
|
|
2
|
+
|
|
3
|
+
Architectural Purpose:
|
|
4
|
+
Harvested from Pi's differential TUI rendering and Aider's prompt_toolkit interface.
|
|
5
|
+
Closes Cursor F4 (background CPU pegging vector indexer).
|
|
6
|
+
Provides responsive streaming, syntax-highlighted diff review, token cost meter,
|
|
7
|
+
and inline slash commands without heavy Electron overhead.
|
|
8
|
+
|
|
9
|
+
Subsystems:
|
|
10
|
+
- app: Interactive terminal session loop
|
|
11
|
+
- diff_view: Syntax-highlighted unified diff preview & approval prompt
|
|
12
|
+
- status: Token tracker & USD cost meter
|
|
13
|
+
- commands: Interactive slash commands (/undo, /diff, /model, /help)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from pari.tui.app import TerminalApp
|
|
17
|
+
from pari.tui.commands import SlashCommandHandler
|
|
18
|
+
from pari.tui.diff_view import display_diff
|
|
19
|
+
from pari.tui.status import CostMeter, TokenUsage
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"TerminalApp",
|
|
23
|
+
"SlashCommandHandler",
|
|
24
|
+
"display_diff",
|
|
25
|
+
"CostMeter",
|
|
26
|
+
"TokenUsage",
|
|
27
|
+
]
|
|
28
|
+
|
pari/tui/app.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import shutil
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from prompt_toolkit import PromptSession
|
|
8
|
+
from prompt_toolkit.application.current import get_app
|
|
9
|
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
10
|
+
from prompt_toolkit.document import Document
|
|
11
|
+
from prompt_toolkit.filters import has_completions
|
|
12
|
+
from prompt_toolkit.formatted_text import HTML
|
|
13
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
14
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
15
|
+
from prompt_toolkit.shortcuts import CompleteStyle
|
|
16
|
+
from prompt_toolkit.styles import Style
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.markdown import Markdown
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
|
|
21
|
+
from pari.tui.commands import SlashCommandHandler
|
|
22
|
+
from pari.tui.diff_view import display_diff
|
|
23
|
+
|
|
24
|
+
# Slash command metadata definitions
|
|
25
|
+
COMMAND_METADATA = [
|
|
26
|
+
("/help", "Show available slash commands and help"),
|
|
27
|
+
("/diff", "Preview pending or latest unified diffs"),
|
|
28
|
+
("/undo", "Revert the most recent file change"),
|
|
29
|
+
("/clear", "Clear terminal screen"),
|
|
30
|
+
("/model", "Inspect or switch active LLM"),
|
|
31
|
+
("/add", "Pin reference file or glob into context"),
|
|
32
|
+
("/exit", "End interactive session"),
|
|
33
|
+
("/quit", "End interactive session"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SlashCommandCompleter(Completer):
|
|
38
|
+
"""Autocompleter for slash commands with formatted descriptions and clean prefix matching."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, commands: list[tuple[str, str]] | None = None):
|
|
41
|
+
self.commands = commands or COMMAND_METADATA
|
|
42
|
+
|
|
43
|
+
def get_completions(
|
|
44
|
+
self, document: Document, complete_event: CompleteEvent
|
|
45
|
+
) -> Iterable[Completion]:
|
|
46
|
+
text = document.text_before_cursor.lstrip()
|
|
47
|
+
|
|
48
|
+
# If user has entered arguments (e.g. "/model gpt-4o "), don't complete slash commands
|
|
49
|
+
if " " in text:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
# Trigger on '/' or when explicit completion is requested (e.g. Tab on empty line)
|
|
53
|
+
if not text.startswith("/") and not (complete_event.completion_requested and not text):
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
query = text.lower()
|
|
57
|
+
for cmd, desc in self.commands:
|
|
58
|
+
if cmd.lower().startswith(query):
|
|
59
|
+
yield Completion(
|
|
60
|
+
text=cmd,
|
|
61
|
+
start_position=-len(text),
|
|
62
|
+
display=cmd,
|
|
63
|
+
display_meta=desc,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Modern CLI theme matching the completion dropdown menu & toolbar styling
|
|
68
|
+
CLI_STYLE = Style.from_dict({
|
|
69
|
+
# Prompt & input
|
|
70
|
+
"prompt": "fg:#38bdf8 bold",
|
|
71
|
+
"": "fg:#f3f4f6",
|
|
72
|
+
"placeholder": "fg:#6b7280 italic",
|
|
73
|
+
|
|
74
|
+
# Completion Menu dropdown
|
|
75
|
+
"completion-menu": "bg:#18181b fg:#f4f4f5",
|
|
76
|
+
"completion-menu.completion": "bg:#18181b fg:#38bdf8",
|
|
77
|
+
"completion-menu.completion.current": "bg:#27272a fg:#38bdf8 bold",
|
|
78
|
+
"completion-menu.meta.completion": "bg:#18181b fg:#717882",
|
|
79
|
+
"completion-menu.meta.completion.current": "bg:#27272a fg:#e4e4e7",
|
|
80
|
+
"completion-menu.multi-column-meta": "bg:#18181b fg:#717882",
|
|
81
|
+
|
|
82
|
+
# Scrollbar
|
|
83
|
+
"scrollbar.background": "bg:#18181b",
|
|
84
|
+
"scrollbar.button": "bg:#3f3f46",
|
|
85
|
+
"scrollbar.arrow": "bg:#18181b fg:#717882",
|
|
86
|
+
|
|
87
|
+
# Bottom toolbar & navigation hint keys
|
|
88
|
+
"bottom-toolbar": "bg:default fg:#717882",
|
|
89
|
+
"bottom-toolbar.text": "bg:default fg:#717882",
|
|
90
|
+
"toolbar-key": "fg:#38bdf8 bold",
|
|
91
|
+
"toolbar-key-esc": "fg:#717882",
|
|
92
|
+
"toolbar-action": "fg:#717882",
|
|
93
|
+
"toolbar-dot": "fg:#4b5563",
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TerminalApp:
|
|
98
|
+
"""Manages developer interactions in the terminal REPL."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, workspace: Path, model_name: str = "pari-core"):
|
|
101
|
+
self.workspace = workspace.resolve()
|
|
102
|
+
self.model_name = model_name
|
|
103
|
+
self.console = Console()
|
|
104
|
+
# TODO:
|
|
105
|
+
# self.runtime = runtime or AgentRuntime(self.workspace)
|
|
106
|
+
# self.cost_meter = CostMeter(model_family=self.model_name) # later todo
|
|
107
|
+
self.commands = SlashCommandHandler()
|
|
108
|
+
self._register_commands()
|
|
109
|
+
|
|
110
|
+
self.completer = SlashCommandCompleter()
|
|
111
|
+
self.key_bindings = self._create_key_bindings()
|
|
112
|
+
self.session: PromptSession = PromptSession(
|
|
113
|
+
history=InMemoryHistory(),
|
|
114
|
+
completer=self.completer,
|
|
115
|
+
style=CLI_STYLE,
|
|
116
|
+
complete_while_typing=True,
|
|
117
|
+
complete_style=CompleteStyle.COLUMN,
|
|
118
|
+
reserve_space_for_menu=10,
|
|
119
|
+
key_bindings=self.key_bindings,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _create_key_bindings(self) -> KeyBindings:
|
|
123
|
+
"""Keybindings for autocomplete navigation: enter select, tab complete, esc cancel."""
|
|
124
|
+
kb = KeyBindings()
|
|
125
|
+
|
|
126
|
+
@kb.add("enter", filter=has_completions)
|
|
127
|
+
def _handle_enter(event):
|
|
128
|
+
buff = event.current_buffer
|
|
129
|
+
if buff.complete_state and buff.complete_state.current_completion:
|
|
130
|
+
buff.apply_completion(buff.complete_state.current_completion)
|
|
131
|
+
else:
|
|
132
|
+
buff.validate_and_handle()
|
|
133
|
+
|
|
134
|
+
@kb.add("tab", filter=has_completions)
|
|
135
|
+
def _handle_tab(event):
|
|
136
|
+
buff = event.current_buffer
|
|
137
|
+
if buff.complete_state:
|
|
138
|
+
buff.complete_next()
|
|
139
|
+
|
|
140
|
+
@kb.add("s-tab", filter=has_completions)
|
|
141
|
+
def _handle_s_tab(event):
|
|
142
|
+
buff = event.current_buffer
|
|
143
|
+
if buff.complete_state:
|
|
144
|
+
buff.complete_previous()
|
|
145
|
+
|
|
146
|
+
@kb.add("down", filter=has_completions)
|
|
147
|
+
def _handle_down(event):
|
|
148
|
+
event.current_buffer.complete_next()
|
|
149
|
+
|
|
150
|
+
@kb.add("up", filter=has_completions)
|
|
151
|
+
def _handle_up(event):
|
|
152
|
+
event.current_buffer.complete_previous()
|
|
153
|
+
|
|
154
|
+
@kb.add("escape", filter=has_completions)
|
|
155
|
+
def _handle_escape(event):
|
|
156
|
+
buff = event.current_buffer
|
|
157
|
+
buff.cancel_completion()
|
|
158
|
+
|
|
159
|
+
return kb
|
|
160
|
+
|
|
161
|
+
def _register_commands(self) -> None:
|
|
162
|
+
"""Register built-in slash commands."""
|
|
163
|
+
self.commands.register("/help", self._cmd_help)
|
|
164
|
+
self.commands.register("/diff", self._cmd_diff)
|
|
165
|
+
self.commands.register("/undo", self._cmd_undo)
|
|
166
|
+
self.commands.register("/model", self._cmd_model)
|
|
167
|
+
self.commands.register("/add", self._cmd_add)
|
|
168
|
+
|
|
169
|
+
def _cmd_help(self, args: str) -> str:
|
|
170
|
+
return (
|
|
171
|
+
"**Available Slash Commands:**\n\n"
|
|
172
|
+
"- `/diff` - Preview pending or latest unified diffs\n"
|
|
173
|
+
"- `/undo` - Revert the most recent file change\n"
|
|
174
|
+
"- `/model [name]` - Inspect or switch active LLM\n"
|
|
175
|
+
"- `/add [path]` - Pin reference file or glob into context\n"
|
|
176
|
+
"- `/clear` - Clear terminal screen\n"
|
|
177
|
+
"- `/help` - Show this commands overview\n"
|
|
178
|
+
"- `/exit` or `/quit` - End interactive session"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _cmd_diff(self, args: str) -> str:
|
|
182
|
+
# Display sample/active unified diff preview
|
|
183
|
+
sample_diff = (
|
|
184
|
+
"--- a/src/example.py\n"
|
|
185
|
+
"+++ b/src/example.py\n"
|
|
186
|
+
"@@ -1,3 +1,3 @@\n"
|
|
187
|
+
"-def hello():\n"
|
|
188
|
+
"- return 'world'\n"
|
|
189
|
+
"+def hello() -> str:\n"
|
|
190
|
+
"+ return 'hello world from PARI!'"
|
|
191
|
+
)
|
|
192
|
+
display_diff(sample_diff, filename="src/example.py", console=self.console)
|
|
193
|
+
return "Inspecting diff preview for `src/example.py`."
|
|
194
|
+
|
|
195
|
+
def _cmd_undo(self, args: str) -> str:
|
|
196
|
+
# TODO: res = self.runtime.undo_manager.undo()
|
|
197
|
+
if hasattr(self, "runtime") and hasattr(self.runtime, "undo_manager"):
|
|
198
|
+
res = self.runtime.undo_manager.undo()
|
|
199
|
+
if res.reverted_files:
|
|
200
|
+
return f"Reverted files: {', '.join(res.reverted_files)}"
|
|
201
|
+
return "No previous file modifications in session history to undo."
|
|
202
|
+
|
|
203
|
+
def _cmd_model(self, args: str) -> str:
|
|
204
|
+
if args:
|
|
205
|
+
self.model_name = args.strip()
|
|
206
|
+
# TODO: self.cost_meter.model_family = self.model_name.lower()
|
|
207
|
+
if hasattr(self, "cost_meter"):
|
|
208
|
+
self.cost_meter.model_family = self.model_name.lower()
|
|
209
|
+
return f"Switched active model to: `{self.model_name}`"
|
|
210
|
+
return f"Active model: `{self.model_name}` (Tiers: claude-3-7-sonnet, claude-3-5-sonnet, gpt-4o, pari-core)"
|
|
211
|
+
|
|
212
|
+
def _cmd_add(self, args: str) -> str:
|
|
213
|
+
if not args:
|
|
214
|
+
return "Usage: `/add <file_or_glob>` (e.g. `/add pyproject.toml`)"
|
|
215
|
+
# TODO: self.runtime.add_pinned_file(args.strip())
|
|
216
|
+
if hasattr(self, "runtime"):
|
|
217
|
+
added = self.runtime.add_pinned_file(args.strip())
|
|
218
|
+
if added:
|
|
219
|
+
return f"Added {args.strip()} to active context."
|
|
220
|
+
target = (self.workspace / args.strip()).resolve()
|
|
221
|
+
if not target.exists():
|
|
222
|
+
return f"File not found: `{args}`"
|
|
223
|
+
try:
|
|
224
|
+
rel = target.relative_to(self.workspace)
|
|
225
|
+
except ValueError:
|
|
226
|
+
rel = target
|
|
227
|
+
return f"Added {rel} to active context."
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def print_welcome(self):
|
|
232
|
+
"""Display startup banner."""
|
|
233
|
+
welcome_text = (
|
|
234
|
+
f"[bold cyan]PARI v0.1.0[/bold cyan] — Coding Agent\n"
|
|
235
|
+
f"[dim]Workspace :[/dim] [yellow]{self.workspace}[/yellow]\n"
|
|
236
|
+
f"[dim]Model :[/dim] [green]{self.model_name}[/green]\n\n"
|
|
237
|
+
f"[dim]Type your message, [/dim][bold cyan]/help[/bold cyan][dim] for commands, [/dim][bold cyan]/exit[/bold cyan][dim] to quit.[/dim]"
|
|
238
|
+
)
|
|
239
|
+
self.console.print(
|
|
240
|
+
Panel(
|
|
241
|
+
welcome_text,
|
|
242
|
+
title="[bold green]PARI[/bold green]",
|
|
243
|
+
border_style="dim cyan",
|
|
244
|
+
padding=(1, 2),
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def get_prompt(self):
|
|
249
|
+
"""Clean prompt indicator matching modern CLI design."""
|
|
250
|
+
return [
|
|
251
|
+
("class:prompt", "> "),
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
def get_placeholder(self):
|
|
255
|
+
"""Placeholder text when input line is empty."""
|
|
256
|
+
return [("class:placeholder", "Ask PARI to do anything...")]
|
|
257
|
+
|
|
258
|
+
def get_bottom_toolbar(self):
|
|
259
|
+
"""Dynamic toolbar showing key navigation hints during completion or session status."""
|
|
260
|
+
try:
|
|
261
|
+
app = get_app()
|
|
262
|
+
buff = app.current_buffer
|
|
263
|
+
is_completing = bool(buff.complete_state) or (
|
|
264
|
+
buff.text.strip().startswith("/") and " " not in buff.text.strip()
|
|
265
|
+
)
|
|
266
|
+
except Exception:
|
|
267
|
+
is_completing = False
|
|
268
|
+
|
|
269
|
+
if is_completing:
|
|
270
|
+
return [
|
|
271
|
+
("class:toolbar-key", "↑/↓"),
|
|
272
|
+
("class:toolbar-action", " Navigate "),
|
|
273
|
+
("class:toolbar-dot", "· "),
|
|
274
|
+
("class:toolbar-key", "enter"),
|
|
275
|
+
("class:toolbar-action", " Select "),
|
|
276
|
+
("class:toolbar-dot", "· "),
|
|
277
|
+
("class:toolbar-key", "tab"),
|
|
278
|
+
("class:toolbar-action", " Complete\n"),
|
|
279
|
+
("class:toolbar-key-esc", "esc"),
|
|
280
|
+
("class:toolbar-action", " to cancel"),
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
home = Path.home()
|
|
284
|
+
try:
|
|
285
|
+
rel_path = f"~/{self.workspace.relative_to(home)}"
|
|
286
|
+
except ValueError:
|
|
287
|
+
rel_path = str(self.workspace)
|
|
288
|
+
|
|
289
|
+
cost_str = f" · {self.cost_meter.format_status()}" if hasattr(self, "cost_meter") else ""
|
|
290
|
+
status_meta = f" {self.model_name} default · {rel_path}{cost_str}"
|
|
291
|
+
return [
|
|
292
|
+
("class:bottom-toolbar.text", status_meta),
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
def process_message(self, user_msg: str) -> str:
|
|
296
|
+
"""Process user message and generate a response."""
|
|
297
|
+
# 1. Check slash commands first
|
|
298
|
+
cmd_result = self.commands.handle(user_msg)
|
|
299
|
+
if cmd_result is not None:
|
|
300
|
+
_, output = cmd_result
|
|
301
|
+
return output
|
|
302
|
+
|
|
303
|
+
normalized = user_msg.strip().lower()
|
|
304
|
+
|
|
305
|
+
# 2. Handle greetings like "hi how are you", "hi howr you", etc.
|
|
306
|
+
if "how" in normalized and ("are you" in normalized or "r you" in normalized or "howr" in normalized):
|
|
307
|
+
return "Good! How can I help you today?"
|
|
308
|
+
elif normalized in ("hi", "hello", "hey"):
|
|
309
|
+
return "Hello! How can I assist you with your project?"
|
|
310
|
+
|
|
311
|
+
# 3. Normal turn execution (TODO: self.cost_meter.add_usage)
|
|
312
|
+
if hasattr(self, "cost_meter"):
|
|
313
|
+
self.cost_meter.add_usage(in_tokens=len(user_msg.split()) * 4 + 150, out_tokens=60)
|
|
314
|
+
return f"Received: {user_msg}\n\n*(Agent core is ready for prompt orchestration)*"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def run_repl(self):
|
|
318
|
+
"""Main interactive terminal read-eval-print loop."""
|
|
319
|
+
self.print_welcome()
|
|
320
|
+
|
|
321
|
+
while True:
|
|
322
|
+
try:
|
|
323
|
+
user_input = await self.session.prompt_async(
|
|
324
|
+
message=self.get_prompt,
|
|
325
|
+
placeholder=self.get_placeholder,
|
|
326
|
+
bottom_toolbar=self.get_bottom_toolbar,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
text = user_input.strip()
|
|
330
|
+
if not text:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# 2. Built-in session commands
|
|
334
|
+
if text in ("/exit", "/quit", "exit", "quit"):
|
|
335
|
+
self.console.print("[dim]Exiting PARI session. Goodbye![/dim]")
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
if text == "/clear":
|
|
339
|
+
self.console.clear()
|
|
340
|
+
self.print_welcome()
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
# 3. Simulate thinking with Rich status spinner
|
|
344
|
+
with self.console.status("[bold cyan]PARI is thinking...", spinner="dots"):
|
|
345
|
+
await asyncio.sleep(0.4)
|
|
346
|
+
response = self.process_message(text)
|
|
347
|
+
|
|
348
|
+
# 4. Render response via Rich
|
|
349
|
+
self.console.print()
|
|
350
|
+
self.console.print(Panel(Markdown(response), title="[bold cyan]PARI Agent[/bold cyan]", border_style="dim"))
|
|
351
|
+
self.console.print()
|
|
352
|
+
|
|
353
|
+
except (KeyboardInterrupt, EOFError):
|
|
354
|
+
self.console.print("\n[dim]Session interrupted. Goodbye![/dim]")
|
|
355
|
+
break
|
pari/tui/commands.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""PARI Layer 9: Interactive Slash Command Dispatcher.
|
|
2
|
+
|
|
3
|
+
Handles in-session slash commands:
|
|
4
|
+
/undo - Revert the most recent turn's file modifications
|
|
5
|
+
/diff - Inspect pending or latest diffs
|
|
6
|
+
/add - Add a specific file into active context
|
|
7
|
+
/model - Inspect or switch active LLM model
|
|
8
|
+
/help - Display available commands
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Callable, Dict, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SlashCommandHandler:
|
|
15
|
+
"""Dispatches slash commands entered by the developer."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self._handlers: Dict[str, Callable[[str], str]] = {}
|
|
19
|
+
|
|
20
|
+
def register(self, command: str, handler: Callable[[str], str]) -> None:
|
|
21
|
+
"""Register custom slash command handler."""
|
|
22
|
+
cmd_name = command.lower().strip()
|
|
23
|
+
if not cmd_name.startswith("/"):
|
|
24
|
+
cmd_name = f"/{cmd_name}"
|
|
25
|
+
self._handlers[cmd_name] = handler
|
|
26
|
+
|
|
27
|
+
def handle(self, input_text: str) -> Optional[Tuple[str, str]]:
|
|
28
|
+
"""If input_text starts with '/', parse and execute command.
|
|
29
|
+
|
|
30
|
+
Returns (command_name, result_message) if handled, or None if not a slash command.
|
|
31
|
+
"""
|
|
32
|
+
text = input_text.strip()
|
|
33
|
+
if not text.startswith("/"):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
parts = text.split(maxsplit=1)
|
|
37
|
+
cmd = parts[0].lower()
|
|
38
|
+
args = parts[1].strip() if len(parts) > 1 else ""
|
|
39
|
+
|
|
40
|
+
if cmd in self._handlers:
|
|
41
|
+
output = self._handlers[cmd](args)
|
|
42
|
+
return (cmd, output)
|
|
43
|
+
|
|
44
|
+
return (cmd, f"Unknown command: '{cmd}'. Type `/help` for a list of commands.")
|
|
45
|
+
|
pari/tui/diff_view.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
from rich.syntax import Syntax
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def display_diff(diff_text: str, filename: str = "", console: Optional[Console] = None) -> None:
|
|
8
|
+
"""Renders a unified diff in the terminal with colored highlighting."""
|
|
9
|
+
c = console or Console()
|
|
10
|
+
if not diff_text.strip():
|
|
11
|
+
c.print("[dim]No diff changes to display.[/dim]")
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=True, word_wrap=True)
|
|
15
|
+
title = f"[bold cyan]Diff: {filename}[/bold cyan]" if filename else "[bold cyan]Unified Diff Preview[/bold cyan]"
|
|
16
|
+
c.print(Panel(syntax, title=title, border_style="cyan", padding=(0, 1)))
|
|
17
|
+
|