gitpr-cli 0.0.25__py3-none-any.whl → 0.0.26__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.
- {gitpr_cli-0.0.25.dist-info → gitpr_cli-0.0.26.dist-info}/METADATA +34 -16
- gitpr_cli-0.0.26.dist-info/RECORD +25 -0
- src/ai_providers.py +119 -2
- src/chat_memory.py +218 -0
- src/core.py +15 -13
- src/i18n.py +13 -0
- src/main.py +75 -1
- src/spinner.py +15 -0
- src/ui/chat_app.py +587 -0
- src/updater.py +2 -2
- gitpr_cli-0.0.25.dist-info/RECORD +0 -23
- {gitpr_cli-0.0.25.dist-info → gitpr_cli-0.0.26.dist-info}/WHEEL +0 -0
- {gitpr_cli-0.0.25.dist-info → gitpr_cli-0.0.26.dist-info}/entry_points.txt +0 -0
- {gitpr_cli-0.0.25.dist-info → gitpr_cli-0.0.26.dist-info}/licenses/LICENSE +0 -0
- {gitpr_cli-0.0.25.dist-info → gitpr_cli-0.0.26.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitpr-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.26
|
|
4
4
|
Summary: Automação de PRs, Commits e Code Review com IA (Gemini e DeepSeek)
|
|
5
5
|
Author-email: Natan Fiuza <contato@natanfiuza.dev.br>
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -126,7 +126,9 @@ You can pass the following *flags* for specific actions:
|
|
|
126
126
|
* `-r` or `--review`: Performs a detailed **Code Review** of local changes.
|
|
127
127
|
* `-f` or `--fullreview`: Performs a **Full Code Review** analyzing all changes since the remote branch.
|
|
128
128
|
* `-i <file>` or `--input <file>`: **Full File Audit.** Must be used together with `-r` or `-f`; it ignores git history and does a Code Review of the entire file. Excellent for acting as a consultant on legacy code refactoring.
|
|
129
|
-
* `--provider <gemini|deepseek>`: Forces the use of a specific AI only for this execution, ignoring your default saved in `.env`.
|
|
129
|
+
* `--provider <gemini|deepseek|ollama>`: Forces the use of a specific AI only for this execution, ignoring your default saved in `.env`.
|
|
130
|
+
* `--lang <code>`: Forces the interface language for this execution (e.g.: `en_us`, `pt_br`). Overrides `GITPR_LANG` in `.env` without persisting the change.
|
|
131
|
+
* `-ch` or `--chat`: Opens the **Interactive Pair Programming Chat** — a TUI terminal where the AI sees your current diff and maintains a contextual conversation. Features memory per branch, slash commands (`/explain`, `/tests`, `/optimize`, `/clear`), auto-patching (F5), diff refresh (F2), and session export (F6).
|
|
130
132
|
* `-l` or `--linter`: Runs **only the local static linter** (no AI calls). Ideal for use in CI/CD pipelines to block non-compliant code.
|
|
131
133
|
* `-ih` or `--installhooks`: Automatically installs **local Git Hooks** (`pre-commit` and `prepare-commit-msg`) in your repository.
|
|
132
134
|
* `-s` or `--skill`: Creates the AI context template files (`.gitpr.commit.md`, `.gitpr.pr.md`, `.gitpr.review.md`, `.gitpr.filereview.md`, `.gitpr.issue.md`, `.gitpr.blame.md`) and the Linter (`.gitpr.linter.yml`) at the project root.
|
|
@@ -165,6 +167,7 @@ The Linter analyzes only the **added lines** in your `git diff`, ensuring a focu
|
|
|
165
167
|
GitPR is not tied to a single Artificial Intelligence. During initial setup, the user can choose their default engine. We currently support:
|
|
166
168
|
* **Google Gemini** (Default: `gemini-2.5-flash`)
|
|
167
169
|
* **DeepSeek** (Default: `deepseek-chat`)
|
|
170
|
+
* **Ollama** (Local) — run models locally without internet, fully compatible with the OpenAI API format
|
|
168
171
|
|
|
169
172
|
You can dynamically switch models by configuring the `GEMINI_API_MODEL` or `DEEPSEEK_API_MODEL` variables in your `~/.gitpr/.env` file, or switch in real-time using the `--provider` flag.
|
|
170
173
|
|
|
@@ -197,20 +200,35 @@ To force a specific language, set `GITPR_LANG=pt_br` or `GITPR_LANG=en` in `~/.g
|
|
|
197
200
|
|
|
198
201
|
To keep this README concise, we detail the most advanced **DevOps** and **Continuous Integration** focused implementations in separate documents.
|
|
199
202
|
|
|
200
|
-
If you want to implement GitPR as an automated quality barrier in your team, check out the guides below
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
* [
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
* [**
|
|
211
|
-
* [**
|
|
212
|
-
* [**CI/CD Integration (GitHub Actions)**](docs/github-ci-linter.md)
|
|
213
|
-
|
|
203
|
+
If you want to implement GitPR as an automated quality barrier in your team, check out the guides below.
|
|
204
|
+
|
|
205
|
+
> 🌐 Each guide is available in **5 languages** — add `.pt_br`, `.pt_pt`, `.fr_fr`, or `.es_es` before the `.md` extension for translated versions (e.g., `docs/understanding_chat_functionality.pt_br.md`). English is the default with no suffix.
|
|
206
|
+
|
|
207
|
+
### Chat & Interactive Features
|
|
208
|
+
|
|
209
|
+
* [**🧠 Interactive Chat (Pair Programming)**](https://github.com/natanfiuza/gitpr/blob/main/docs/understanding_chat_functionality.md) — How to use the AI chat with memory, slash commands, auto-patch, and session export.
|
|
210
|
+
|
|
211
|
+
### DevOps & CI/CD
|
|
212
|
+
|
|
213
|
+
* [**Local Git Hooks (Shift-Left)**](https://github.com/natanfiuza/gitpr/blob/main/docs/git-hooks-locais.md) — How to use `gitpr --installhooks` to create guardrails on the developer's machine and use AI to automatically write commit messages.
|
|
214
|
+
* [**Customizable Static Linter**](https://github.com/natanfiuza/gitpr/blob/main/docs/linter-regras-customizadas.md) — How to create validation rules in `.gitpr.linter.yml` for CI/CD and pre-commit hooks.
|
|
215
|
+
* [**CI/CD Integration (GitHub Actions)**](https://github.com/natanfiuza/gitpr/blob/main/docs/github-ci-linter.md) — How to run GitPR in the pipeline to block "Merge" of PRs with violations.
|
|
216
|
+
|
|
217
|
+
### Core Features
|
|
218
|
+
|
|
219
|
+
* [**Pull Request (Default Mode)**](https://github.com/natanfiuza/gitpr/blob/main/docs/pr-descricao-padrao.md) — Complete flow for generating PR descriptions without flags.
|
|
220
|
+
* [**AI Code Review**](https://github.com/natanfiuza/gitpr/blob/main/docs/code-review-ia.md) — Guide to review modes (`--review`, `--fullreview`) and file auditing (`--input`).
|
|
221
|
+
* [**AI Commit Messages**](https://github.com/natanfiuza/gitpr/blob/main/docs/commit-message-ia.md) — How to generate messages in the Conventional Commits standard and integrate with Git Hooks.
|
|
222
|
+
* [**Issue Generation and TUI Interface**](https://github.com/natanfiuza/gitpr/blob/main/docs/issue-tui-help.md) — How to use the terminal graphical interface (TUI) and the 3 context engines to manage structured Issues.
|
|
223
|
+
* [**Code Archaeologist (Git Blame)**](https://github.com/natanfiuza/gitpr/blob/main/docs/blame-arqueologo.md) — How to trace the origin of business rules with `git blame` and AI.
|
|
224
|
+
* [**Skills and Templates System**](https://github.com/natanfiuza/gitpr/blob/main/docs/skill-template.md) — How to customize AI behavior with `.gitpr.*.md` files.
|
|
225
|
+
|
|
226
|
+
### Configuration & Infrastructure
|
|
227
|
+
|
|
228
|
+
* [**AI Providers**](https://github.com/natanfiuza/gitpr/blob/main/docs/providers-ia.md) — Configuration and selection between Google Gemini, DeepSeek, and Ollama.
|
|
229
|
+
* [**Auto-Updater**](https://github.com/natanfiuza/gitpr/blob/main/docs/auto-update.md) — How GitPR's automatic update (hot-swap) works.
|
|
230
|
+
* [**GitHub Token (PAT) Integration and Security**](https://github.com/natanfiuza/gitpr/blob/main/docs/github-pat-integration.md) — Understand how GitPR creates issues directly in the repository with authentication.
|
|
231
|
+
* [**Internationalization (i18n)**](https://github.com/natanfiuza/gitpr/blob/main/docs/i18n_explanation.md) — Architecture, usage patterns, and how to add new languages.
|
|
214
232
|
|
|
215
233
|
## ⚡ Local Cache System (Quota Savings)
|
|
216
234
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
gitpr_cli-0.0.26.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
|
|
2
|
+
src/__init__.py,sha256=U6ksarernNcryHO20aj4rNG7w0thT7xdMs5KzBJyPvE,28
|
|
3
|
+
src/ai_providers.py,sha256=gjWhl38AAVtCVDd4E_YOBlC_gKvtRBSoOj0b_kJ6N1I,8316
|
|
4
|
+
src/blame_engine.py,sha256=Ci7t08xTcC1kBWAjQ_ulP-Zraau_PC35MDfAngc4AYk,10396
|
|
5
|
+
src/cache.py,sha256=XggMTy00GlZfeF1VoLEYCdxL5h0eTg11TJfPIRPx7fs,3201
|
|
6
|
+
src/chat_memory.py,sha256=vdYuElIQb6-flFm4SfzapnQZGRIwgbLECzIcsAc0Oo8,8724
|
|
7
|
+
src/config.py,sha256=VJ9ekDlTVG5ETWDuO1kZWlgrncXbrvduxa5Zg7wN_ZY,9064
|
|
8
|
+
src/core.py,sha256=TGtSA_7wyNoa-B6ytFhunCpEmpW9miQzvlizUQcyhis,18259
|
|
9
|
+
src/i18n.py,sha256=QED7hTSdWCC1lSNJ-l6VuOba18OrWgFTMQcZ7cQmXbY,3599
|
|
10
|
+
src/issue_engine.py,sha256=435kPPgdsFMcY3nvutxdIfJAnLP0U4FMCPdbok6C2sc,3626
|
|
11
|
+
src/linter_engine.py,sha256=D04wvOgRa-vZEYknm-z-Cg7V_3gnoQz_64PMorWnISo,4414
|
|
12
|
+
src/main.py,sha256=QryicYBsYiRaNCUTs9eJbM-x-S3Z4-jWvjGR3SwkmJ8,31412
|
|
13
|
+
src/security.py,sha256=Li0zM8ZURTK2nKRHIt49TWLO4DIddBEHzXPrVycv0Rs,1343
|
|
14
|
+
src/spinner.py,sha256=nnnoz91qt2NMEA3aMIT27XH7KImSMNVt_SyRAjNS8rk,6922
|
|
15
|
+
src/tui_issue.py,sha256=gcAJCIfMcdeMjrFJUWQQvdgBv7fnOBtxQxwdBk2Sk40,1789
|
|
16
|
+
src/updater.py,sha256=BDBRFpL9ek4MvduIKa3A_UH1KM-YCOCaXwPvN-eVREw,5886
|
|
17
|
+
src/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
src/ui/chat_app.py,sha256=y-py5UpisQbPLqqDON8AIkyA7X1IM5LAn1GbeYr2p_U,25598
|
|
19
|
+
src/ui/help_screen.py,sha256=tjbokXhwyzLnkOC0S56NbpX-78hcFQnvsQMpNU1wt0c,1862
|
|
20
|
+
src/ui/issue_app.py,sha256=8MdBcOVdCHPFx6EUhs80CiZ2G-29KulskkCz9qMZ_Lg,4737
|
|
21
|
+
gitpr_cli-0.0.26.dist-info/METADATA,sha256=RReNTAFsjM1rMKH3aordrfMn90pZygCt0jn-tTo3X-M,17450
|
|
22
|
+
gitpr_cli-0.0.26.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
23
|
+
gitpr_cli-0.0.26.dist-info/entry_points.txt,sha256=mh_4F_idM4cj0XhfRGBDQb5Nnku93YJ-dyKh5SufwlE,39
|
|
24
|
+
gitpr_cli-0.0.26.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
|
|
25
|
+
gitpr_cli-0.0.26.dist-info/RECORD,,
|
src/ai_providers.py
CHANGED
|
@@ -3,8 +3,12 @@ import time
|
|
|
3
3
|
import click
|
|
4
4
|
from google import genai
|
|
5
5
|
from openai import OpenAI
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
6
9
|
from src.spinner import Spinner
|
|
7
|
-
from src.i18n import __
|
|
10
|
+
from src.i18n import __,CURRENT_LANG
|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quiet=False):
|
|
10
14
|
"""
|
|
@@ -77,4 +81,117 @@ def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quie
|
|
|
77
81
|
click.secho(__("\r❌ Critical error contacting {provider} API after {max_retries} attempts: {error}", provider=provider.capitalize(), max_retries=max_retries, error=str(e)), fg="red", bold=True)
|
|
78
82
|
return None
|
|
79
83
|
finally:
|
|
80
|
-
spinner.stop()
|
|
84
|
+
spinner.stop()
|
|
85
|
+
|
|
86
|
+
def load_chat_commands():
|
|
87
|
+
"""Download and cache the translated chat commands."""
|
|
88
|
+
lang_suffix = "" if CURRENT_LANG.startswith("en") else f".{CURRENT_LANG}"
|
|
89
|
+
url = f"https://raw.githubusercontent.com/natanfiuza/gitpr/main/templates/chat_commands{lang_suffix}.json"
|
|
90
|
+
|
|
91
|
+
cache_dir = Path.home() / ".gitpr" / "cache"
|
|
92
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
cache_file = cache_dir / f"chat_commands{lang_suffix}.json"
|
|
94
|
+
|
|
95
|
+
# Try loading from the local cache first to avoid slowing down the terminal
|
|
96
|
+
if cache_file.exists():
|
|
97
|
+
try:
|
|
98
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
99
|
+
return json.load(f)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# If it is not in the cache, download it from the remote repository
|
|
104
|
+
try:
|
|
105
|
+
req = urllib.request.Request(url, headers={'User-Agent': 'GitPR-Chat'})
|
|
106
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
107
|
+
data = json.loads(response.read().decode())
|
|
108
|
+
with open(cache_file, "w", encoding="utf-8") as f:
|
|
109
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
110
|
+
return data
|
|
111
|
+
except Exception:
|
|
112
|
+
# Safety fallback if the user is offline
|
|
113
|
+
return {
|
|
114
|
+
"/explain": "Explains the diff line by line.",
|
|
115
|
+
"/tests": "Generates unit tests for the changed functions.",
|
|
116
|
+
"/optimize": "Analyzes cyclomatic complexity and performance.",
|
|
117
|
+
"/clear": "Clears conversation and creates a new chat session for the current diff."
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def process_chat_command(message):
|
|
121
|
+
"""
|
|
122
|
+
Check whether the message is a command (e.g., /explain).
|
|
123
|
+
Returns a tuple: (is_command, is_clear_command, processed_message)
|
|
124
|
+
"""
|
|
125
|
+
msg_trimmed = message.strip().lower()
|
|
126
|
+
if not msg_trimmed.startswith("/"):
|
|
127
|
+
return False, False, message
|
|
128
|
+
|
|
129
|
+
commands = load_chat_commands()
|
|
130
|
+
|
|
131
|
+
for cmd, prompt in commands.items():
|
|
132
|
+
if msg_trimmed == cmd.lower():
|
|
133
|
+
# Special flag so the UI knows whether to reset the session without calling the AI
|
|
134
|
+
is_clear = (cmd.lower() in ["/clear", "/limpar", "/limpiar", "/effacer"])
|
|
135
|
+
return True, is_clear, prompt
|
|
136
|
+
|
|
137
|
+
return False, False, message
|
|
138
|
+
|
|
139
|
+
def call_ai_chat(provider, api_key, api_model, system_instruction, chat_history, new_message, quiet=False):
|
|
140
|
+
"""
|
|
141
|
+
Dedicated engine for the Interactive Chat.
|
|
142
|
+
Keeps the historical context and returns free Markdown (does not force JSON).
|
|
143
|
+
"""
|
|
144
|
+
spinner = Spinner(quiet=quiet)
|
|
145
|
+
spinner.start()
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
if provider == "gemini":
|
|
149
|
+
client = genai.Client(api_key=api_key)
|
|
150
|
+
|
|
151
|
+
# Format the history into the Gemini SDK format
|
|
152
|
+
formatted_contents = []
|
|
153
|
+
for msg in chat_history:
|
|
154
|
+
role = "model" if msg["role"] == "assistant" else "user"
|
|
155
|
+
formatted_contents.append({"role": role, "parts": [{"text": msg["content"]}]})
|
|
156
|
+
|
|
157
|
+
formatted_contents.append({"role": "user", "parts": [{"text": new_message}]})
|
|
158
|
+
|
|
159
|
+
response = client.models.generate_content(
|
|
160
|
+
model=api_model,
|
|
161
|
+
contents=formatted_contents,
|
|
162
|
+
config={
|
|
163
|
+
"system_instruction": system_instruction,
|
|
164
|
+
"temperature": 0.3 # Slightly higher so the chat feels more natural
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
result_text = response.text
|
|
168
|
+
|
|
169
|
+
elif provider in ["deepseek", "ollama"]:
|
|
170
|
+
base_url = "https://api.deepseek.com" if provider == "deepseek" else "http://localhost:11434/v1"
|
|
171
|
+
client = OpenAI(api_key=api_key, base_url=base_url)
|
|
172
|
+
|
|
173
|
+
messages = [{"role": "system", "content": system_instruction}]
|
|
174
|
+
for msg in chat_history:
|
|
175
|
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
|
176
|
+
messages.append({"role": "user", "content": new_message})
|
|
177
|
+
|
|
178
|
+
response = client.chat.completions.create(
|
|
179
|
+
model=api_model,
|
|
180
|
+
messages=messages,
|
|
181
|
+
temperature=0.3
|
|
182
|
+
)
|
|
183
|
+
result_text = response.choices[0].message.content
|
|
184
|
+
else:
|
|
185
|
+
spinner.stop()
|
|
186
|
+
click.secho(__("❌ Unknown AI provider: {provider}", provider=provider), fg="red")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
spinner.stop()
|
|
190
|
+
return result_text
|
|
191
|
+
|
|
192
|
+
except Exception as e:
|
|
193
|
+
spinner.stop()
|
|
194
|
+
click.secho(__("\r❌ Critical error in Chat API ({provider}): {error}", provider=provider.capitalize(), error=str(e)), fg="red", bold=True)
|
|
195
|
+
return None
|
|
196
|
+
finally:
|
|
197
|
+
spinner.stop()
|
src/chat_memory.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
import random
|
|
5
|
+
import string
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
def gerar_uuid_base_15():
|
|
10
|
+
"""
|
|
11
|
+
Generate a UUID with a base of 15 characters (group 4-5-4)
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
str: UUID in the format XXXX-XXXXX-XXXX
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def gerar_grupo(tamanho):
|
|
18
|
+
"""
|
|
19
|
+
Helper function to generate a group of characters with at least one number
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
tamanho (int): Size of the group to be generated
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
str: Group of characters with at least one number
|
|
26
|
+
"""
|
|
27
|
+
letras = string.ascii_letters # 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
28
|
+
numeros = string.digits # '0123456789'
|
|
29
|
+
todos_caracteres = letras + numeros
|
|
30
|
+
|
|
31
|
+
# Ensures that at least one number will be present in the group
|
|
32
|
+
grupo = []
|
|
33
|
+
numero_inserido = False
|
|
34
|
+
|
|
35
|
+
for i in range(tamanho):
|
|
36
|
+
if i == tamanho - 1 and not numero_inserido:
|
|
37
|
+
# If it is the last character and we have not inserted a number yet
|
|
38
|
+
grupo.append(random.choice(numeros))
|
|
39
|
+
else:
|
|
40
|
+
caractere = random.choice(todos_caracteres)
|
|
41
|
+
if caractere.isdigit():
|
|
42
|
+
numero_inserido = True
|
|
43
|
+
grupo.append(caractere)
|
|
44
|
+
|
|
45
|
+
return ''.join(grupo)
|
|
46
|
+
|
|
47
|
+
# Generate the three groups of characters
|
|
48
|
+
grupo1 = gerar_grupo(4)
|
|
49
|
+
grupo2 = gerar_grupo(5)
|
|
50
|
+
grupo3 = gerar_grupo(4)
|
|
51
|
+
|
|
52
|
+
# Combine the groups into the desired format
|
|
53
|
+
return f"{grupo1}-{grupo2}-{grupo3}"
|
|
54
|
+
|
|
55
|
+
class ChatMemoryManager:
|
|
56
|
+
"""
|
|
57
|
+
State and Memory Manager for GitPR's Hybrid Chat sessions.
|
|
58
|
+
Keeps the message history and tracks code changes (diff) during the session.
|
|
59
|
+
"""
|
|
60
|
+
def __init__(self, repo_name, branch_name, current_diff, git_user, git_email):
|
|
61
|
+
self.repo_name = repo_name
|
|
62
|
+
self.branch_name = branch_name
|
|
63
|
+
self.git_user = git_user
|
|
64
|
+
self.git_email = git_email
|
|
65
|
+
|
|
66
|
+
self.base_dir = Path.home() / ".gitpr" / "cache" / "chat"
|
|
67
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
self.session_uuid = None
|
|
70
|
+
self.session_dir = None
|
|
71
|
+
self.config_file = None
|
|
72
|
+
self.conversation_file = None
|
|
73
|
+
|
|
74
|
+
self._initialize_session(current_diff)
|
|
75
|
+
|
|
76
|
+
def _generate_md5(self, text):
|
|
77
|
+
return hashlib.md5(text.encode('utf-8')).hexdigest()
|
|
78
|
+
|
|
79
|
+
def _initialize_session(self, current_diff):
|
|
80
|
+
"""Look for an open session for the current branch or create a new one."""
|
|
81
|
+
diff_md5 = self._generate_md5(current_diff)
|
|
82
|
+
latest_session = None
|
|
83
|
+
latest_time = None
|
|
84
|
+
|
|
85
|
+
# Look for the most recent session for this repository and branch
|
|
86
|
+
for session_folder in self.base_dir.iterdir():
|
|
87
|
+
if session_folder.is_dir():
|
|
88
|
+
uuid_str = session_folder.name
|
|
89
|
+
cfg_path = session_folder / f"chat-config_{uuid_str}.json"
|
|
90
|
+
|
|
91
|
+
if cfg_path.exists():
|
|
92
|
+
try:
|
|
93
|
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
94
|
+
cfg = json.load(f)
|
|
95
|
+
|
|
96
|
+
if cfg.get("repo") == self.repo_name and cfg.get("branch") == self.branch_name:
|
|
97
|
+
# Compare the modification time to pick the most recent chat for this branch
|
|
98
|
+
mtime = cfg_path.stat().st_mtime
|
|
99
|
+
if latest_time is None or mtime > latest_time:
|
|
100
|
+
latest_time = mtime
|
|
101
|
+
latest_session = (uuid_str, session_folder, cfg_path, cfg)
|
|
102
|
+
except Exception:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if latest_session:
|
|
106
|
+
# Reopen the existing session
|
|
107
|
+
self.session_uuid, self.session_dir, self.config_file, cfg_data = latest_session
|
|
108
|
+
self.conversation_file = self.session_dir / f"conversation_{self.session_uuid}.json"
|
|
109
|
+
|
|
110
|
+
# Check whether the code (diff) has changed since last time
|
|
111
|
+
diff_history = cfg_data.get("diff_history", [])
|
|
112
|
+
last_diff_md5 = diff_history[-1]["md5"] if diff_history else None
|
|
113
|
+
|
|
114
|
+
if last_diff_md5 != diff_md5:
|
|
115
|
+
self._append_diff_to_history(cfg_data, current_diff, diff_md5)
|
|
116
|
+
else:
|
|
117
|
+
# No session found for this branch, create a new one from scratch
|
|
118
|
+
self._create_new_session(current_diff, diff_md5)
|
|
119
|
+
|
|
120
|
+
def _append_diff_to_history(self, cfg_data, diff_text, diff_md5):
|
|
121
|
+
"""Store the new diff in the configuration file."""
|
|
122
|
+
new_entry = {
|
|
123
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
124
|
+
"md5": diff_md5,
|
|
125
|
+
"diff": diff_text
|
|
126
|
+
}
|
|
127
|
+
cfg_data.setdefault("diff_history", []).append(new_entry)
|
|
128
|
+
|
|
129
|
+
with open(self.config_file, "w", encoding="utf-8") as f:
|
|
130
|
+
json.dump(cfg_data, f, indent=2, ensure_ascii=False)
|
|
131
|
+
|
|
132
|
+
def _create_new_session(self, current_diff, diff_md5):
|
|
133
|
+
"""Set up a new base-15 UUID folder with the initial JSON files."""
|
|
134
|
+
self.session_uuid = gerar_uuid_base_15()
|
|
135
|
+
self.session_dir = self.base_dir / self.session_uuid
|
|
136
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
self.config_file = self.session_dir / f"chat-config_{self.session_uuid}.json"
|
|
139
|
+
self.conversation_file = self.session_dir / f"conversation_{self.session_uuid}.json"
|
|
140
|
+
|
|
141
|
+
initial_config = {
|
|
142
|
+
"session_uuid": self.session_uuid,
|
|
143
|
+
"folder_name": self.session_uuid,
|
|
144
|
+
"repo": self.repo_name,
|
|
145
|
+
"branch": self.branch_name,
|
|
146
|
+
"git_user": self.git_user,
|
|
147
|
+
"git_email": self.git_email,
|
|
148
|
+
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
149
|
+
"diff_history": [
|
|
150
|
+
{
|
|
151
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
152
|
+
"md5": diff_md5,
|
|
153
|
+
"diff": current_diff
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# Write the metadata
|
|
159
|
+
with open(self.config_file, "w", encoding="utf-8") as f:
|
|
160
|
+
json.dump(initial_config, f, indent=2, ensure_ascii=False)
|
|
161
|
+
|
|
162
|
+
# Create the empty chat memory
|
|
163
|
+
with open(self.conversation_file, "w", encoding="utf-8") as f:
|
|
164
|
+
json.dump([], f, indent=2, ensure_ascii=False)
|
|
165
|
+
|
|
166
|
+
def get_history(self):
|
|
167
|
+
"""Return the conversation history from the JSON file."""
|
|
168
|
+
if self.conversation_file.exists():
|
|
169
|
+
try:
|
|
170
|
+
with open(self.conversation_file, "r", encoding="utf-8") as f:
|
|
171
|
+
return json.load(f)
|
|
172
|
+
except Exception:
|
|
173
|
+
return []
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
def save_message(self, role, content):
|
|
177
|
+
"""
|
|
178
|
+
Save a new message to the conversation.
|
|
179
|
+
'role' must be 'user', 'assistant' or 'system'.
|
|
180
|
+
"""
|
|
181
|
+
history = self.get_history()
|
|
182
|
+
history.append({
|
|
183
|
+
"role": role,
|
|
184
|
+
"content": content,
|
|
185
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
186
|
+
})
|
|
187
|
+
with open(self.conversation_file, "w", encoding="utf-8") as f:
|
|
188
|
+
json.dump(history, f, indent=2, ensure_ascii=False)
|
|
189
|
+
|
|
190
|
+
def get_latest_diff(self):
|
|
191
|
+
"""Retrieve the most recent current diff from the session configuration."""
|
|
192
|
+
try:
|
|
193
|
+
with open(self.config_file, "r", encoding="utf-8") as f:
|
|
194
|
+
cfg = json.load(f)
|
|
195
|
+
return cfg.get("diff_history", [])[-1]["diff"]
|
|
196
|
+
except Exception:
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
def update_diff_if_changed(self, new_diff):
|
|
200
|
+
"""
|
|
201
|
+
Silently check whether the code has changed.
|
|
202
|
+
Used by the F2 key to update the chat context in real time.
|
|
203
|
+
Returns True if the diff was updated, False otherwise.
|
|
204
|
+
"""
|
|
205
|
+
new_md5 = self._generate_md5(new_diff)
|
|
206
|
+
try:
|
|
207
|
+
with open(self.config_file, "r", encoding="utf-8") as f:
|
|
208
|
+
cfg = json.load(f)
|
|
209
|
+
|
|
210
|
+
diff_history = cfg.get("diff_history", [])
|
|
211
|
+
last_diff_md5 = diff_history[-1]["md5"] if diff_history else None
|
|
212
|
+
|
|
213
|
+
if last_diff_md5 != new_md5:
|
|
214
|
+
self._append_diff_to_history(cfg, new_diff, new_md5)
|
|
215
|
+
return True
|
|
216
|
+
return False
|
|
217
|
+
except Exception:
|
|
218
|
+
return False
|
src/core.py
CHANGED
|
@@ -40,21 +40,21 @@ def get_doc_url(filename):
|
|
|
40
40
|
return f"https://github.com/natanfiuza/gitpr/blob/main/docs/{base}.{CURRENT_LANG}.{ext}"
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
def get_git_diff():
|
|
43
|
+
def get_git_diff(quiet=False):
|
|
44
44
|
"""Runs 'git diff HEAD' and returns the output, warning about untracked files."""
|
|
45
45
|
try:
|
|
46
46
|
# Check if there are new untracked files
|
|
47
47
|
untracked_process = subprocess.run(
|
|
48
|
-
["git", "ls-files", "--others", "--exclude-standard"],
|
|
49
|
-
capture_output=True,
|
|
50
|
-
text=True,
|
|
48
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
51
|
encoding="utf-8",
|
|
52
|
-
errors="replace"
|
|
52
|
+
errors="replace"
|
|
53
53
|
)
|
|
54
54
|
untracked_files = untracked_process.stdout.strip()
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
# If there are new files, display an educational warning in the console
|
|
57
|
-
if untracked_files:
|
|
57
|
+
if untracked_files and not quiet:
|
|
58
58
|
click.secho(__("⚠️ Warning: Git detected new untracked files:"), fg="yellow")
|
|
59
59
|
for file in untracked_files.split('\n'):
|
|
60
60
|
click.secho(f" - {file}", fg="yellow", dim=True)
|
|
@@ -64,19 +64,21 @@ def get_git_diff():
|
|
|
64
64
|
# Run the normal diff that captures tracked and staged files
|
|
65
65
|
cmd = ["git", "diff", "HEAD", "--"] + SMART_EXCLUDES
|
|
66
66
|
result = subprocess.run(
|
|
67
|
-
cmd,
|
|
68
|
-
capture_output=True,
|
|
69
|
-
text=True,
|
|
67
|
+
cmd,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
70
|
encoding="utf-8",
|
|
71
|
-
errors="replace",
|
|
71
|
+
errors="replace",
|
|
72
72
|
check=True
|
|
73
73
|
)
|
|
74
74
|
return result.stdout
|
|
75
75
|
except subprocess.CalledProcessError as e:
|
|
76
|
-
|
|
76
|
+
if not quiet:
|
|
77
|
+
click.secho(__("❌ Error running Git: {error}", error=e.stderr), fg="red")
|
|
77
78
|
return None
|
|
78
79
|
except FileNotFoundError:
|
|
79
|
-
|
|
80
|
+
if not quiet:
|
|
81
|
+
click.secho(__("❌ Git not found. Make sure it is installed and in the PATH."), fg="red")
|
|
80
82
|
return None
|
|
81
83
|
|
|
82
84
|
|
src/i18n.py
CHANGED
|
@@ -82,6 +82,19 @@ def get_translations(lang_code):
|
|
|
82
82
|
CURRENT_LANG = get_system_language()
|
|
83
83
|
TRANSLATIONS = get_translations(CURRENT_LANG)
|
|
84
84
|
|
|
85
|
+
|
|
86
|
+
def set_lang(lang: str) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Override the session language at runtime.
|
|
89
|
+
Updates CURRENT_LANG and TRANSLATIONS. Does NOT persist to .env.
|
|
90
|
+
Called by the --lang CLI flag before any command logic.
|
|
91
|
+
"""
|
|
92
|
+
global CURRENT_LANG, TRANSLATIONS
|
|
93
|
+
lang = lang.lower().replace("-", "_") # normalize pt-BR -> pt_br
|
|
94
|
+
CURRENT_LANG = lang
|
|
95
|
+
TRANSLATIONS = get_translations(lang)
|
|
96
|
+
|
|
97
|
+
|
|
85
98
|
def __(key, **kwargs):
|
|
86
99
|
"""
|
|
87
100
|
Translation Engine inspired by Laravel.
|
src/main.py
CHANGED
|
@@ -18,6 +18,9 @@ from src.core import (
|
|
|
18
18
|
)
|
|
19
19
|
from src.linter_engine import parse_diff_and_lint
|
|
20
20
|
from src.i18n import __
|
|
21
|
+
import subprocess
|
|
22
|
+
from src.chat_memory import ChatMemoryManager
|
|
23
|
+
from src.ui.chat_app import ChatApp
|
|
21
24
|
|
|
22
25
|
def print_banner():
|
|
23
26
|
"""Displays the project ASCII Art signature"""
|
|
@@ -98,6 +101,16 @@ HELP_MAP: dict[str, dict[str, str]] = {
|
|
|
98
101
|
'title': __('AI Provider Selection (--provider)'),
|
|
99
102
|
'description': __('Forces the use of a specific AI provider for this execution: gemini (Google Gemini), deepseek (DeepSeek) or ollama (Local). Temporarily overrides the default provider defined in the .env file.'),
|
|
100
103
|
},
|
|
104
|
+
'chat': {
|
|
105
|
+
'url': get_doc_url('chat-interativo.md'),
|
|
106
|
+
'title': __('Interactive Pair Programming Chat (--chat)'),
|
|
107
|
+
'description': __('Opens an interactive terminal (TUI) to chat with the AI about the current uncommitted changes. Features memory, auto-patching (F5), and live diff refresh (F2).'),
|
|
108
|
+
},
|
|
109
|
+
'lang': {
|
|
110
|
+
'url': get_doc_url('providers-ia.md'),
|
|
111
|
+
'title': __('Language Override (--lang)'),
|
|
112
|
+
'description': __('Forces the interface language for this execution (e.g.: en_us, pt_br). Overrides the GITPR_LANG environment variable and OS locale detection.'),
|
|
113
|
+
},
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
# Priority for contextual help when multiple flags are used with -h
|
|
@@ -115,6 +128,7 @@ HELP_PRIORITY: dict[str, int] = {
|
|
|
115
128
|
'input': 10,
|
|
116
129
|
'history': 11,
|
|
117
130
|
'provider': 12,
|
|
131
|
+
'lang': 13,
|
|
118
132
|
}
|
|
119
133
|
|
|
120
134
|
|
|
@@ -133,9 +147,11 @@ HELP_PRIORITY: dict[str, int] = {
|
|
|
133
147
|
@click.option('-b', '--blame', type=str, help=__("Analyzes the origin of a business rule (e.g., file.py:10-20 or just file.py)."))
|
|
134
148
|
@click.option('-ht', '--history', is_flag=True, help=__("Uses the entire branch history (Git Log + PR Cache) as context to generate the issue."))
|
|
135
149
|
@click.option('-is', '--issue', is_flag=True, help=__("Generates a standardized Issue from current changes and opens the interactive interface."))
|
|
150
|
+
@click.option('-ch', '--chat', is_flag=True, help=__("Opens the interactive Pair Programming chat with AI."))
|
|
136
151
|
@click.option('-p', '--provider', type=click.Choice(['gemini', 'deepseek', 'ollama']), help=__("Forces the use of a specific AI provider for this execution."))
|
|
152
|
+
@click.option('--lang', type=str, help=__("Forces the interface language for this execution (e.g.: en_us, pt_br)."))
|
|
137
153
|
@click.option('-h', '--help', 'help_flag', is_flag=True, help=__("Shows this message and exits. Use with another flag for contextual help (e.g., -h --issue)."))
|
|
138
|
-
def cli(commit, review, fullreview, linter, skill, update, installhooks, hook, quiet, provider, input, blame, history, issue, help_flag):
|
|
154
|
+
def cli(commit, review, fullreview, linter, skill, update, installhooks, hook, quiet, provider, input, blame, history, issue, chat, help_flag, lang):
|
|
139
155
|
"""
|
|
140
156
|
GitPR CLI - Intelligent PR Automation and AI Code Review.
|
|
141
157
|
|
|
@@ -197,6 +213,13 @@ def cli(commit, review, fullreview, linter, skill, update, installhooks, hook, q
|
|
|
197
213
|
ctx = click.get_current_context()
|
|
198
214
|
ctx.exit()
|
|
199
215
|
|
|
216
|
+
# Language override via --lang flag (one-shot, does not persist to .env)
|
|
217
|
+
if lang:
|
|
218
|
+
from src.i18n import set_lang
|
|
219
|
+
from src.spinner import reload_thinking_words
|
|
220
|
+
set_lang(lang)
|
|
221
|
+
reload_thinking_words(lang)
|
|
222
|
+
|
|
200
223
|
# Silencia o banner se estiver no modo quiet ou via hook
|
|
201
224
|
if not quiet and not hook:
|
|
202
225
|
print_banner()
|
|
@@ -429,6 +452,57 @@ def cli(commit, review, fullreview, linter, skill, update, installhooks, hook, q
|
|
|
429
452
|
|
|
430
453
|
return
|
|
431
454
|
|
|
455
|
+
# Chat Module (Pair Programming TUI)
|
|
456
|
+
if chat:
|
|
457
|
+
from src.issue_engine import get_github_repo_info
|
|
458
|
+
from src.config import get_api_key
|
|
459
|
+
|
|
460
|
+
setup_environment()
|
|
461
|
+
|
|
462
|
+
diff_text = get_git_diff()
|
|
463
|
+
if not diff_text or not diff_text.strip():
|
|
464
|
+
click.secho(__("\n⚠️ No new code found. Make some changes before starting the chat.\n"), fg="yellow")
|
|
465
|
+
click.secho(f"📚 {__('Chat documentation:')} {get_doc_url('understanding_chat_functionality.md')}", fg="cyan")
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
active_provider = provider if provider else get_ai_provider()
|
|
469
|
+
api_key = get_api_key(active_provider)
|
|
470
|
+
|
|
471
|
+
if not api_key:
|
|
472
|
+
click.secho(__("❌ AI Provider API Key missing or invalid."), fg="red")
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
repo_info = get_github_repo_info() or "local-repo"
|
|
476
|
+
branch_name = get_current_branch()
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
git_user = subprocess.run(["git", "config", "user.name"], capture_output=True, text=True).stdout.strip()
|
|
480
|
+
git_email = subprocess.run(["git", "config", "user.email"], capture_output=True, text=True).stdout.strip()
|
|
481
|
+
except Exception:
|
|
482
|
+
git_user, git_email = "Dev", "dev@local"
|
|
483
|
+
|
|
484
|
+
memory = ChatMemoryManager(repo_info, branch_name, diff_text, git_user, git_email)
|
|
485
|
+
|
|
486
|
+
system_instruction = __("You are a Senior Software Engineer acting as a Pair Programmer. Analyze the code provided and answer the user's questions clearly, objectively, and technically. Use Markdown to format code blocks. The current git diff is:\n\n{diff}", diff=diff_text)
|
|
487
|
+
|
|
488
|
+
# Pega o modelo primário configurado no .env
|
|
489
|
+
env_model_key = f"{active_provider.upper()}_API_MODEL_PRIMARY"
|
|
490
|
+
api_model = os.getenv(env_model_key)
|
|
491
|
+
if not api_model:
|
|
492
|
+
click.secho(__("❌ Model configuration not found for provider {provider}.", provider=active_provider), fg="red")
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
app = ChatApp(
|
|
496
|
+
memory_manager=memory,
|
|
497
|
+
provider=active_provider,
|
|
498
|
+
api_key=api_key,
|
|
499
|
+
api_model=api_model,
|
|
500
|
+
system_instruction=system_instruction
|
|
501
|
+
)
|
|
502
|
+
app.run()
|
|
503
|
+
click.secho(f"📚 {__('Chat documentation:')} {get_doc_url('understanding_chat_functionality.md')}", fg="cyan")
|
|
504
|
+
return
|
|
505
|
+
|
|
432
506
|
# Input Mode validation (with guard to not interfere with contextual -h)
|
|
433
507
|
if input and not help_flag:
|
|
434
508
|
if not os.path.exists(input):
|
src/spinner.py
CHANGED
|
@@ -93,6 +93,21 @@ def _load_thinking_words():
|
|
|
93
93
|
return list(_FALLBACK_WORDS)
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def reload_thinking_words(lang: str) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Recompute module-level spinner constants for the given language.
|
|
99
|
+
Called by cli() after set_lang() when --lang is provided.
|
|
100
|
+
Must be called before any Spinner instance is created.
|
|
101
|
+
"""
|
|
102
|
+
global _LANG_SUFFIX, THINKING_WORDS_URL, THINKING_WORDS
|
|
103
|
+
_LANG_SUFFIX = "" if lang.startswith("en") else f".{lang}"
|
|
104
|
+
THINKING_WORDS_URL = (
|
|
105
|
+
"https://raw.githubusercontent.com/natanfiuza/gitpr/"
|
|
106
|
+
f"refs/heads/main/templates/gitpr.thinking-words{_LANG_SUFFIX}.md"
|
|
107
|
+
)
|
|
108
|
+
THINKING_WORDS = _load_thinking_words()
|
|
109
|
+
|
|
110
|
+
|
|
96
111
|
# Words representing AI "thinking" (loaded from .env or remote template)
|
|
97
112
|
THINKING_WORDS = _load_thinking_words()
|
|
98
113
|
|
src/ui/chat_app.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import random
|
|
3
|
+
import string
|
|
4
|
+
import webbrowser
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Header, Footer, Input, Markdown, Static, Button, ListView, ListItem
|
|
8
|
+
from textual.containers import VerticalScroll, Vertical
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual import work
|
|
11
|
+
from src.core import get_git_diff
|
|
12
|
+
from src.i18n import __, CURRENT_LANG
|
|
13
|
+
from src.ai_providers import call_ai_chat, process_chat_command, load_chat_commands
|
|
14
|
+
from src.spinner import THINKING_WORDS
|
|
15
|
+
|
|
16
|
+
class ChatMessage(Static):
|
|
17
|
+
"""Custom component to render each message bubble."""
|
|
18
|
+
def __init__(self, role, content, msg_index=-1, **kwargs):
|
|
19
|
+
super().__init__(content, markup=False, **kwargs)
|
|
20
|
+
self.role = role
|
|
21
|
+
self.msg_index = msg_index
|
|
22
|
+
|
|
23
|
+
def compose(self) -> ComposeResult:
|
|
24
|
+
# Add the class corresponding to the role (user, assistant or system)
|
|
25
|
+
yield Markdown(self.content, classes=f"message {self.role}")
|
|
26
|
+
|
|
27
|
+
class ChatHelpScreen(ModalScreen):
|
|
28
|
+
"""Help modal showing keyboard shortcuts and slash commands."""
|
|
29
|
+
|
|
30
|
+
CSS = """
|
|
31
|
+
ChatHelpScreen { align: center middle; }
|
|
32
|
+
#help_dialog {
|
|
33
|
+
width: 72; height: auto; max-height: 90%; padding: 1 2;
|
|
34
|
+
background: $surface; border: thick $accent 50%;
|
|
35
|
+
}
|
|
36
|
+
#help_scroll {
|
|
37
|
+
max-height: 28; overflow-y: scroll;
|
|
38
|
+
}
|
|
39
|
+
.help_title { text-align: center; text-style: bold; margin-bottom: 1; }
|
|
40
|
+
.help_section { text-style: bold; color: $accent; margin-top: 1; margin-bottom: 1; }
|
|
41
|
+
.help_text { margin-bottom: 1; }
|
|
42
|
+
Button {
|
|
43
|
+
width: 20%;
|
|
44
|
+
margin-top: 1;
|
|
45
|
+
align: center middle;
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
commands = load_chat_commands()
|
|
51
|
+
cmd_lines = []
|
|
52
|
+
for cmd, desc in commands.items():
|
|
53
|
+
cmd_lines.append(f"• [bold $accent]{cmd}[/bold $accent] — {desc}")
|
|
54
|
+
cmd_text = "\n".join(cmd_lines)
|
|
55
|
+
|
|
56
|
+
with Vertical(id="help_dialog"):
|
|
57
|
+
yield Static("💡 Chat Help", classes="help_title")
|
|
58
|
+
yield Static("⌨️ Keyboard Shortcuts", classes="help_section")
|
|
59
|
+
with VerticalScroll(id="help_scroll"):
|
|
60
|
+
yield Static(
|
|
61
|
+
"• [bold]F1[/bold] Help — Shows this help modal\n"
|
|
62
|
+
"• [bold]F2[/bold] Refresh Diff — Updates the chat context with the latest code changes\n"
|
|
63
|
+
"• [bold]F5[/bold] Auto-Patch — Extracts code blocks from the last AI response\n"
|
|
64
|
+
"• [bold]F6[/bold] Export — Saves the full conversation to a Markdown file\n"
|
|
65
|
+
"• [bold]F7/F8[/bold] — Navigate focus between AI messages\n"
|
|
66
|
+
"• [bold]Ctrlhift+S[/bold] — Auto-Patch the focused AI message\n"
|
|
67
|
+
"• [bold]Ctrlhift+E[/bold] — Export the focused AI message\n"
|
|
68
|
+
"• [bold]Esc[/bold] Exit — Closes the chat",
|
|
69
|
+
classes="help_text"
|
|
70
|
+
)
|
|
71
|
+
yield Static("⚡ Slash Commands", classes="help_section")
|
|
72
|
+
yield Static(cmd_text, classes="help_text")
|
|
73
|
+
yield Button(__("Got it"), variant="primary", id="close_help")
|
|
74
|
+
yield Button(__("Online Help"), variant="default", id="online_help")
|
|
75
|
+
|
|
76
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
77
|
+
if event.button.id == "close_help":
|
|
78
|
+
self.app.pop_screen()
|
|
79
|
+
elif event.button.id == "online_help":
|
|
80
|
+
lang_suffix = "" if CURRENT_LANG.startswith("en") else f".{CURRENT_LANG}"
|
|
81
|
+
url = f"https://github.com/natanfiuza/gitpr/blob/main/docs/understanding_chat_functionality{lang_suffix}.md"
|
|
82
|
+
webbrowser.open(url)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CommandSuggestions(Vertical):
|
|
86
|
+
"""Slash-command suggestion panel that appears above the input while the user types."""
|
|
87
|
+
|
|
88
|
+
DEFAULT_CSS = """
|
|
89
|
+
CommandSuggestions {
|
|
90
|
+
margin: 0 2 1 2;
|
|
91
|
+
height: auto;
|
|
92
|
+
max-height: 12;
|
|
93
|
+
background: $surface-darken-1;
|
|
94
|
+
border: solid $accent;
|
|
95
|
+
display: none;
|
|
96
|
+
}
|
|
97
|
+
CommandSuggestions ListView {
|
|
98
|
+
height: auto;
|
|
99
|
+
max-height: 10;
|
|
100
|
+
}
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, **kwargs):
|
|
104
|
+
super().__init__(**kwargs)
|
|
105
|
+
self.commands = {}
|
|
106
|
+
self.filtered = []
|
|
107
|
+
|
|
108
|
+
def compose(self) -> ComposeResult:
|
|
109
|
+
yield ListView()
|
|
110
|
+
|
|
111
|
+
def load_commands(self):
|
|
112
|
+
"""Load the translated command list."""
|
|
113
|
+
self.commands = load_chat_commands()
|
|
114
|
+
|
|
115
|
+
def filter_commands(self, query: str) -> None:
|
|
116
|
+
"""Update the list with commands matching the partial input."""
|
|
117
|
+
list_view = self.query_one(ListView)
|
|
118
|
+
list_view.clear()
|
|
119
|
+
list_view.index = None # Reset highlight so stale index doesn't cause wrong auto-complete
|
|
120
|
+
|
|
121
|
+
if not query.startswith("/"):
|
|
122
|
+
self.display = False
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
query_lower = query.lower()
|
|
126
|
+
matches = [
|
|
127
|
+
(cmd, desc) for cmd, desc in self.commands.items()
|
|
128
|
+
if cmd.lower().startswith(query_lower)
|
|
129
|
+
]
|
|
130
|
+
if not matches:
|
|
131
|
+
self.display = False
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
for cmd, desc in matches:
|
|
135
|
+
list_view.append(ListItem(Static(f"{cmd} — {desc}")))
|
|
136
|
+
|
|
137
|
+
self.filtered = [cmd for cmd, _ in matches]
|
|
138
|
+
self.display = True
|
|
139
|
+
|
|
140
|
+
def get_selected_command(self) -> str | None:
|
|
141
|
+
"""Return the highlighted command, or the first match as auto-complete fallback."""
|
|
142
|
+
list_view = self.query_one(ListView)
|
|
143
|
+
if list_view.index is not None and 0 <= list_view.index < len(self.filtered):
|
|
144
|
+
return self.filtered[list_view.index]
|
|
145
|
+
if self.filtered:
|
|
146
|
+
return self.filtered[0]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ChatApp(App):
|
|
151
|
+
"""Terminal interface for the Interactive Pair Programming Chat."""
|
|
152
|
+
|
|
153
|
+
TITLE = "GitPR - AI Pair Programming"
|
|
154
|
+
ENABLE_COMMAND_PALETTE = False
|
|
155
|
+
|
|
156
|
+
CSS = """
|
|
157
|
+
#chat_container {
|
|
158
|
+
height: 1fr;
|
|
159
|
+
padding: 1 2;
|
|
160
|
+
overflow-y: scroll;
|
|
161
|
+
}
|
|
162
|
+
.message {
|
|
163
|
+
margin-bottom: 1;
|
|
164
|
+
padding: 1 2;
|
|
165
|
+
border: solid $accent 50%;
|
|
166
|
+
}
|
|
167
|
+
.user {
|
|
168
|
+
background: $surface;
|
|
169
|
+
border-left: thick $accent;
|
|
170
|
+
}
|
|
171
|
+
.assistant {
|
|
172
|
+
background: $panel;
|
|
173
|
+
border-left: thick $success;
|
|
174
|
+
}
|
|
175
|
+
.assistant.focused {
|
|
176
|
+
border-left: thick $warning;
|
|
177
|
+
background: $accent 30%;
|
|
178
|
+
}
|
|
179
|
+
#focus_bar {
|
|
180
|
+
dock: bottom;
|
|
181
|
+
height: 1;
|
|
182
|
+
padding: 0 2;
|
|
183
|
+
background: $surface-darken-1;
|
|
184
|
+
color: $text-muted;
|
|
185
|
+
}
|
|
186
|
+
.system {
|
|
187
|
+
background: $warning-muted;
|
|
188
|
+
color: $text;
|
|
189
|
+
text-align: center;
|
|
190
|
+
border: none;
|
|
191
|
+
padding: 0 1;
|
|
192
|
+
}
|
|
193
|
+
#chat_input {
|
|
194
|
+
dock: bottom;
|
|
195
|
+
margin: 1 2;
|
|
196
|
+
}
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
# Shortcuts already mapped for Phase 4!
|
|
200
|
+
BINDINGS = [
|
|
201
|
+
Binding("f1", "show_help", __("Help")),
|
|
202
|
+
Binding("f2", "refresh_diff", __("Refresh Diff")),
|
|
203
|
+
Binding("f5", "apply_code", __("Auto-Patch")),
|
|
204
|
+
Binding("f6", "export_session", __("Export")),
|
|
205
|
+
Binding("f7", "focus_prev_msg", __("Previous Msg")),
|
|
206
|
+
Binding("f8", "focus_next_msg", __("Next Msg")),
|
|
207
|
+
Binding("ctrl+s", "auto_patch_focused", __("Auto-Patch Msg"), priority=True),
|
|
208
|
+
Binding("ctrl+e", "export_focused_msg", __("Export Msg"), priority=True),
|
|
209
|
+
Binding("escape", "quit", __("Exit"))
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
def __init__(self, memory_manager, provider, api_key, api_model, system_instruction, **kwargs):
|
|
213
|
+
super().__init__(**kwargs)
|
|
214
|
+
self.memory = memory_manager
|
|
215
|
+
self.provider = provider
|
|
216
|
+
self.api_key = api_key
|
|
217
|
+
self.api_model = api_model
|
|
218
|
+
self.system_instruction = system_instruction
|
|
219
|
+
|
|
220
|
+
self.sub_title = f"{self.memory.repo_name} | Branch: {self.memory.branch_name} | ID: {self.memory.session_uuid}"
|
|
221
|
+
self._thinking_widget = None
|
|
222
|
+
self._thinking_timer = None
|
|
223
|
+
self._thinking_state = {}
|
|
224
|
+
self._focused_msg_index = -1
|
|
225
|
+
self._focused_msg_content = ""
|
|
226
|
+
self._ai_msg_widgets = []
|
|
227
|
+
self._focus_bar = None
|
|
228
|
+
|
|
229
|
+
def compose(self) -> ComposeResult:
|
|
230
|
+
yield Header()
|
|
231
|
+
with VerticalScroll(id="chat_container"):
|
|
232
|
+
pass # Populated dynamically in on_mount
|
|
233
|
+
yield Static("", id="focus_bar")
|
|
234
|
+
yield CommandSuggestions(id="cmd_suggestions")
|
|
235
|
+
yield Input(placeholder=__("Type your message or / for commands..."), id="chat_input")
|
|
236
|
+
yield Footer()
|
|
237
|
+
|
|
238
|
+
def on_mount(self) -> None:
|
|
239
|
+
"""Load the history stored in the base-15 UUID when opening the screen."""
|
|
240
|
+
self._focus_bar = self.query_one("#focus_bar", Static)
|
|
241
|
+
self.load_history()
|
|
242
|
+
self._cmd_suggestions = self.query_one("#cmd_suggestions", CommandSuggestions)
|
|
243
|
+
self._cmd_suggestions.load_commands()
|
|
244
|
+
|
|
245
|
+
def load_history(self):
|
|
246
|
+
container = self.query_one("#chat_container")
|
|
247
|
+
history = self.memory.get_history()
|
|
248
|
+
|
|
249
|
+
if not history:
|
|
250
|
+
welcome_msg = __("🤖 Hello! I am your AI assistant. I'm looking at your current diff. How can I help you?")
|
|
251
|
+
container.mount(ChatMessage("system", welcome_msg))
|
|
252
|
+
else:
|
|
253
|
+
for msg in history:
|
|
254
|
+
msg_index = len(self._ai_msg_widgets) if msg["role"] == "assistant" else -1
|
|
255
|
+
widget = ChatMessage(msg["role"], msg["content"], msg_index=msg_index)
|
|
256
|
+
container.mount(widget)
|
|
257
|
+
|
|
258
|
+
if msg["role"] == "assistant":
|
|
259
|
+
self._ai_msg_widgets.append(widget)
|
|
260
|
+
|
|
261
|
+
if self._ai_msg_widgets:
|
|
262
|
+
self._focused_msg_index = len(self._ai_msg_widgets) - 1
|
|
263
|
+
self._update_focus_visual()
|
|
264
|
+
|
|
265
|
+
container.scroll_end(animate=False)
|
|
266
|
+
|
|
267
|
+
BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
268
|
+
|
|
269
|
+
def _animate_thinking(self) -> None:
|
|
270
|
+
"""Called by the App-level timer to animate the thinking indicator."""
|
|
271
|
+
if not self._thinking_widget or not self._thinking_state:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
st = self._thinking_state
|
|
275
|
+
braille = self.BRAILLE[st["braille_idx"]]
|
|
276
|
+
st["braille_idx"] = (st["braille_idx"] + 1) % len(self.BRAILLE)
|
|
277
|
+
|
|
278
|
+
if len(st["discovered"]) < len(st["word"]):
|
|
279
|
+
st["char_step"] += 1
|
|
280
|
+
if st["char_step"] >= 4:
|
|
281
|
+
st["discovered"] = st["word"][:len(st["discovered"]) + 1]
|
|
282
|
+
st["char_step"] = 0
|
|
283
|
+
else:
|
|
284
|
+
fake = random.choice(string.ascii_uppercase + "0123456789!@#$")
|
|
285
|
+
st["discovered"] = st["word"][:len(st["discovered"])] + fake
|
|
286
|
+
display = f" {braille} {st['discovered']}"
|
|
287
|
+
else:
|
|
288
|
+
st["dots_cycle"] = (st["dots_cycle"] + 1) % 12
|
|
289
|
+
if st["dots_cycle"] < 4:
|
|
290
|
+
dots = "."
|
|
291
|
+
elif st["dots_cycle"] < 8:
|
|
292
|
+
dots = ".."
|
|
293
|
+
else:
|
|
294
|
+
dots = "..."
|
|
295
|
+
display = f" {braille} {st['word']}{dots}"
|
|
296
|
+
|
|
297
|
+
if st["dots_cycle"] == 0 and st["braille_idx"] == 0:
|
|
298
|
+
st["word"] = random.choice(THINKING_WORDS)
|
|
299
|
+
st["discovered"] = ""
|
|
300
|
+
st["char_step"] = 0
|
|
301
|
+
|
|
302
|
+
self._thinking_widget.update(display)
|
|
303
|
+
|
|
304
|
+
def add_message(self, role, content):
|
|
305
|
+
"""Add the message visually and scroll the screen to the end."""
|
|
306
|
+
container = self.query_one("#chat_container")
|
|
307
|
+
msg_index = -1
|
|
308
|
+
if role == "assistant":
|
|
309
|
+
msg_index = len(self._ai_msg_widgets)
|
|
310
|
+
msg = ChatMessage(role, content, msg_index=msg_index)
|
|
311
|
+
container.mount(msg)
|
|
312
|
+
container.scroll_end(animate=True)
|
|
313
|
+
if role == "assistant":
|
|
314
|
+
self._ai_msg_widgets.append(msg)
|
|
315
|
+
self._focused_msg_index = msg_index
|
|
316
|
+
self._update_focus_visual()
|
|
317
|
+
|
|
318
|
+
# ── Message focus system ──────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
def _notify_action(self, message, severity="information"):
|
|
321
|
+
"""Show a floating notification that does NOT scroll the chat."""
|
|
322
|
+
self.notify(message, severity=severity, timeout=5)
|
|
323
|
+
def _update_focus_visual(self):
|
|
324
|
+
for message in self._ai_msg_widgets:
|
|
325
|
+
message.query_one(Markdown).remove_class("focused")
|
|
326
|
+
|
|
327
|
+
if 0 <= self._focused_msg_index < len(self._ai_msg_widgets):
|
|
328
|
+
focused = self._ai_msg_widgets[self._focused_msg_index]
|
|
329
|
+
focused.query_one(Markdown).add_class("focused")
|
|
330
|
+
self._focused_msg_content = focused.content
|
|
331
|
+
|
|
332
|
+
if self._focus_bar:
|
|
333
|
+
self._focus_bar.update(
|
|
334
|
+
__("Msg #{n} focused | Ctrl+S: Auto-Patch | Ctrlhift+E: Export",
|
|
335
|
+
n=self._focused_msg_index + 1)
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
self._focused_msg_content = ""
|
|
339
|
+
|
|
340
|
+
if self._focus_bar:
|
|
341
|
+
self._focus_bar.update("")
|
|
342
|
+
# def _update_focus_visual(self):
|
|
343
|
+
# """Apply visual highlight to the active AI message and update the focus bar."""
|
|
344
|
+
# for m in self._ai_msg_widgets:
|
|
345
|
+
# m.styles.border_left = ("thick", "green")
|
|
346
|
+
# if 0 <= self._focused_msg_index < len(self._ai_msg_widgets):
|
|
347
|
+
# focused = self._ai_msg_widgets[self._focused_msg_index]
|
|
348
|
+
# focused.styles.border_left = ("thick", "yellow")
|
|
349
|
+
# self._focused_msg_content = focused.content
|
|
350
|
+
# if self._focus_bar:
|
|
351
|
+
# self._focus_bar.update(__("Msg #{n} focused | Ctrlhift+S: Auto-Patch | Ctrlhift+E: Export", n=self._focused_msg_index + 1))
|
|
352
|
+
# else:
|
|
353
|
+
# self._focused_msg_content = ""
|
|
354
|
+
# if self._focus_bar:
|
|
355
|
+
# self._focus_bar.update("")
|
|
356
|
+
|
|
357
|
+
def action_focus_prev_msg(self):
|
|
358
|
+
"""F7: move focus to the previous AI message."""
|
|
359
|
+
if not self._ai_msg_widgets:
|
|
360
|
+
return
|
|
361
|
+
self._focused_msg_index = max(0, self._focused_msg_index - 1)
|
|
362
|
+
self._update_focus_visual()
|
|
363
|
+
|
|
364
|
+
def action_focus_next_msg(self):
|
|
365
|
+
"""F8: move focus to the next AI message."""
|
|
366
|
+
if not self._ai_msg_widgets:
|
|
367
|
+
return
|
|
368
|
+
self._focused_msg_index = min(len(self._ai_msg_widgets) - 1, self._focused_msg_index + 1)
|
|
369
|
+
self._update_focus_visual()
|
|
370
|
+
|
|
371
|
+
def action_auto_patch_focused(self):
|
|
372
|
+
"""Ctrlhift+S: extract code from the focused AI message only."""
|
|
373
|
+
if not self._focused_msg_content:
|
|
374
|
+
self._notify_action(__("❌ No AI message focused. Use F7/F8 to select one."), severity="warning")
|
|
375
|
+
return
|
|
376
|
+
content = self._focused_msg_content
|
|
377
|
+
code_blocks = re.findall(r'`{3}\s*(?:\w+)?\s*\n(.*?)`{3}', content, re.DOTALL)
|
|
378
|
+
if not code_blocks:
|
|
379
|
+
parts = content.split("```")
|
|
380
|
+
for i in range(1, len(parts), 2):
|
|
381
|
+
block = parts[i].strip()
|
|
382
|
+
if block:
|
|
383
|
+
first_line_end = block.find("\n")
|
|
384
|
+
if first_line_end > 0 and first_line_end < 20:
|
|
385
|
+
first_line = block[:first_line_end].strip()
|
|
386
|
+
if first_line and " " not in first_line:
|
|
387
|
+
block = block[first_line_end + 1:]
|
|
388
|
+
code_blocks.append(block.strip())
|
|
389
|
+
if code_blocks:
|
|
390
|
+
extracted_code = "\n\n".join(code_blocks)
|
|
391
|
+
key = ''.join(random.choices(string.ascii_letters + string.digits, k=3)) + '-' + ''.join(random.choices(string.ascii_letters + string.digits, k=3))
|
|
392
|
+
export_file = f"GITPR_PATCH_SUGGESTION_{key}.txt"
|
|
393
|
+
with open(export_file, "w", encoding="utf-8") as f:
|
|
394
|
+
f.write(extracted_code)
|
|
395
|
+
self._notify_action(__("🧪 Auto-Patch: Code extracted from message #{n} and saved to {file}!", n=self._focused_msg_index + 1, file=export_file))
|
|
396
|
+
else:
|
|
397
|
+
self._notify_action(__("❌ No code blocks found in message #{n}.", n=self._focused_msg_index + 1), severity="warning")
|
|
398
|
+
|
|
399
|
+
def action_export_focused_msg(self):
|
|
400
|
+
"""Ctrlhift+E: export the focused AI message to a Markdown file."""
|
|
401
|
+
if not self._focused_msg_content:
|
|
402
|
+
self._notify_action(__("❌ No AI message focused. Use F7/F8 to select one."), severity="warning")
|
|
403
|
+
return
|
|
404
|
+
key = ''.join(random.choices(string.ascii_letters + string.digits, k=3)) + '-' + ''.join(random.choices(string.ascii_letters + string.digits, k=3))
|
|
405
|
+
export_file = f"MESSAGE_{self.memory.session_uuid}_{key}.md"
|
|
406
|
+
with open(export_file, "w", encoding="utf-8") as f:
|
|
407
|
+
f.write(self._focused_msg_content)
|
|
408
|
+
self._notify_action(__("📤 Message #{n} exported to {file}!", n=self._focused_msg_index + 1, file=export_file))
|
|
409
|
+
|
|
410
|
+
# ── Input handlers ────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
413
|
+
"""Filter slash-command suggestions as the user types."""
|
|
414
|
+
self._cmd_suggestions.filter_commands(event.value)
|
|
415
|
+
|
|
416
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
417
|
+
"""Fill the input with the clicked/highlighted slash command."""
|
|
418
|
+
cmd = self._cmd_suggestions.get_selected_command()
|
|
419
|
+
if cmd:
|
|
420
|
+
input_widget = self.query_one("#chat_input", Input)
|
|
421
|
+
input_widget.value = cmd + " "
|
|
422
|
+
input_widget.focus()
|
|
423
|
+
self._cmd_suggestions.display = False
|
|
424
|
+
|
|
425
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
426
|
+
"""Capture the user's Enter key."""
|
|
427
|
+
user_text = event.value.strip()
|
|
428
|
+
if not user_text:
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
# Auto-complete slash command from the highlighted or first suggestion
|
|
432
|
+
if user_text.startswith("/"):
|
|
433
|
+
selected = self._cmd_suggestions.get_selected_command()
|
|
434
|
+
if selected and selected.startswith(user_text) and selected != user_text:
|
|
435
|
+
user_text = selected
|
|
436
|
+
|
|
437
|
+
# Hide suggestions after submission
|
|
438
|
+
self._cmd_suggestions.display = False
|
|
439
|
+
|
|
440
|
+
# Clear the input
|
|
441
|
+
input_widget = self.query_one("#chat_input", Input)
|
|
442
|
+
input_widget.value = ""
|
|
443
|
+
|
|
444
|
+
# Intercept dynamic commands (Phase 2)
|
|
445
|
+
is_cmd, is_clear, processed_msg = process_chat_command(user_text)
|
|
446
|
+
|
|
447
|
+
if is_clear:
|
|
448
|
+
# Recreate the session to clear memory using the current diff
|
|
449
|
+
current_diff = self.memory.get_latest_diff()
|
|
450
|
+
diff_md5 = self.memory._generate_md5(current_diff)
|
|
451
|
+
self.memory._create_new_session(current_diff, diff_md5)
|
|
452
|
+
|
|
453
|
+
# Clear the screen visually
|
|
454
|
+
container = self.query_one("#chat_container")
|
|
455
|
+
await container.query("*").remove()
|
|
456
|
+
self.sub_title = f"{self.memory.repo_name} | Branch: {self.memory.branch_name} | ID: {self.memory.session_uuid}"
|
|
457
|
+
self.add_message("system", __("🧹 Conversation cleared. A new session has started."))
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
# Display the user's message on screen (if it's a command, the screen shows the raw command, but the AI reads the processed one)
|
|
461
|
+
self.add_message("user", user_text)
|
|
462
|
+
|
|
463
|
+
# Save the processed message to persistent memory
|
|
464
|
+
self.memory.save_message("user", processed_msg if is_cmd else user_text)
|
|
465
|
+
|
|
466
|
+
# Show the animated thinking indicator (braille spinner + word discovery)
|
|
467
|
+
container = self.query_one("#chat_container")
|
|
468
|
+
self._thinking_widget = Static(" ⠋", classes="message system")
|
|
469
|
+
container.mount(self._thinking_widget)
|
|
470
|
+
self._thinking_state = {
|
|
471
|
+
"braille_idx": 0,
|
|
472
|
+
"word": random.choice(THINKING_WORDS),
|
|
473
|
+
"discovered": "",
|
|
474
|
+
"char_step": 0,
|
|
475
|
+
"dots_cycle": 0,
|
|
476
|
+
}
|
|
477
|
+
self._thinking_timer = self.set_interval(0.08, self._animate_thinking)
|
|
478
|
+
container.scroll_end(animate=True)
|
|
479
|
+
|
|
480
|
+
# Call the API asynchronously to avoid freezing the interface
|
|
481
|
+
self.call_ai_background()
|
|
482
|
+
|
|
483
|
+
@work(exclusive=True, thread=True)
|
|
484
|
+
def call_ai_background(self) -> None:
|
|
485
|
+
"""Run the AI in the background (Thread) to keep the UI responsive."""
|
|
486
|
+
history = self.memory.get_history()
|
|
487
|
+
|
|
488
|
+
# Since we already saved the user's message in history,
|
|
489
|
+
# we separate the last message from the rest to send to the Phase 2 function.
|
|
490
|
+
history_to_send = history[:-1]
|
|
491
|
+
new_message = history[-1]["content"]
|
|
492
|
+
|
|
493
|
+
response = call_ai_chat(
|
|
494
|
+
provider=self.provider,
|
|
495
|
+
api_key=self.api_key,
|
|
496
|
+
api_model=self.api_model,
|
|
497
|
+
system_instruction=self.system_instruction,
|
|
498
|
+
chat_history=history_to_send,
|
|
499
|
+
new_message=new_message,
|
|
500
|
+
quiet=True # Don't print terminal loading (sys.stdout) since we're in a TUI
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Update the interface from the main thread
|
|
504
|
+
def update_ui(result):
|
|
505
|
+
# Remove the animated thinking indicator
|
|
506
|
+
if self._thinking_timer:
|
|
507
|
+
self._thinking_timer.stop()
|
|
508
|
+
self._thinking_timer = None
|
|
509
|
+
if self._thinking_widget:
|
|
510
|
+
self._thinking_widget.remove()
|
|
511
|
+
self._thinking_widget = None
|
|
512
|
+
self._thinking_state = {}
|
|
513
|
+
|
|
514
|
+
if result:
|
|
515
|
+
self.memory.save_message("assistant", result)
|
|
516
|
+
self.add_message("assistant", result)
|
|
517
|
+
else:
|
|
518
|
+
self.add_message("system", __("❌ Failed to get response from AI."))
|
|
519
|
+
|
|
520
|
+
self.call_from_thread(update_ui, response)
|
|
521
|
+
|
|
522
|
+
def action_show_help(self):
|
|
523
|
+
"""F1 shortcut: Show the help modal with shortcuts and slash commands."""
|
|
524
|
+
self.push_screen(ChatHelpScreen())
|
|
525
|
+
|
|
526
|
+
def action_refresh_diff(self):
|
|
527
|
+
"""F2 shortcut: Update the chat context with the latest code diff."""
|
|
528
|
+
new_diff = get_git_diff(quiet=True)
|
|
529
|
+
updated = self.memory.update_diff_if_changed(new_diff)
|
|
530
|
+
if updated:
|
|
531
|
+
self.add_message("system", __("🔄 Diff updated! The AI now sees your latest changes."))
|
|
532
|
+
else:
|
|
533
|
+
self.add_message("system", __("✅ Diff is already up to date."))
|
|
534
|
+
|
|
535
|
+
def action_apply_code(self):
|
|
536
|
+
"""F5 shortcut: Extract the last AI code block and save it to a suggestion file."""
|
|
537
|
+
history = self.memory.get_history()
|
|
538
|
+
|
|
539
|
+
# Filter only the AI messages
|
|
540
|
+
ai_messages = [m for m in history if m["role"] == "assistant"]
|
|
541
|
+
if not ai_messages:
|
|
542
|
+
self._notify_action(__("❌ No AI responses available to extract code from."), severity="warning")
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
last_msg = ai_messages[-1]["content"]
|
|
546
|
+
|
|
547
|
+
# Match triple-backtick code blocks: ```python, ``` python, ```, etc.
|
|
548
|
+
code_blocks = re.findall(r'`{3}\s*(?:\w+)?\s*\n(.*?)`{3}', last_msg, re.DOTALL)
|
|
549
|
+
|
|
550
|
+
# Fallback: split by triple backticks and take odd-indexed parts
|
|
551
|
+
if not code_blocks:
|
|
552
|
+
parts = last_msg.split("```")
|
|
553
|
+
for i in range(1, len(parts), 2):
|
|
554
|
+
block = parts[i].strip()
|
|
555
|
+
if block:
|
|
556
|
+
# Strip language identifier from first line if present
|
|
557
|
+
first_line_end = block.find("\n")
|
|
558
|
+
if first_line_end > 0 and first_line_end < 20:
|
|
559
|
+
first_line = block[:first_line_end].strip()
|
|
560
|
+
if first_line and not " " in first_line:
|
|
561
|
+
block = block[first_line_end + 1 :]
|
|
562
|
+
code_blocks.append(block.strip())
|
|
563
|
+
|
|
564
|
+
if code_blocks:
|
|
565
|
+
extracted_code = "\n\n".join(code_blocks)
|
|
566
|
+
key = ''.join(random.choices(string.ascii_letters + string.digits, k=3)) + '-' + ''.join(random.choices(string.ascii_letters + string.digits, k=3))
|
|
567
|
+
export_file = f"GITPR_PATCH_SUGGESTION_{key}.txt"
|
|
568
|
+
with open(export_file, "w", encoding="utf-8") as f:
|
|
569
|
+
f.write(extracted_code)
|
|
570
|
+
self._notify_action(__("⚡ Auto-Patch: Code extracted and saved to {file}!", file=export_file))
|
|
571
|
+
else:
|
|
572
|
+
self._notify_action(__("❌ No code blocks found in the last AI message."), severity="warning")
|
|
573
|
+
|
|
574
|
+
def action_export_session(self):
|
|
575
|
+
"""F6 shortcut: Export the entire conversation to a structured Markdown file at the project root."""
|
|
576
|
+
history = self.memory.get_history()
|
|
577
|
+
export_text = f"# GitPR Chat Session Export\n**Repo:** {self.memory.repo_name} | **Branch:** {self.memory.branch_name}\n\n"
|
|
578
|
+
|
|
579
|
+
for msg in history:
|
|
580
|
+
role_icon = "🧑💻 User" if msg["role"] == "user" else "🤖 AI Assistant" if msg["role"] == "assistant" else "⚙️ System"
|
|
581
|
+
export_text += f"### {role_icon}\n{msg['content']}\n\n---\n\n"
|
|
582
|
+
|
|
583
|
+
export_file = f"GITPR_CHAT_EXPORT_{self.memory.session_uuid}.md"
|
|
584
|
+
with open(export_file, "w", encoding="utf-8") as f:
|
|
585
|
+
f.write(export_text)
|
|
586
|
+
|
|
587
|
+
self._notify_action(__("📤 Session exported successfully to {file}!", file=export_file))
|
src/updater.py
CHANGED
|
@@ -7,8 +7,8 @@ import click
|
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
|
|
9
9
|
# Current version of your local executable (Update this on every new build!)
|
|
10
|
-
__version__ = "0.0.
|
|
11
|
-
__lang_version__ = "v0.0.
|
|
10
|
+
__version__ = "0.0.26" # GitPR current version
|
|
11
|
+
__lang_version__ = "v0.0.4" # Language dictionary version control
|
|
12
12
|
GITHUB_API_URL = "https://api.github.com/repos/natanfiuza/gitpr/releases/latest"
|
|
13
13
|
PYPI_API_URL = "https://pypi.org/pypi/gitpr-cli/json"
|
|
14
14
|
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
gitpr_cli-0.0.25.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
|
|
2
|
-
src/__init__.py,sha256=U6ksarernNcryHO20aj4rNG7w0thT7xdMs5KzBJyPvE,28
|
|
3
|
-
src/ai_providers.py,sha256=rf4IJTG6P8nLKU-C-BdUdy68JvhnsgzvhEsJdp-9El0,3477
|
|
4
|
-
src/blame_engine.py,sha256=Ci7t08xTcC1kBWAjQ_ulP-Zraau_PC35MDfAngc4AYk,10396
|
|
5
|
-
src/cache.py,sha256=XggMTy00GlZfeF1VoLEYCdxL5h0eTg11TJfPIRPx7fs,3201
|
|
6
|
-
src/config.py,sha256=VJ9ekDlTVG5ETWDuO1kZWlgrncXbrvduxa5Zg7wN_ZY,9064
|
|
7
|
-
src/core.py,sha256=MtcbftxhFFADNKzf6YBImeeTwW3oFxkQgvUArv-aHLA,18246
|
|
8
|
-
src/i18n.py,sha256=Bn1OrhCYEaT0kUidu5SeTu6kiIuWOZ4OjB-7mIb6RtY,3185
|
|
9
|
-
src/issue_engine.py,sha256=435kPPgdsFMcY3nvutxdIfJAnLP0U4FMCPdbok6C2sc,3626
|
|
10
|
-
src/linter_engine.py,sha256=D04wvOgRa-vZEYknm-z-Cg7V_3gnoQz_64PMorWnISo,4414
|
|
11
|
-
src/main.py,sha256=k4WNlrqWHZqR1t7-IGfKUpmlxjTyp17dulP_cS01jho,27734
|
|
12
|
-
src/security.py,sha256=Li0zM8ZURTK2nKRHIt49TWLO4DIddBEHzXPrVycv0Rs,1343
|
|
13
|
-
src/spinner.py,sha256=LeawExgx7lnHUvyuIlEx0Bc4WlAfC5KzSRWMEiu-ufY,6331
|
|
14
|
-
src/tui_issue.py,sha256=gcAJCIfMcdeMjrFJUWQQvdgBv7fnOBtxQxwdBk2Sk40,1789
|
|
15
|
-
src/updater.py,sha256=JEzyGu6pcdrKDziCSU8iM-yjStC-3ii6o76GUHfj3Zw,5886
|
|
16
|
-
src/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
-
src/ui/help_screen.py,sha256=tjbokXhwyzLnkOC0S56NbpX-78hcFQnvsQMpNU1wt0c,1862
|
|
18
|
-
src/ui/issue_app.py,sha256=8MdBcOVdCHPFx6EUhs80CiZ2G-29KulskkCz9qMZ_Lg,4737
|
|
19
|
-
gitpr_cli-0.0.25.dist-info/METADATA,sha256=EcoI5mi-eX_wE8qpmmN1AuVC6RGDDVzparNEIUPyVwQ,15488
|
|
20
|
-
gitpr_cli-0.0.25.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
21
|
-
gitpr_cli-0.0.25.dist-info/entry_points.txt,sha256=mh_4F_idM4cj0XhfRGBDQb5Nnku93YJ-dyKh5SufwlE,39
|
|
22
|
-
gitpr_cli-0.0.25.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
|
|
23
|
-
gitpr_cli-0.0.25.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|