yaicli 0.1.0__py3-none-any.whl → 0.3.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.
yaicli/printer.py CHANGED
@@ -1,20 +1,31 @@
1
- import itertools
2
1
  import time
3
2
  import traceback
4
3
  from typing import (
5
4
  Any,
6
5
  Dict,
7
6
  Iterator,
7
+ List,
8
8
  Optional,
9
9
  Tuple,
10
10
  )
11
11
 
12
- from rich import get_console
13
- from rich.console import Console
12
+ from rich.console import Console, Group, RenderableType
14
13
  from rich.live import Live
15
- from rich.markdown import Markdown
16
14
 
17
- from yaicli.const import DEFAULT_CODE_THEME, EventTypeEnum
15
+ from yaicli.console import get_console
16
+ from yaicli.const import EventTypeEnum
17
+ from yaicli.render import JustifyMarkdown as Markdown
18
+ from yaicli.render import plain_formatter
19
+
20
+
21
+ def cursor_animation() -> Iterator[str]:
22
+ """Generate a cursor animation for the console."""
23
+ cursors = ["_", " "]
24
+ while True:
25
+ # Use current time to determine cursor state (changes every 0.5 seconds)
26
+ current_time = time.time()
27
+ # Alternate between cursors based on time
28
+ yield cursors[int(current_time * 2) % 2]
18
29
 
19
30
 
20
31
  class Printer:
@@ -23,12 +34,40 @@ class Printer:
23
34
  _REASONING_PREFIX = "> "
24
35
  _CURSOR_ANIMATION_SLEEP = 0.005
25
36
 
26
- def __init__(self, config: Dict[str, Any], console: Console, verbose: bool = False):
37
+ def __init__(
38
+ self,
39
+ config: Dict[str, Any],
40
+ console: Console,
41
+ verbose: bool = False,
42
+ markdown: bool = True,
43
+ reasoning_markdown: Optional[bool] = None,
44
+ content_markdown: Optional[bool] = None,
45
+ ):
46
+ """Initialize the Printer class.
47
+
48
+ Args:
49
+ config (Dict[str, Any]): The configuration dictionary.
50
+ console (Console): The console object.
51
+ verbose (bool): Whether to print verbose output.
52
+ markdown (bool): Whether to use Markdown formatting for all output (legacy).
53
+ reasoning_markdown (Optional[bool]): Whether to use Markdown for reasoning sections.
54
+ content_markdown (Optional[bool]): Whether to use Markdown for content sections.
55
+ """
27
56
  self.config = config
28
57
  self.console = console or get_console()
29
58
  self.verbose = verbose
30
- self.code_theme = config.get("CODE_THEME", DEFAULT_CODE_THEME)
59
+ self.code_theme = config["CODE_THEME"]
31
60
  self.in_reasoning: bool = False
61
+ # Print reasoning content or not
62
+ self.show_reasoning = config["SHOW_REASONING"]
63
+
64
+ # Use explicit settings if provided, otherwise fall back to the global markdown setting
65
+ self.reasoning_markdown = reasoning_markdown if reasoning_markdown is not None else markdown
66
+ self.content_markdown = content_markdown if content_markdown is not None else markdown
67
+
68
+ # Set formatters for reasoning and content
69
+ self.reasoning_formatter = Markdown if self.reasoning_markdown else plain_formatter
70
+ self.content_formatter = Markdown if self.content_markdown else plain_formatter
32
71
 
33
72
  def _reset_state(self) -> None:
34
73
  """Resets the printer state for a new stream."""
@@ -118,7 +157,7 @@ class Printer:
118
157
 
119
158
  return content, reasoning
120
159
 
121
- def _format_display_text(self, content: str, reasoning: str) -> str:
160
+ def _format_display_text(self, content: str, reasoning: str) -> RenderableType:
122
161
  """Format the text for display, combining content and reasoning if needed.
123
162
 
124
163
  Args:
@@ -126,26 +165,39 @@ class Printer:
126
165
  reasoning (str): The reasoning text.
127
166
 
128
167
  Returns:
129
- str: The formatted text for display.
168
+ RenderableType: The formatted text ready for display as a Rich renderable.
130
169
  """
131
- display_text = ""
132
-
133
- # Add reasoning with proper formatting if it exists
134
- if reasoning:
135
- formatted_reasoning = reasoning.replace("\n", f"\n{self._REASONING_PREFIX}")
136
- if not formatted_reasoning.startswith(self._REASONING_PREFIX):
137
- formatted_reasoning = self._REASONING_PREFIX + formatted_reasoning
138
- # Reasoning prefix is now added per line
139
- display_text += f"\nThinking:\n{formatted_reasoning}"
140
-
141
- # Only add newlines if there is content following the reasoning
142
- if content:
143
- display_text += "\n\n"
144
-
145
- # Add content
146
- display_text += content
147
-
148
- return display_text
170
+ # Create list of display elements to avoid type issues with concatenation
171
+ display_elements: List[RenderableType] = []
172
+
173
+ reasoning = reasoning.strip()
174
+ # Format reasoning with proper formatting if it exists
175
+ if reasoning and self.show_reasoning:
176
+ raw_reasoning = reasoning.replace("\n", f"\n{self._REASONING_PREFIX}")
177
+ if not raw_reasoning.startswith(self._REASONING_PREFIX):
178
+ raw_reasoning = self._REASONING_PREFIX + raw_reasoning
179
+
180
+ # Format the reasoning section
181
+ reasoning_header = "\nThinking:\n"
182
+ formatted_reasoning = self.reasoning_formatter(reasoning_header + raw_reasoning, code_theme=self.code_theme)
183
+ display_elements.append(formatted_reasoning)
184
+
185
+ content = content.strip()
186
+ # Format content if it exists
187
+ if content:
188
+ formatted_content = self.content_formatter(content, code_theme=self.code_theme)
189
+
190
+ # Add spacing between reasoning and content if both exist
191
+ if reasoning and self.show_reasoning:
192
+ display_elements.append("")
193
+
194
+ display_elements.append(formatted_content)
195
+
196
+ # Return based on what we have
197
+ if not display_elements:
198
+ return ""
199
+ # Use Rich Group to combine multiple renderables
200
+ return Group(*display_elements)
149
201
 
150
202
  def _update_live_display(self, live: Live, content: str, reasoning: str, cursor: Iterator[str]) -> None:
151
203
  """Update live display content and execute cursor animation
@@ -157,52 +209,57 @@ class Printer:
157
209
  reasoning (str): The current reasoning text.
158
210
  cursor (Iterator[str]): The cursor animation iterator.
159
211
  """
160
- # Format display text without cursor first
161
- display_text = self._format_display_text(content, reasoning)
212
+
162
213
  cursor_char = next(cursor)
163
214
 
164
- # Add cursor at the end of reasoning or content depending on current state
165
- if self.in_reasoning:
166
- # Add cursor at the end of reasoning content
215
+ # Handle cursor placement based on current state
216
+ if self.in_reasoning and self.show_reasoning:
217
+ # For reasoning, add cursor in plaintext to reasoning section
167
218
  if reasoning:
168
- # Append cursor directly if reasoning ends without newline, otherwise append after prefix
169
219
  if reasoning.endswith("\n"):
170
- display_text += f"\n{self._REASONING_PREFIX}{cursor_char}"
220
+ cursor_line = f"\n{self._REASONING_PREFIX}{cursor_char}"
171
221
  else:
172
- display_text += cursor_char
222
+ cursor_line = cursor_char
223
+
224
+ # Re-format with cursor added
225
+ raw_reasoning = reasoning + cursor_line.replace(self._REASONING_PREFIX, "")
226
+ formatted_display = self._format_display_text(content, raw_reasoning)
173
227
  else:
174
- # If reasoning just started and no content yet
175
- # Updated to match formatting
176
- display_text = f"\nThinking:\n{self._REASONING_PREFIX}{cursor_char}"
228
+ # If reasoning just started with no content yet
229
+ reasoning_header = f"\nThinking:\n{self._REASONING_PREFIX}{cursor_char}"
230
+ formatted_reasoning = self.reasoning_formatter(reasoning_header, code_theme=self.code_theme)
231
+ formatted_display = Group(formatted_reasoning)
177
232
  else:
178
- # Add cursor at the end of normal content
179
- display_text += cursor_char
233
+ # For content, add cursor to content section
234
+ formatted_content_with_cursor = content + cursor_char
235
+ formatted_display = self._format_display_text(formatted_content_with_cursor, reasoning)
180
236
 
181
- markdown = Markdown(display_text, code_theme=self.code_theme)
182
- live.update(markdown)
237
+ live.update(formatted_display)
183
238
  time.sleep(self._CURSOR_ANIMATION_SLEEP)
184
239
 
185
- def display_stream(self, stream_iterator: Iterator[Dict[str, Any]]) -> Tuple[Optional[str], Optional[str]]:
240
+ def display_stream(
241
+ self, stream_iterator: Iterator[Dict[str, Any]], with_assistant_prefix: bool = True
242
+ ) -> Tuple[Optional[str], Optional[str]]:
186
243
  """Display streaming response content
187
244
  Handle stream events and update the live display accordingly.
188
245
  This method separates content and reasoning blocks for display and further processing.
189
246
 
190
247
  Args:
191
248
  stream_iterator (Iterator[Dict[str, Any]]): The stream iterator to process.
249
+ with_assistant_prefix (bool): Whether to display the "Assistant:" prefix.
192
250
  Returns:
193
251
  Tuple[Optional[str], Optional[str]]: The final content and reasoning texts if successful, None otherwise.
194
252
  """
195
- self.console.print("Assistant:", style="bold green")
253
+ if with_assistant_prefix:
254
+ self.console.print("Assistant:", style="bold green")
196
255
  self._reset_state() # Reset state for the new stream
197
256
  content = ""
198
257
  reasoning = ""
199
- # Removed in_reasoning local variable initialization
200
- cursor = itertools.cycle(["_", " "])
258
+ cursor = cursor_animation()
201
259
 
202
260
  with Live(console=self.console) as live:
203
261
  try:
204
262
  for event in stream_iterator:
205
- # Pass only content and reasoning, rely on self.in_reasoning
206
263
  content, reasoning = self._handle_event(event, content, reasoning)
207
264
 
208
265
  if event.get("type") in (
@@ -210,35 +267,34 @@ class Printer:
210
267
  EventTypeEnum.REASONING,
211
268
  EventTypeEnum.REASONING_END,
212
269
  ):
213
- # Pass only necessary variables, rely on self.in_reasoning
214
270
  self._update_live_display(live, content, reasoning, cursor)
215
271
 
216
272
  # Remove cursor and finalize display
217
- display_text = self._format_display_text(content, reasoning)
218
- live.update(Markdown(display_text, code_theme=self.code_theme))
273
+ live.update(self._format_display_text(content, reasoning))
219
274
  return content, reasoning
220
275
 
221
276
  except Exception as e:
222
277
  self.console.print(f"An error occurred during stream display: {e}", style="red")
223
- display_text = self._format_display_text(content, reasoning) + " [Display Error]"
224
- live.update(Markdown(markup=display_text, code_theme=self.code_theme))
225
278
  if self.verbose:
226
279
  traceback.print_exc()
227
280
  return None, None
228
281
 
229
- def display_normal(self, content: Optional[str], reasoning: Optional[str] = None) -> None:
282
+ def display_normal(
283
+ self, content: Optional[str], reasoning: Optional[str] = None, with_assistant_prefix: bool = True
284
+ ) -> None:
230
285
  """Display a complete, non-streamed response.
231
286
 
232
287
  Args:
233
288
  content (Optional[str]): The main content to display.
234
289
  reasoning (Optional[str]): The reasoning content to display.
290
+ with_assistant_prefix (bool): Whether to display the "Assistant:" prefix.
235
291
  """
236
- self.console.print("Assistant:", style="bold green")
237
-
292
+ if with_assistant_prefix:
293
+ self.console.print("Assistant:", style="bold green")
238
294
  if content or reasoning:
239
295
  # Use the existing _format_display_text method
