stackfix 0.2.0
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.
- package/README.md +111 -0
- package/bin/stackfix +56 -0
- package/package.json +39 -0
- package/pyproject.toml +23 -0
- package/scripts/parse_selftest.py +13 -0
- package/scripts/postinstall.js +130 -0
- package/stackfix/__init__.py +1 -0
- package/stackfix/__main__.py +4 -0
- package/stackfix/__pycache__/__init__.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/__main__.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/agent.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/agents.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/cli.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/context.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/history.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/patching.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/safety.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/session.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/tui.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/util.cpython-314.pyc +0 -0
- package/stackfix/agent.py +298 -0
- package/stackfix/agents.py +29 -0
- package/stackfix/cli.py +169 -0
- package/stackfix/context.py +73 -0
- package/stackfix/history.py +32 -0
- package/stackfix/patching.py +138 -0
- package/stackfix/safety.py +60 -0
- package/stackfix/session.py +40 -0
- package/stackfix/tui.py +591 -0
- package/stackfix/util.py +54 -0
package/stackfix/tui.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
10
|
+
from textual.widgets import Input, RichLog, Static
|
|
11
|
+
|
|
12
|
+
from .agent import call_agent
|
|
13
|
+
from .context import collect_context
|
|
14
|
+
from .history import read_last, write_history
|
|
15
|
+
from .session import new_session_id, save_session, load_session, list_sessions
|
|
16
|
+
from .agents import load_agents_instructions
|
|
17
|
+
from .patching import apply_patch
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Slash command definitions for /help
|
|
21
|
+
SLASH_COMMANDS = [
|
|
22
|
+
("/exit", "Exit the TUI"),
|
|
23
|
+
("/clear", "Clear the log panel"),
|
|
24
|
+
("/model <name>", "View or switch the model"),
|
|
25
|
+
("/approvals <mode>", "Set approval mode (suggest, auto-edit, full-auto)"),
|
|
26
|
+
("/status", "Show current configuration"),
|
|
27
|
+
("/history", "Show last run summary and patch"),
|
|
28
|
+
("/plan", "Show current execution plan"),
|
|
29
|
+
("/diff", "Show pending git changes"),
|
|
30
|
+
("/help", "Show this help message"),
|
|
31
|
+
("/skills", "List available skills"),
|
|
32
|
+
("/compact", "Summarize session (stub)"),
|
|
33
|
+
("/new", "Start a new session"),
|
|
34
|
+
("/resume <id>", "Resume a saved session"),
|
|
35
|
+
("/sessions", "List saved sessions"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _highlight_diff(diff_text: str) -> str:
|
|
40
|
+
"""Apply syntax highlighting to a unified diff."""
|
|
41
|
+
lines = []
|
|
42
|
+
for line in diff_text.split("\n"):
|
|
43
|
+
if line.startswith("+++ ") or line.startswith("--- "):
|
|
44
|
+
lines.append(f"[bold cyan]{line}[/bold cyan]")
|
|
45
|
+
elif line.startswith("@@"):
|
|
46
|
+
lines.append(f"[magenta]{line}[/magenta]")
|
|
47
|
+
elif line.startswith("+"):
|
|
48
|
+
lines.append(f"[green]{line}[/green]")
|
|
49
|
+
elif line.startswith("-"):
|
|
50
|
+
lines.append(f"[red]{line}[/red]")
|
|
51
|
+
elif line.startswith("diff --git"):
|
|
52
|
+
lines.append(f"[bold blue]{line}[/bold blue]")
|
|
53
|
+
else:
|
|
54
|
+
lines.append(f"[dim]{line}[/dim]")
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class StackFixTUI(App):
|
|
59
|
+
CSS = """
|
|
60
|
+
Screen {
|
|
61
|
+
background: #000000;
|
|
62
|
+
color: #e6e6e6;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
VerticalScroll {
|
|
66
|
+
padding: 0 4;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
RichLog {
|
|
70
|
+
background: #000000;
|
|
71
|
+
color: #e6e6e6;
|
|
72
|
+
border: none;
|
|
73
|
+
height: auto;
|
|
74
|
+
min-height: 1;
|
|
75
|
+
max-height: 100%;
|
|
76
|
+
padding: 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#splash {
|
|
80
|
+
padding: 1 0 0 0;
|
|
81
|
+
height: auto;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#tip {
|
|
85
|
+
color: #e6e6e6;
|
|
86
|
+
padding: 1 0 1 0;
|
|
87
|
+
height: auto;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#prompt-row {
|
|
91
|
+
height: 1;
|
|
92
|
+
margin: 1 0 0 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#prompt-label {
|
|
96
|
+
width: 2;
|
|
97
|
+
color: #e6e6e6;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#prompt-input {
|
|
101
|
+
border: none;
|
|
102
|
+
background: transparent;
|
|
103
|
+
color: #e6e6e6;
|
|
104
|
+
padding: 0;
|
|
105
|
+
height: 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#prompt-input:focus {
|
|
109
|
+
border: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#status-line {
|
|
113
|
+
color: #6b7280;
|
|
114
|
+
padding: 0 0 1 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.accent {
|
|
118
|
+
color: #3b82f6;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.dim {
|
|
122
|
+
color: #6b7280;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.bold {
|
|
126
|
+
text-style: bold;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.phase {
|
|
130
|
+
color: #94a3b8;
|
|
131
|
+
}
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
BINDINGS = [
|
|
135
|
+
("ctrl+c", "quit", "Quit"),
|
|
136
|
+
("ctrl+l", "clear", "Clear"),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def __init__(self) -> None:
|
|
140
|
+
super().__init__()
|
|
141
|
+
self._log: Optional[RichLog] = None
|
|
142
|
+
self._awaiting_confirm = False
|
|
143
|
+
self._pending_cmd: Optional[List[str]] = None
|
|
144
|
+
self._pending_agent = None
|
|
145
|
+
self._last_plan: List[str] = []
|
|
146
|
+
self._splash: Optional[Static] = None
|
|
147
|
+
self._tip: Optional[Static] = None
|
|
148
|
+
self._session_id = new_session_id()
|
|
149
|
+
self._approvals_mode = "suggest"
|
|
150
|
+
self._last_prompt: Optional[str] = None
|
|
151
|
+
self._current_phase: str = "Ready"
|
|
152
|
+
|
|
153
|
+
def compose(self) -> ComposeResult:
|
|
154
|
+
with VerticalScroll():
|
|
155
|
+
self._splash = Static(self._render_splash(), id="splash", markup=True)
|
|
156
|
+
yield self._splash
|
|
157
|
+
self._tip = Static(self._render_tip(), id="tip", markup=True)
|
|
158
|
+
yield self._tip
|
|
159
|
+
self._log = RichLog(highlight=False, wrap=True)
|
|
160
|
+
yield self._log
|
|
161
|
+
with Horizontal(id="prompt-row"):
|
|
162
|
+
yield Static(">", id="prompt-label")
|
|
163
|
+
yield Input(placeholder="Type a prompt, !cmd, or /command", id="prompt-input")
|
|
164
|
+
self._status_bar = Static(self._render_status(), id="status-line", markup=True)
|
|
165
|
+
yield self._status_bar
|
|
166
|
+
|
|
167
|
+
def on_mount(self) -> None:
|
|
168
|
+
self.query_one(Input).focus()
|
|
169
|
+
|
|
170
|
+
def _render_splash(self) -> str:
|
|
171
|
+
cwd = os.getcwd()
|
|
172
|
+
if cwd == os.path.expanduser("~"):
|
|
173
|
+
cwd = "~"
|
|
174
|
+
model = os.environ.get("MODEL_NAME", "gpt-4")
|
|
175
|
+
version = "v0.2.0"
|
|
176
|
+
|
|
177
|
+
# Simpler, cleaner splash
|
|
178
|
+
return (
|
|
179
|
+
f"\n [bold]StackFix[/bold] [dim]{version}[/dim]\n"
|
|
180
|
+
f" [dim]model:[/dim] {model}\n"
|
|
181
|
+
f" [dim]cwd:[/dim] {cwd}\n"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _render_tip(self) -> str:
|
|
185
|
+
return "[bold]Tip:[/bold] Use [bold]/skills[/bold] to list available skills or ask StackFix to use one."
|
|
186
|
+
|
|
187
|
+
def _render_status(self) -> str:
|
|
188
|
+
phase_style = "[bold cyan]" if self._current_phase != "Ready" else "[dim]"
|
|
189
|
+
return f" {phase_style}• {self._current_phase}[/] · [dim]100% context left[/dim] · [dim]? for shortcuts[/dim]"
|
|
190
|
+
|
|
191
|
+
def action_clear(self) -> None:
|
|
192
|
+
if self._log:
|
|
193
|
+
self._log.clear()
|
|
194
|
+
|
|
195
|
+
def _log_line(self, text: str) -> None:
|
|
196
|
+
if self._log:
|
|
197
|
+
self._log.write(text)
|
|
198
|
+
|
|
199
|
+
def _phase(self, name: str) -> None:
|
|
200
|
+
self._current_phase = name
|
|
201
|
+
if hasattr(self, "_status_bar") and self._status_bar:
|
|
202
|
+
self._status_bar.update(self._render_status())
|
|
203
|
+
|
|
204
|
+
def _set_plan(self, steps: List[str]) -> None:
|
|
205
|
+
self._last_plan = steps
|
|
206
|
+
|
|
207
|
+
def _show_plan(self) -> None:
|
|
208
|
+
if not self._last_plan:
|
|
209
|
+
self._log_line("[dim]No plan available.[/dim]")
|
|
210
|
+
return
|
|
211
|
+
self._log_line("[bold]Current plan:[/bold]")
|
|
212
|
+
for step in self._last_plan:
|
|
213
|
+
self._log_line(f" [dim]- {step}[/dim]")
|
|
214
|
+
|
|
215
|
+
def _show_history(self) -> None:
|
|
216
|
+
last = read_last(os.getcwd())
|
|
217
|
+
if not last:
|
|
218
|
+
self._log_line("No history found.")
|
|
219
|
+
return
|
|
220
|
+
self._log_line("Last run summary:")
|
|
221
|
+
self._log_line(last.get("summary", ""))
|
|
222
|
+
patch = last.get("patch", "")
|
|
223
|
+
if patch:
|
|
224
|
+
self._log_line("\nPatch:")
|
|
225
|
+
self._log_line(patch)
|
|
226
|
+
|
|
227
|
+
def _show_status(self) -> None:
|
|
228
|
+
cwd = os.getcwd()
|
|
229
|
+
model = os.environ.get("MODEL_NAME", "unset")
|
|
230
|
+
endpoint = os.environ.get("STACKFIX_ENDPOINT", "direct")
|
|
231
|
+
self._log_line(f"cwd: {cwd}")
|
|
232
|
+
self._log_line(f"model: {model}")
|
|
233
|
+
self._log_line(f"endpoint: {endpoint}")
|
|
234
|
+
self._log_line(f"approvals: {self._approvals_mode}")
|
|
235
|
+
self._log_line(f"session: {self._session_id}")
|
|
236
|
+
|
|
237
|
+
def _show_sessions(self) -> None:
|
|
238
|
+
sessions = list_sessions(os.getcwd())
|
|
239
|
+
if not sessions:
|
|
240
|
+
self._log_line("No saved sessions.")
|
|
241
|
+
return
|
|
242
|
+
self._log_line("Sessions:")
|
|
243
|
+
for sid in sessions[-10:]:
|
|
244
|
+
self._log_line(f"- {sid}")
|
|
245
|
+
|
|
246
|
+
def _show_diff(self) -> None:
|
|
247
|
+
"""Show pending git changes."""
|
|
248
|
+
cwd = os.getcwd()
|
|
249
|
+
try:
|
|
250
|
+
result = subprocess.run(
|
|
251
|
+
["git", "diff", "HEAD"],
|
|
252
|
+
cwd=cwd,
|
|
253
|
+
capture_output=True,
|
|
254
|
+
text=True,
|
|
255
|
+
timeout=10,
|
|
256
|
+
)
|
|
257
|
+
diff = result.stdout.strip()
|
|
258
|
+
if not diff:
|
|
259
|
+
self._log_line("[dim]No pending changes.[/dim]")
|
|
260
|
+
return
|
|
261
|
+
self._log_line("[bold]Pending changes:[/bold]")
|
|
262
|
+
self._log_line(_highlight_diff(diff))
|
|
263
|
+
except FileNotFoundError:
|
|
264
|
+
self._log_line("[red]git not found. /diff requires git.[/red]")
|
|
265
|
+
except subprocess.TimeoutExpired:
|
|
266
|
+
self._log_line("[red]git diff timed out.[/red]")
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
self._log_line(f"[red]Error: {exc}[/red]")
|
|
269
|
+
|
|
270
|
+
def _show_help(self) -> None:
|
|
271
|
+
"""Show available slash commands."""
|
|
272
|
+
self._log_line("[bold]Available commands:[/bold]")
|
|
273
|
+
for cmd, desc in SLASH_COMMANDS:
|
|
274
|
+
self._log_line(f" [accent]{cmd:<20}[/accent] {desc}")
|
|
275
|
+
|
|
276
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
277
|
+
text = event.value.strip()
|
|
278
|
+
event.input.value = ""
|
|
279
|
+
if not text:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
if self._awaiting_confirm:
|
|
283
|
+
self._handle_confirm(text)
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
if text.startswith("/"):
|
|
287
|
+
self._handle_slash_command(text)
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
if text.startswith("!"):
|
|
291
|
+
cmd = shlex.split(text[1:].strip())
|
|
292
|
+
if not cmd:
|
|
293
|
+
self._log_line("No command provided.")
|
|
294
|
+
return
|
|
295
|
+
self._run_command(cmd)
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
self._last_prompt = text
|
|
299
|
+
self._run_prompt(text)
|
|
300
|
+
|
|
301
|
+
def _handle_slash_command(self, text: str) -> None:
|
|
302
|
+
raw = text.strip()
|
|
303
|
+
cmd = raw.lower()
|
|
304
|
+
if cmd == "/exit":
|
|
305
|
+
self.exit()
|
|
306
|
+
return
|
|
307
|
+
if cmd == "/clear":
|
|
308
|
+
self.action_clear()
|
|
309
|
+
return
|
|
310
|
+
if cmd == "/plan":
|
|
311
|
+
self._show_plan()
|
|
312
|
+
return
|
|
313
|
+
if cmd == "/history":
|
|
314
|
+
self._show_history()
|
|
315
|
+
return
|
|
316
|
+
if cmd == "/status":
|
|
317
|
+
self._show_status()
|
|
318
|
+
return
|
|
319
|
+
if cmd.startswith("/model"):
|
|
320
|
+
parts = raw.split(maxsplit=1)
|
|
321
|
+
if len(parts) == 2:
|
|
322
|
+
os.environ["MODEL_NAME"] = parts[1].strip()
|
|
323
|
+
if self._splash:
|
|
324
|
+
self._splash.update(self._render_splash())
|
|
325
|
+
self._log_line(f"Model set to {parts[1].strip()}")
|
|
326
|
+
else:
|
|
327
|
+
self._log_line(f"Current model: {os.environ.get('MODEL_NAME', 'unset')}")
|
|
328
|
+
return
|
|
329
|
+
if cmd.startswith("/approvals"):
|
|
330
|
+
parts = raw.split(maxsplit=1)
|
|
331
|
+
if len(parts) == 2:
|
|
332
|
+
mode = parts[1].strip().lower()
|
|
333
|
+
if mode not in ["suggest", "auto-edit", "full-auto"]:
|
|
334
|
+
self._log_line("Approvals mode must be: suggest | auto-edit | full-auto")
|
|
335
|
+
else:
|
|
336
|
+
self._approvals_mode = mode
|
|
337
|
+
self._log_line(f"Approvals mode set to {mode}")
|
|
338
|
+
else:
|
|
339
|
+
self._log_line(f"Approvals mode: {self._approvals_mode}")
|
|
340
|
+
return
|
|
341
|
+
if cmd == "/skills":
|
|
342
|
+
self._log_line("Skills: review (stub), compact (stub), mcp (stub)")
|
|
343
|
+
return
|
|
344
|
+
if cmd == "/compact":
|
|
345
|
+
self._log_line("Compact: not implemented yet.")
|
|
346
|
+
return
|
|
347
|
+
if cmd == "/new":
|
|
348
|
+
self._session_id = new_session_id()
|
|
349
|
+
self._log_line(f"New session: {self._session_id}")
|
|
350
|
+
return
|
|
351
|
+
if cmd.startswith("/resume"):
|
|
352
|
+
parts = raw.split(maxsplit=1)
|
|
353
|
+
if len(parts) != 2:
|
|
354
|
+
self._show_sessions()
|
|
355
|
+
return
|
|
356
|
+
sid = parts[1].strip()
|
|
357
|
+
session = load_session(os.getcwd(), sid)
|
|
358
|
+
if not session:
|
|
359
|
+
self._log_line(f"Session not found: {sid}")
|
|
360
|
+
return
|
|
361
|
+
self._session_id = sid
|
|
362
|
+
self._log_line(f"Resumed session: {sid}")
|
|
363
|
+
return
|
|
364
|
+
if cmd == "/sessions":
|
|
365
|
+
self._show_sessions()
|
|
366
|
+
return
|
|
367
|
+
if cmd == "/diff":
|
|
368
|
+
self._show_diff()
|
|
369
|
+
return
|
|
370
|
+
if cmd == "/help" or cmd == "/?":
|
|
371
|
+
self._show_help()
|
|
372
|
+
return
|
|
373
|
+
self._log_line(f"Unknown command: {cmd}. Type /help for available commands.")
|
|
374
|
+
|
|
375
|
+
def _handle_confirm(self, text: str) -> None:
|
|
376
|
+
self._awaiting_confirm = False
|
|
377
|
+
if text.strip().lower() != "y":
|
|
378
|
+
self._log_line("Patch not applied.")
|
|
379
|
+
record = {
|
|
380
|
+
"command": self._pending_cmd,
|
|
381
|
+
"exit_code": 1,
|
|
382
|
+
"summary": self._pending_agent.get("summary", "") if self._pending_agent else "",
|
|
383
|
+
"patch": self._pending_agent.get("patch_unified_diff", "") if self._pending_agent else "",
|
|
384
|
+
"rerun_exit_code": None,
|
|
385
|
+
"applied": False,
|
|
386
|
+
}
|
|
387
|
+
write_history(os.getcwd(), record)
|
|
388
|
+
self._pending_cmd = None
|
|
389
|
+
self._pending_agent = None
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
self._phase("Validation / Summary")
|
|
393
|
+
self._log_line("Applying patch...")
|
|
394
|
+
self.run_worker(self._apply_and_rerun, thread=True)
|
|
395
|
+
|
|
396
|
+
def _run_command(self, cmd: List[str]) -> None:
|
|
397
|
+
self._phase("Planning")
|
|
398
|
+
self._log_line(f"[bold cyan]> {shlex.join(cmd)}[/bold cyan]")
|
|
399
|
+
self._set_plan([
|
|
400
|
+
"Run command",
|
|
401
|
+
"Collect context",
|
|
402
|
+
"Propose fix",
|
|
403
|
+
"Validate outcome",
|
|
404
|
+
])
|
|
405
|
+
self.run_worker(lambda: self._command_flow(cmd), thread=True)
|
|
406
|
+
|
|
407
|
+
def _run_prompt(self, prompt: str) -> None:
|
|
408
|
+
self._phase("Planning")
|
|
409
|
+
self._log_line(f"[bold cyan]> {prompt}[/bold cyan]")
|
|
410
|
+
self._set_plan(["Answer prompt", "Summarize response"])
|
|
411
|
+
self.run_worker(lambda: self._prompt_flow(prompt), thread=True)
|
|
412
|
+
|
|
413
|
+
def _command_flow(self, cmd: List[str]) -> None:
|
|
414
|
+
cwd = os.getcwd()
|
|
415
|
+
stdout_chunks: List[str] = []
|
|
416
|
+
stderr_chunks: List[str] = []
|
|
417
|
+
|
|
418
|
+
def _stream(pipe, sink, is_err: bool) -> None:
|
|
419
|
+
for line in iter(pipe.readline, ""):
|
|
420
|
+
sink.append(line)
|
|
421
|
+
prefix = "stderr" if is_err else "stdout"
|
|
422
|
+
self.call_from_thread(self._log_line, f"[{prefix}] {line.rstrip()}" if line.strip() else "")
|
|
423
|
+
pipe.close()
|
|
424
|
+
|
|
425
|
+
proc = subprocess.Popen(
|
|
426
|
+
cmd,
|
|
427
|
+
cwd=cwd,
|
|
428
|
+
stdout=subprocess.PIPE,
|
|
429
|
+
stderr=subprocess.PIPE,
|
|
430
|
+
text=True,
|
|
431
|
+
bufsize=1,
|
|
432
|
+
universal_newlines=True,
|
|
433
|
+
)
|
|
434
|
+
t_out = threading.Thread(target=_stream, args=(proc.stdout, stdout_chunks, False))
|
|
435
|
+
t_err = threading.Thread(target=_stream, args=(proc.stderr, stderr_chunks, True))
|
|
436
|
+
t_out.start()
|
|
437
|
+
t_err.start()
|
|
438
|
+
proc.wait()
|
|
439
|
+
t_out.join()
|
|
440
|
+
t_err.join()
|
|
441
|
+
|
|
442
|
+
exit_code = proc.returncode
|
|
443
|
+
stdout = "".join(stdout_chunks)
|
|
444
|
+
stderr = "".join(stderr_chunks)
|
|
445
|
+
|
|
446
|
+
if exit_code == 0:
|
|
447
|
+
record = {
|
|
448
|
+
"command": cmd,
|
|
449
|
+
"exit_code": exit_code,
|
|
450
|
+
"summary": "Command succeeded; no patch applied.",
|
|
451
|
+
"patch": "",
|
|
452
|
+
"rerun_exit_code": None,
|
|
453
|
+
}
|
|
454
|
+
write_history(cwd, record)
|
|
455
|
+
write_history(cwd, record)
|
|
456
|
+
self.call_from_thread(self._phase, "Ready")
|
|
457
|
+
self.call_from_thread(self._log_line, "[green]Command succeeded.[/green]")
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
self.call_from_thread(self._phase, "Exploring")
|
|
461
|
+
context = collect_context(cwd, cmd, exit_code, stdout, stderr)
|
|
462
|
+
agents = load_agents_instructions(cwd)
|
|
463
|
+
if agents:
|
|
464
|
+
context["agent_instructions"] = agents
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
agent_result = call_agent(context)
|
|
468
|
+
except Exception as exc:
|
|
469
|
+
self.call_from_thread(self._log_line, f"Agent call failed: {exc}")
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
warning = agent_result.get("_warning")
|
|
473
|
+
if warning:
|
|
474
|
+
self.call_from_thread(self._log_line, f"Warning: {warning}")
|
|
475
|
+
|
|
476
|
+
self._pending_cmd = cmd
|
|
477
|
+
self._pending_agent = agent_result
|
|
478
|
+
|
|
479
|
+
summary = agent_result.get("summary", "")
|
|
480
|
+
patch = agent_result.get("patch_unified_diff", "")
|
|
481
|
+
|
|
482
|
+
self.call_from_thread(self._phase, "Review")
|
|
483
|
+
self.call_from_thread(self._log_line, f"\n{summary}")
|
|
484
|
+
|
|
485
|
+
if patch:
|
|
486
|
+
self.call_from_thread(self._log_line, "\n[bold]Patch preview[/bold]\n")
|
|
487
|
+
highlighted = _highlight_diff(patch)
|
|
488
|
+
self.call_from_thread(self._log_line, highlighted)
|
|
489
|
+
else:
|
|
490
|
+
self.call_from_thread(self._log_line, "\n[dim]No patch provided by agent.[/dim]")
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
if self._approvals_mode == "full-auto":
|
|
494
|
+
self._phase("Validation / Summary")
|
|
495
|
+
self._log_line("Auto-apply enabled; applying patch...")
|
|
496
|
+
self.run_worker(self._apply_and_rerun, thread=True)
|
|
497
|
+
else:
|
|
498
|
+
self.call_from_thread(self._log_line, "\nApply patch? [y/N]")
|
|
499
|
+
self._awaiting_confirm = True
|
|
500
|
+
|
|
501
|
+
def _apply_and_rerun(self) -> None:
|
|
502
|
+
cwd = os.getcwd()
|
|
503
|
+
agent = self._pending_agent or {}
|
|
504
|
+
cmd = self._pending_cmd or []
|
|
505
|
+
patch = agent.get("patch_unified_diff", "")
|
|
506
|
+
summary = agent.get("summary", "")
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
apply_patch(patch, cwd)
|
|
510
|
+
except Exception as exc:
|
|
511
|
+
self.call_from_thread(self._log_line, f"Failed to apply patch: {exc}")
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
rerun_cmd = agent.get("rerun_command") or cmd
|
|
515
|
+
self.call_from_thread(self._log_line, "Rerunning command...")
|
|
516
|
+
proc = subprocess.Popen(
|
|
517
|
+
rerun_cmd,
|
|
518
|
+
cwd=cwd,
|
|
519
|
+
stdout=subprocess.PIPE,
|
|
520
|
+
stderr=subprocess.PIPE,
|
|
521
|
+
text=True,
|
|
522
|
+
)
|
|
523
|
+
out, err = proc.communicate()
|
|
524
|
+
rerun_exit = proc.returncode
|
|
525
|
+
|
|
526
|
+
record = {
|
|
527
|
+
"command": cmd,
|
|
528
|
+
"exit_code": 1,
|
|
529
|
+
"summary": summary,
|
|
530
|
+
"patch": patch,
|
|
531
|
+
"rerun_command": rerun_cmd,
|
|
532
|
+
"rerun_exit_code": rerun_exit,
|
|
533
|
+
"rerun_stdout": out,
|
|
534
|
+
"rerun_stderr": err,
|
|
535
|
+
"applied": True,
|
|
536
|
+
}
|
|
537
|
+
write_history(cwd, record)
|
|
538
|
+
|
|
539
|
+
self.call_from_thread(self._phase, "Validation / Summary")
|
|
540
|
+
self.call_from_thread(self._log_line, f"Rerun exit code: [bold]{rerun_exit}[/bold]")
|
|
541
|
+
if out:
|
|
542
|
+
self.call_from_thread(self._log_line, f"\n[bold]Output[/bold]\n{out.strip()}")
|
|
543
|
+
if err:
|
|
544
|
+
self.call_from_thread(self._log_line, f"\n[bold]Errors[/bold]\n{err.strip()}")
|
|
545
|
+
|
|
546
|
+
self.call_from_thread(self._phase, "Ready")
|
|
547
|
+
|
|
548
|
+
self._pending_cmd = None
|
|
549
|
+
self._pending_agent = None
|
|
550
|
+
state = {
|
|
551
|
+
"session_id": self._session_id,
|
|
552
|
+
"last_prompt": self._last_prompt,
|
|
553
|
+
"last_command": cmd,
|
|
554
|
+
"approvals_mode": self._approvals_mode,
|
|
555
|
+
}
|
|
556
|
+
save_session(cwd, self._session_id, state)
|
|
557
|
+
|
|
558
|
+
def _prompt_flow(self, prompt: str) -> None:
|
|
559
|
+
cwd = os.getcwd()
|
|
560
|
+
context = {
|
|
561
|
+
"mode": "prompt",
|
|
562
|
+
"prompt": prompt,
|
|
563
|
+
"cwd": cwd,
|
|
564
|
+
}
|
|
565
|
+
agents = load_agents_instructions(cwd)
|
|
566
|
+
if agents:
|
|
567
|
+
context["agent_instructions"] = agents
|
|
568
|
+
try:
|
|
569
|
+
agent_result = call_agent(context)
|
|
570
|
+
except Exception as exc:
|
|
571
|
+
self.call_from_thread(self._log_line, f"Agent call failed: {exc}")
|
|
572
|
+
return
|
|
573
|
+
warning = agent_result.get("_warning")
|
|
574
|
+
if warning:
|
|
575
|
+
self.call_from_thread(self._log_line, f"Warning: {warning}")
|
|
576
|
+
summary = agent_result.get("summary", "")
|
|
577
|
+
if not summary:
|
|
578
|
+
summary = agent_result.get("_raw_content", "")
|
|
579
|
+
self.call_from_thread(self._phase, "Ready")
|
|
580
|
+
self.call_from_thread(self._log_line, summary)
|
|
581
|
+
state = {
|
|
582
|
+
"session_id": self._session_id,
|
|
583
|
+
"last_prompt": prompt,
|
|
584
|
+
"approvals_mode": self._approvals_mode,
|
|
585
|
+
}
|
|
586
|
+
save_session(cwd, self._session_id, state)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def run_tui() -> None:
|
|
590
|
+
app = StackFixTUI()
|
|
591
|
+
app.run()
|
package/stackfix/util.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import threading
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Tuple, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_git_repo(cwd: str) -> bool:
|
|
9
|
+
return os.path.isdir(os.path.join(cwd, ".git"))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_command_stream(cmd: List[str], cwd: str) -> Tuple[int, str, str]:
|
|
13
|
+
proc = subprocess.Popen(
|
|
14
|
+
cmd,
|
|
15
|
+
cwd=cwd,
|
|
16
|
+
stdout=subprocess.PIPE,
|
|
17
|
+
stderr=subprocess.PIPE,
|
|
18
|
+
text=True,
|
|
19
|
+
bufsize=1,
|
|
20
|
+
universal_newlines=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
stdout_chunks = []
|
|
24
|
+
stderr_chunks = []
|
|
25
|
+
|
|
26
|
+
def _pump(stream, sink, out_stream):
|
|
27
|
+
for line in iter(stream.readline, ""):
|
|
28
|
+
sink.append(line)
|
|
29
|
+
out_stream.write(line)
|
|
30
|
+
out_stream.flush()
|
|
31
|
+
stream.close()
|
|
32
|
+
|
|
33
|
+
t_out = threading.Thread(target=_pump, args=(proc.stdout, stdout_chunks, sys.stdout))
|
|
34
|
+
t_err = threading.Thread(target=_pump, args=(proc.stderr, stderr_chunks, sys.stderr))
|
|
35
|
+
t_out.start()
|
|
36
|
+
t_err.start()
|
|
37
|
+
proc.wait()
|
|
38
|
+
t_out.join()
|
|
39
|
+
t_err.join()
|
|
40
|
+
|
|
41
|
+
return proc.returncode, "".join(stdout_chunks), "".join(stderr_chunks)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def truncate_text(text: str, max_chars: int) -> str:
|
|
45
|
+
if len(text) <= max_chars:
|
|
46
|
+
return text
|
|
47
|
+
return text[:max_chars] + f"\n... [truncated to {max_chars} chars]\n"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def env_required(name: str) -> str:
|
|
51
|
+
value = os.environ.get(name)
|
|
52
|
+
if not value:
|
|
53
|
+
raise RuntimeError(f"Missing required env var: {name}")
|
|
54
|
+
return value
|