tass 0.1.9__tar.gz → 0.1.10__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.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tass
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: A terminal assistant that allows you to ask an LLM to run commands.
5
5
  Project-URL: Homepage, https://github.com/cetincan0/tass
6
6
  Author: Can Cetin
7
7
  License: MIT
8
8
  License-File: LICENSE
9
9
  Requires-Python: >=3.10
10
+ Requires-Dist: prompt-toolkit>=3.0.52
10
11
  Requires-Dist: requests>=2.32.5
11
12
  Requires-Dist: rich>=14.2.0
12
13
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tass"
3
- version = "0.1.9"
3
+ version = "0.1.10"
4
4
  description = "A terminal assistant that allows you to ask an LLM to run commands."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -8,6 +8,7 @@ license = {text = "MIT"}
8
8
  authors = [{name = "Can Cetin"}]
9
9
 
10
10
  dependencies = [
11
+ "prompt-toolkit>=3.0.52",
11
12
  "requests>=2.32.5",
12
13
  "rich>=14.2.0",
13
14
  ]
@@ -4,7 +4,7 @@ import subprocess
4
4
  from pathlib import Path
5
5
 
6
6
  import requests
7
-
7
+ from prompt_toolkit import prompt
8
8
  from rich.console import Console, Group
9
9
  from rich.live import Live
10
10
  from rich.markdown import Markdown
@@ -15,7 +15,11 @@ from src.constants import (
15
15
  SYSTEM_PROMPT,
16
16
  TOOLS,
17
17
  )
18
- from src.utils import is_read_only_command
18
+ from src.utils import (
19
+ FileCompleter,
20
+ create_key_bindings,
21
+ is_read_only_command,
22
+ )
19
23
 
20
24
  console = Console()
21
25
 
@@ -25,6 +29,8 @@ class TassApp:
25
29
  def __init__(self):
26
30
  self.messages: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
27
31
  self.host = os.environ.get("TASS_HOST", "http://localhost:8080")