240
- display_text = self._format_display_text(content or "", reasoning or "")
241
- self.console.print(Markdown(display_text, code_theme=self.code_theme))
296
+ formatted_display = self._format_display_text(content or "", reasoning or "")
297
+ self.console.print(formatted_display)
242
298
  self.console.print() # Add a newline for spacing
243
299
  else:
244
300
  self.console.print("Assistant did not provide any content.", style="yellow")
yaicli/render.py ADDED
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+
3
+ from rich.markdown import Markdown
4
+
5
+ from yaicli.config import cfg
6
+
7
+
8
+ class JustifyMarkdown(Markdown):
9
+ """Custom Markdown class that defaults to the configured justify value."""
10
+
11
+ def __init__(self, *args, **kwargs):
12
+ if "justify" not in kwargs:
13
+ kwargs["justify"] = cfg["JUSTIFY"]
14
+ super().__init__(*args, **kwargs)
15
+
16
+
17
+ def plain_formatter(text: str, **kwargs: Any) -> str:
18
+ """Format the text for display, without Markdown formatting."""
19
+ return text
yaicli/roles.py ADDED
@@ -0,0 +1,248 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional, Union
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.prompt import Prompt
8
+ from rich.table import Table
9
+
10
+ from yaicli.config import cfg
11
+ from yaicli.console import get_console
12
+ from yaicli.const import DEFAULT_ROLES, ROLES_DIR, DefaultRoleNames
13
+ from yaicli.exceptions import RoleAlreadyExistsError, RoleCreationError
14
+ from yaicli.utils import detect_os, detect_shell, option_callback
15
+
16
+
17
+ class Role:
18
+ def __init__(
19
+ self, name: str, prompt: str, variables: Optional[Dict[str, Any]] = None, filepath: Optional[str] = None
20
+ ):
21
+ self.name = name
22
+ self.prompt = prompt
23
+ if not variables:
24
+ variables = {"_os": detect_os(cfg), "_shell": detect_shell(cfg)}
25
+ self.variables = variables
26
+ self.filepath = filepath
27
+
28
+ self.prompt = self.prompt.format(**self.variables)
29
+
30
+ def to_dict(self) -> Dict[str, Any]:
31
+ """Convert Role to dictionary for serialization"""
32
+ return {
33
+ "name": self.name,
34
+ "prompt": self.prompt,
35
+ }
36
+
37
+ @classmethod
38
+ def from_dict(cls, role_id: str, data: Dict[str, Any], filepath: Optional[str] = None) -> "Role":
39
+ """Create Role object from dictionary"""
40
+ return cls(
41
+ name=data.get("name", role_id),
42
+ prompt=data.get("prompt", ""),
43
+ variables=data.get("variables", {}),
44
+ filepath=filepath,
45
+ )
46
+
47
+ def __str__(self):
48
+ return f"Role(name={self.name}, prompt={self.prompt[:30]}...)"
49
+
50
+
51
+ class RoleManager:
52
+ roles_dir: Path = ROLES_DIR
53
+ console: Console = get_console()
54
+
55
+ def __init__(self):
56
+ self.roles: Dict[str, Role] = self._load_roles()
57
+
58
+ def _load_roles(self) -> Dict[str, Role]:
59
+ """Load all role configurations"""
60
+ roles = {}
61
+ self.roles_dir.mkdir(parents=True, exist_ok=True)
62
+
63
+ # Check if any role files exist
64
+ role_files: list[Path] = list(self.roles_dir.glob("*.json"))
65
+
66
+ if not role_files:
67
+ # Fast path: no existing roles, just create defaults
68
+ for role_id, role_config in DEFAULT_ROLES.items():
69
+ role_file = self.roles_dir / f"{role_id}.json"
70
+ filepath = str(role_file)
71
+ roles[role_id] = Role.from_dict(role_id, role_config, filepath)
72
+ with role_file.open("w", encoding="utf-8") as f:
73
+ json.dump(role_config, f, indent=2)
74
+ return roles
75
+
76
+ # Load existing role files
77
+ for role_file in role_files:
78
+ role_id = role_file.stem
79
+ filepath = str(role_file)
80
+ try:
81
+ with role_file.open("r", encoding="utf-8") as f:
82
+ role_data = json.load(f)
83
+ roles[role_id] = Role.from_dict(role_id, role_data, filepath)
84
+ except Exception as e:
85
+ self.console.print(f"Error loading role {role_id}: {e}", style="red")
86
+
87
+ # Ensure default roles exist
88
+ for role_id, role_config in DEFAULT_ROLES.items():
89
+ if role_id not in roles:
90
+ role_file = self.roles_dir / f"{role_id}.json"
91
+ filepath = str(role_file)
92
+ roles[role_id] = Role.from_dict(role_id, role_config, filepath)
93
+ with role_file.open("w", encoding="utf-8") as f:
94
+ json.dump(role_config, f, indent=2)
95
+
96
+ return roles
97
+
98
+ @classmethod
99
+ @option_callback
100
+ def print_list_option(cls, _: Any):
101
+ """Print the list of roles.
102
+ This method is a cli option callback.
103
+ """
104
+ table = Table(show_header=True, show_footer=False)
105
+ table.add_column("Name", style="dim")
106
+ table.add_column("Filepath", style="dim")
107
+ for file in sorted(cls.roles_dir.glob("*.json"), key=lambda f: f.stat().st_mtime):
108
+ table.add_row(file.stem, str(file))
109
+ cls.console.print(table)
110
+ cls.console.print("Use `ai --show-role <name>` to view a role.", style="dim")
111
+
112
+ def list_roles(self) -> list:
113
+ """List all available roles info"""
114
+ roles_list = []
115
+ for role_id, role in sorted(self.roles.items()):
116
+ roles_list.append(
117
+ {
118
+ "id": role_id,
119
+ "name": role.name,
120
+ "prompt": role.prompt,
121
+ "is_default": role_id in DEFAULT_ROLES,
122
+ "filepath": role.filepath,
123
+ }
124
+ )
125
+ return roles_list
126
+
127
+ @classmethod
128
+ @option_callback
129
+ def show_role_option(cls, name: str):
130
+ """Show a role's prompt.
131
+ This method is a cli option callback.
132
+ """
133
+ self = cls()
134
+ role = self.get_role(name)
135
+ if not role:
136
+ self.console.print(f"Role '{name}' does not exist", style="red")
137
+ return
138
+ self.console.print(role.prompt)
139
+
140
+ def get_role(self, role_id: str) -> Optional[Role]:
141
+ """Get role by ID"""
142
+ return self.roles.get(role_id)
143
+
144
+ @classmethod
145
+ def check_id_ok(cls, role_id: str):
146
+ """Check if role exists by ID.
147
+ This method is a cli option callback.
148
+ If role does not exist, exit with error.
149
+ """
150
+ if not role_id:
151
+ return role_id
152
+ self = cls()
153
+ if not self.role_exists(role_id):
154
+ self.console.print(f"Role '{role_id}' does not exist", style="red")
155
+ raise typer.Abort()
156
+ return role_id
157
+
158
+ def role_exists(self, role_id: str) -> bool:
159
+ """Check if role exists"""
160
+ return role_id in self.roles
161
+
162
+ def save_role(self, role_id: str, role: Role) -> None:
163
+ """Save role configuration"""
164
+ try:
165
+ self.roles[role_id] = role
166
+ role_file = self.roles_dir / f"{role_id}.json"
167
+ role.filepath = str(role_file)
168
+ with role_file.open("w", encoding="utf-8") as f:
169
+ json.dump(role.to_dict(), f, indent=2)
170
+ except Exception as e:
171
+ raise RoleCreationError(f"RoleCreationError {e}") from e
172
+
173
+ @classmethod
174
+ @option_callback
175
+ def create_role_option(cls, name: str):
176
+ """Create a new role and save it to file.
177
+ This method is a cli option callback.
178
+ """
179
+ self = cls()
180
+ if name in self.roles:
181
+ self.console.print(f"Role '{name}' already exists", style="yellow")
182
+ return
183
+ description = Prompt.ask("Enter role description")
184
+
185
+ # Format the prompt as "You are {role_id}, {description}"
186
+ prompt = f"You are {name}, {description}"
187
+
188
+ role = Role(name=name, prompt=prompt)
189
+ self.create_role(name, role)
190
+ self.console.print(f"Role '{name}' created successfully", style="green")
191
+
192
+ def create_role(self, role_id: str, role: Union[Role, Dict[str, Any]]) -> None:
193
+ """Create a new role and save it to file"""
194
+ if role_id in self.roles:
195
+ raise RoleAlreadyExistsError(f"Role '{role_id}' already exists")
196
+ if isinstance(role, dict):
197
+ if "name" not in role or "prompt" not in role:
198
+ raise RoleCreationError("Role must have 'name' and 'prompt' keys")
199
+ # Convert dict to Role object
200
+ role = Role.from_dict(role_id, role)
201
+ self.save_role(role_id, role)
202
+
203
+ @classmethod
204
+ @option_callback
205
+ def delete_role_option(cls, name: str):
206
+ """Delete a role and its file.
207
+ This method is a cli option callback.
208
+ """
209
+ self = cls()
210
+ if self.delete_role(name):
211
+ self.console.print(f"Role '{name}' deleted successfully", style="green")
212
+
213
+ def delete_role(self, role_id: str) -> bool:
214
+ """Delete a role and its file"""
215
+ if role_id not in self.roles:
216
+ self.console.print(f"Role '{role_id}' does not exist", style="red")
217
+ return False
218
+
219
+ # Don't allow deleting default roles
220
+ if role_id in DEFAULT_ROLES:
221
+ self.console.print(f"Cannot delete default role: '{role_id}'", style="red")
222
+ return False
223
+
224
+ try:
225
+ role = self.roles[role_id]
226
+ if role.filepath:
227
+ Path(role.filepath).unlink(missing_ok=True)
228
+ del self.roles[role_id]
229
+ return True
230
+ except Exception as e:
231
+ self.console.print(f"Error deleting role: {e}", style="red")
232
+ return False
233
+
234
+ def get_system_prompt(self, role_id: str) -> str:
235
+ """Get prompt from file by role ID"""
236
+ role = self.get_role(role_id)
237
+ if not role:
238
+ # Fall back to default role if specified role doesn't exist
239
+ self.console.print(f"Role {role_id} not found, using default role", style="yellow")
240
+ role = self.get_role(DefaultRoleNames.DEFAULT)
241
+ if not role:
242
+ # Last resort fallback
243
+ default_config = DEFAULT_ROLES[DefaultRoleNames.DEFAULT]
244
+ role = Role.from_dict(DefaultRoleNames.DEFAULT, default_config)
245
+
246
+ # Create a copy of the role with system variables
247
+ system_role = Role(name=role.name, prompt=role.prompt)
248
+ return system_role.prompt
yaicli/utils.py CHANGED
@@ -1,13 +1,32 @@
1
1
  import platform
2
2
  from os import getenv
3
3
  from os.path import basename, pathsep
4
- from typing import Any, Optional, TypeVar
4
+ from typing import Any, Callable, Optional, TypeVar
5
5
 
6
+ import typer
6
7
  from distro import name as distro_name
7
8
 
8
9
  from yaicli.const import DEFAULT_OS_NAME, DEFAULT_SHELL_NAME
9
10
 
10
- T = TypeVar("T", int, float, str)
11
+ T = TypeVar("T", int, float, str, bool)
12
+
13
+
14
+ def option_callback(func: Callable) -> Callable: # pragma: no cover
15
+ """
16
+ A decorator for Typer option callbacks that ensures the application exits
17
+ after the callback function is executed.
18
+
19
+ Args:
20
+ func (Callable): The callback classmethod to wrap.
21
+ """
22
+
23
+ def wrapper(cls, value: T) -> T:
24
+ if not value:
25
+ return value
26
+ func(cls, value)
27
+ raise typer.Exit()
28
+
29
+ return wrapper
11
30
 
12
31
 
13
32
  def detect_os(config: dict[str, Any]) -> str: