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.
@@ -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()
@@ -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