32
+ self.key_bindings = create_key_bindings()
33
+ self.file_completer = FileCompleter()
28
34
  self.TOOLS_MAP = {
29
35
  "execute": self.execute,
30
36
  "read_file": self.read_file,
@@ -193,9 +199,9 @@ class TassApp:
193
199
  self.messages.append(
194
200
  {
195
201
  "role": "assistant",
196
- "content": content.strip() or None,
197
- "reasoning_content": reasoning_content.strip() or None,
198
- "tool_calls": list(tool_calls_map.values()) or None,
202
+ "content": content.strip(),
203
+ "reasoning_content": reasoning_content.strip(),
204
+ "tool_calls": list(tool_calls_map.values()) or [],
199
205
  }
200
206
  )
201
207
 
@@ -220,11 +226,12 @@ class TassApp:
220
226
  self.messages.append({"role": "user", "content": str(e)})
221
227
  return self.call_llm()
222
228
 
223
- def read_file(self, path: str, start: int = 1) -> str:
224
- if start == 1:
229
+ def read_file(self, path: str, start: int = 1, num_lines: int = 1000) -> str:
230
+ if start == 1 and num_lines == 1000:
225
231
  console.print(f" └ Reading file [bold]{path}[/]...")
226
232
  else:
227
- console.print(f" Reading file [bold]{path}[/] (from line {start})...")
233
+ last_line = start + num_lines - 1
234
+ console.print(f" └ Reading file [bold]{path}[/] (lines {start}-{last_line})...")
228
235
 
229
236
  try:
230
237
  result = subprocess.run(
@@ -256,14 +263,15 @@ class TassApp:
256
263
  lines.append(line)
257
264
  line_num += 1
258
265
 
259
- if len(lines) >= 1000:
266
+ if len(lines) >= num_lines:
260
267
  lines.append("... (truncated)")
261
268
  break
262
269
 
263
270
  console.print(" [green]Command succeeded[/green]")
264
- return "".join(lines)
271
+ return "\n".join(lines)
265
272
 
266
273
  def edit_file(self, path: str, edits: list[dict]) -> str:
274
+ console.print(json.dumps(edits, indent=2))
267
275
  for edit in edits:
268
276
  edit["applied"] = False
269
277
 
@@ -294,8 +302,9 @@ class TassApp:
294
302
  if edit["applied"]:
295
303
  continue
296
304
 
297
- replace_lines = edit["replace"].split("\n")
298
- final_lines.extend(replace_lines)
305
+ replace_lines = edit["content"].split("\n")
306
+ if edit["content"]:
307
+ final_lines.extend(replace_lines)
299
308
  original_lines = original_content.split("\n")
300
309
  replaced_lines = original_lines[edit["line_start"] - 1:edit["line_end"]]
301
310
 
@@ -303,8 +312,10 @@ class TassApp:
303
312
  line_before = "" if i == 0 else f" {original_lines[i - 1]}\n"
304
313
  line_after = "" if edit["line_end"] == len(original_lines) else f"\n {original_lines[edit['line_end']]}"
305
314
  replaced_with_minuses = "\n".join([f"-{line}" for line in replaced_lines]) if file_exists else ""
306
- replace_with_pluses = "\n".join([f"+{line}" for line in edit["replace"].split("\n")])
307
- diff_text = f"{diff_text}\n\n@@ -{prev_line_num},{len(replaced_lines)} +{prev_line_num},{len(replace_lines)} @@\n{line_before}{replaced_with_minuses}\n{replace_with_pluses}{line_after}"
315
+ replaced_with_pluses = ""
316
+ if edit["content"]:
317
+ replaced_with_pluses = "\n" + "\n".join([f"+{line}" for line in edit["content"].split("\n")])
318
+ diff_text = f"{diff_text}\n\n@@ -{prev_line_num},{len(replaced_lines)} +{prev_line_num},{len(replace_lines)} @@\n{line_before}{replaced_with_minuses}{replaced_with_pluses}{line_after}"
308
319
  edit["applied"] = True
309
320
 
310
321
  console.print()
@@ -393,12 +404,16 @@ class TassApp:
393
404
  try:
394
405
  input_lines = []
395
406
  while True:
396
- input_line = console.input("> ")
407
+ input_line = prompt(
408
+ "> ",
409
+ completer=self.file_completer,
410
+ complete_while_typing=True,
411
+ key_bindings=self.key_bindings,
412
+ )
397
413
  if not input_line or input_line[-1] != "\\":
398
414
  input_lines.append(input_line)
399
415
  break
400
416
  input_lines.append(input_line[:-1])
401
-
402
417
  user_input = "\n".join(input_lines)
403
418
  except KeyboardInterrupt:
404
419
  console.print("\nBye!")
@@ -1,6 +1,6 @@
1
1
  from pathlib import Path
2
2
 
3
- _cwd_path = Path.cwd().resolve()
3
+ cwd_path = Path.cwd().resolve()
4
4
 
5
5
  SYSTEM_PROMPT = f"""You are tass, or Terminal Assistant, a helpful AI that executes shell commands based on natural-language requests.
6
6
 
@@ -8,7 +8,7 @@ If the user's request involves making changes to the filesystem such as creating
8
8
 
9
9
  If a user asks for an answer or explanation to something instead of requesting to run a command, answer briefly and concisely. Do not supply extra information, suggestions, tips, or anything of the sort.
10
10
 
11
- Current working directory: {_cwd_path}"""
11
+ Current working directory: {cwd_path}"""
12
12
 
13
13
  TOOLS = [
14
14
  {
@@ -37,7 +37,7 @@ TOOLS = [
37
37
  "type": "function",
38
38
  "function": {
39
39
  "name": "edit_file",
40
- "description": "Edits (or creates) a file. Makes multiple replacements in one call. Each edit removes the contents between 'line_start' and 'line_end' inclusive and replaces it with 'replace'. If creating a file, only return a single edit where the line_start and line_end are both 1 and replace is the entire contents of the file.",
40
+ "description": "Edits (or creates) a file. Can make multiple edits in one call. Each edit replaces the contents between 'line_start' and 'line_end' inclusive with 'content'. If creating a file, only return a single edit where 'line_start' and 'line_end' are both 1 and 'content' is the entire contents of the file. You must use the read_file tool before editing a file.",
41
41
  "parameters": {
42
42
  "type": "object",
43
43
  "properties": {
@@ -47,7 +47,7 @@ TOOLS = [
47
47
  },
48
48
  "edits": {
49
49
  "type": "array",
50
- "description": "List of edits to apply. Each edit must contain 'line_start', 'line_end', and 'replace'.",
50
+ "description": "List of edits to apply. Each edit must contain 'line_start', 'line_end', and 'content'.",
51
51
  "items": {
52
52
  "type": "object",
53
53
  "properties": {
@@ -59,12 +59,12 @@ TOOLS = [
59
59
  "type": "integer",
60
60
  "description": "The last line to remove (inclusive)",
61
61
  },
62
- "replace": {
62
+ "content": {
63
63
  "type": "string",
64
- "description": "The string to replace with. Must have the correct spacing and indentation for all lines.",
64
+ "description": "The content to replace with. Must have the correct spacing and indentation for all lines.",
65
65
  },
66
66
  },
67
- "required": ["line_start", "line_end", "replace"],
67
+ "required": ["line_start", "line_end", "content"],
68
68
  },
69
69
  },
70
70
  },
@@ -77,7 +77,7 @@ TOOLS = [
77
77
  "type": "function",
78
78
  "function": {
79
79
  "name": "read_file",
80
- "description": "Read a file's contents (up to 1000 lines). The output will be identical to calling `cat -n <path>` with preceding spaces, line number and a tab.",
80
+ "description": "Read a file's contents (the first 1000 lines by default). When reading a file for the first time, do not change the defaults and always read the first 1000 lines unless you are absolutely certain of which lines need to be read. The output will be identical to calling `cat -n <path>` with preceding spaces, line number and a tab.",
81
81
  "parameters": {
82
82
  "type": "object",
83
83
  "properties": {
@@ -90,6 +90,11 @@ TOOLS = [
90
90
  "description": "Which line to start reading from",
91
91
  "default": 1,
92
92
  },
93
+ "num_lines": {
94
+ "type": "integer",
95
+ "description": "Number of lines to read, defaults to 1000",
96
+ "default": 1000,
97
+ },
93
98
  },
94
99
  "required": ["path"],
95
100
  "$schema": "http://json-schema.org/draft-07/schema#",
@@ -0,0 +1,98 @@
1
+ from prompt_toolkit.completion import (
2
+ Completer,
3
+ Completion,
4
+ CompleteEvent,
5
+ )
6
+ from prompt_toolkit.document import Document
7
+ from prompt_toolkit.key_binding import KeyBindings
8
+ from prompt_toolkit.keys import Keys
9
+
10
+ from src.constants import (
11
+ READ_ONLY_COMMANDS,
12
+ cwd_path,
13
+ )
14
+
15
+ def is_read_only_command(command: str) -> bool:
16
+ """A simple check to see if the command is only for reading files.
17
+
18
+ Not a comprehensive or foolproof check by any means, and will
19
+ return false negatives to be safe.
20
+ """
21
+ if ">" in command:
22
+ return False
23
+
24
+ # Replace everything that potentially runs another command with a pipe
25
+ command = command.replace("&&", "|")
26
+ command = command.replace("||", "|")
27
+ command = command.replace(";", "|")
28
+
29
+ pipes = command.split("|")
30
+ for pipe in pipes:
31
+ if pipe.strip().split()[0] not in READ_ONLY_COMMANDS:
32
+ return False
33
+
34
+ return True
35
+
36
+
37
+ class FileCompleter(Completer):
38
+
39
+ def get_completions(self, document: Document, complete_event: CompleteEvent) -> list[Completion]:
40
+ """Return file completions for text after @ anywhere in input."""
41
+ text = document.text
42
+ cursor_pos = document.cursor_position
43
+ text_before_cursor = document.text_before_cursor
44
+ last_at_pos = text_before_cursor.rfind("@")
45
+ if last_at_pos == -1:
46
+ return []
47
+
48
+ text_after_at = text[last_at_pos + 1 : cursor_pos]
49
+
50
+ base_dir = cwd_path
51
+ search_pattern = text_after_at
52
+ prefix = ""
53
+
54
+ if "/" in text_after_at:
55
+ dir_part, file_part = text_after_at.rsplit("/", 1)
56
+ base_dir = cwd_path / dir_part
57
+ search_pattern = file_part
58
+ prefix = dir_part + "/"
59
+
60
+ if not base_dir.is_dir():
61
+ return []
62
+
63
+ try:
64
+ dirs = []
65
+ files = []
66
+ for entry in sorted(base_dir.iterdir()):
67
+ name = entry.name
68
+ if search_pattern in name:
69
+ full_path = prefix + name
70
+ if entry.is_dir():
71
+ dirs.append(Completion(full_path, display=name, start_position=-len(text_after_at), style="Blue"))
72
+ else:
73
+ files.append(Completion(full_path, display=name, start_position=-len(text_after_at)))
74
+
75
+ return dirs + files
76
+ except (PermissionError, OSError):
77
+ return []
78
+
79
+
80
+ def create_key_bindings() -> KeyBindings:
81
+ bindings = KeyBindings()
82
+
83
+ @bindings.add(Keys.Backspace)
84
+ def backspace_with_completion(event):
85
+ """Also trigger completion on backspace."""
86
+ buffer = event.current_buffer
87
+ buffer.delete_before_cursor(count=1)
88
+ text_before_cursor = buffer.document.text_before_cursor
89
+ last_at_pos = text_before_cursor.rfind("@")
90
+ if last_at_pos != -1:
91
+ buffer.start_completion()
92
+
93
+ @bindings.add(Keys.ControlC)
94
+ def ctrl_c_handler(event):
95
+ """Ctrl+C should raise KeyboardInterrupt."""
96
+ raise KeyboardInterrupt()
97
+
98
+ return bindings
@@ -178,6 +178,18 @@ wheels = [
178
178
  { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
179
179
  ]
180
180
 
181
+ [[package]]
182
+ name = "prompt-toolkit"
183
+ version = "3.0.52"
184
+ source = { registry = "https://pypi.org/simple" }
185
+ dependencies = [
186
+ { name = "wcwidth" },
187
+ ]
188
+ sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
189
+ wheels = [
190
+ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
191
+ ]
192
+
181
193
  [[package]]
182
194
  name = "pygments"
183
195
  version = "2.19.2"
@@ -261,9 +273,10 @@ wheels = [
261
273
 
262
274
  [[package]]
263
275
  name = "tass"
264
- version = "0.1.9"
276
+ version = "0.1.10"
265
277
  source = { editable = "." }
266
278
  dependencies = [
279
+ { name = "prompt-toolkit" },
267
280
  { name = "requests" },
268
281
  { name = "rich" },
269
282
  ]
@@ -276,6 +289,7 @@ dev = [
276
289
 
277
290
  [package.metadata]
278
291
  requires-dist = [
292
+ { name = "prompt-toolkit", specifier = ">=3.0.52" },
279
293
  { name = "requests", specifier = ">=2.32.5" },
280
294
  { name = "rich", specifier = ">=14.2.0" },
281
295
  ]
@@ -352,3 +366,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787
352
366
  wheels = [
353
367
  { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
354
368
  ]
369
+
370
+ [[package]]
371
+ name = "wcwidth"
372
+ version = "0.2.14"
373
+ source = { registry = "https://pypi.org/simple" }
374
+ sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
375
+ wheels = [
376
+ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
377
+ ]
tass-0.1.9/src/utils.py DELETED
@@ -1,23 +0,0 @@
1
- from src.constants import READ_ONLY_COMMANDS
2
-
3
-
4
- def is_read_only_command(command: str) -> bool:
5
- """A simple check to see if the command is only for reading files.
6
-
7
- Not a comprehensive or foolproof check by any means, and will
8
- return false negatives to be safe.
9
- """
10
- if ">" in command:
11
- return False
12
-
13
- # Replace everything that potentially runs another command with a pipe
14
- command = command.replace("&&", "|")
15
- command = command.replace("||", "|")
16
- command = command.replace(";", "|")
17
-
18
- pipes = command.split("|")
19
- for pipe in pipes:
20
- if pipe.strip().split()[0] not in READ_ONLY_COMMANDS:
21
- return False
22
-
23
- return True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes