Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd8551d282 | |||
| 69401365be |
15
aggregate.py
15
aggregate.py
@@ -164,6 +164,18 @@ def build_markdown_from_items(file_items: list[dict], screenshot_base_dir: Path,
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
def build_markdown_no_history(file_items: list[dict], screenshot_base_dir: Path, screenshots: list[str], summary_only: bool = False) -> str:
|
||||
"""Build markdown with only files + screenshots (no history). Used for stable caching."""
|
||||
return build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history=[], summary_only=summary_only)
|
||||
|
||||
|
||||
def build_discussion_text(history: list[str]) -> str:
|
||||
"""Build just the discussion history section text. Returns empty string if no history."""
|
||||
if not history:
|
||||
return ""
|
||||
return "## Discussion History\n\n" + build_discussion_section(history)
|
||||
|
||||
|
||||
def build_markdown(base_dir: Path, files: list[str], screenshot_base_dir: Path, screenshots: list[str], history: list[str], summary_only: bool = False) -> str:
|
||||
parts = []
|
||||
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
|
||||
@@ -195,8 +207,9 @@ def run(config: dict) -> tuple[str, Path, list[dict]]:
|
||||
output_file = output_dir / f"{namespace}_{increment:03d}.md"
|
||||
# Build file items once, then construct markdown from them (avoids double I/O)
|
||||
file_items = build_file_items(base_dir, files)
|
||||
summary_only = config.get("project", {}).get("summary_only", False)
|
||||
markdown = build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history,
|
||||
summary_only=False)
|
||||
summary_only=summary_only)
|
||||
output_file.write_text(markdown, encoding="utf-8")
|
||||
return markdown, output_file, file_items
|
||||
|
||||
|
||||
172
ai_client.py
172
ai_client.py
@@ -15,7 +15,11 @@ import tomllib
|
||||
import json
|
||||
import time
|
||||
import datetime
|
||||
import hashlib
|
||||
import difflib
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import os
|
||||
import file_cache
|
||||
import mcp_client
|
||||
import anthropic
|
||||
@@ -51,6 +55,8 @@ _GEMINI_CACHE_TTL = 3600
|
||||
|
||||
_anthropic_client = None
|
||||
_anthropic_history: list[dict] = []
|
||||
_anthropic_history_lock = threading.Lock()
|
||||
_send_lock = threading.Lock()
|
||||
|
||||
# Injected by gui.py - called when AI wants to run a command.
|
||||
# Signature: (script: str, base_dir: str) -> str | None
|
||||
@@ -67,6 +73,10 @@ tool_log_callback = None
|
||||
# Increased to allow thorough code exploration before forcing a summary
|
||||
MAX_TOOL_ROUNDS = 10
|
||||
|
||||
# Maximum cumulative bytes of tool output allowed per send() call.
|
||||
# Prevents unbounded memory growth during long tool-calling loops.
|
||||
_MAX_TOOL_OUTPUT_BYTES = 500_000
|
||||
|
||||
# Maximum characters per text chunk sent to Anthropic.
|
||||
# Kept well under the ~200k token API limit.
|
||||
_ANTHROPIC_CHUNK_SIZE = 120_000
|
||||
@@ -128,8 +138,18 @@ def clear_comms_log():
|
||||
|
||||
|
||||
def _load_credentials() -> dict:
|
||||
with open("credentials.toml", "rb") as f:
|
||||
return tomllib.load(f)
|
||||
cred_path = os.environ.get("SLOP_CREDENTIALS", "credentials.toml")
|
||||
try:
|
||||
with open(cred_path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(
|
||||
f"Credentials file not found: {cred_path}\n"
|
||||
f"Create a credentials.toml with:\n"
|
||||
f" [gemini]\n api_key = \"your-key\"\n"
|
||||
f" [anthropic]\n api_key = \"your-key\"\n"
|
||||
f"Or set SLOP_CREDENTIALS env var to a custom path."
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ provider errors
|
||||
@@ -244,7 +264,8 @@ def reset_session():
|
||||
_gemini_cache_md_hash = None
|
||||
_gemini_cache_created_at = None
|
||||
_anthropic_client = None
|
||||
_anthropic_history = []
|
||||
with _anthropic_history_lock:
|
||||
_anthropic_history = []
|
||||
_CACHED_ANTHROPIC_TOOLS = None
|
||||
file_cache.reset_client()
|
||||
|
||||
@@ -435,6 +456,13 @@ def _run_script(script: str, base_dir: str) -> str:
|
||||
return output
|
||||
|
||||
|
||||
def _truncate_tool_output(output: str) -> str:
|
||||
"""Truncate tool output to _history_trunc_limit chars before sending to API."""
|
||||
if _history_trunc_limit > 0 and len(output) > _history_trunc_limit:
|
||||
return output[:_history_trunc_limit] + "\n\n... [TRUNCATED BY SYSTEM TO SAVE TOKENS.]"
|
||||
return output
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ dynamic file context refresh
|
||||
|
||||
def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
@@ -460,7 +488,7 @@ def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
refreshed.append(item) # unchanged — skip re-read
|
||||
continue
|
||||
content = p.read_text(encoding="utf-8")
|
||||
new_item = {**item, "content": content, "error": False, "mtime": current_mtime}
|
||||
new_item = {**item, "old_content": item.get("content", ""), "content": content, "error": False, "mtime": current_mtime}
|
||||
refreshed.append(new_item)
|
||||
changed.append(new_item)
|
||||
except Exception as e:
|
||||
@@ -486,6 +514,35 @@ def _build_file_context_text(file_items: list[dict]) -> str:
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
_DIFF_LINE_THRESHOLD = 200
|
||||
|
||||
def _build_file_diff_text(changed_items: list[dict]) -> str:
|
||||
"""
|
||||
Build text for changed files. Small files (<= _DIFF_LINE_THRESHOLD lines)
|
||||
get full content; large files get a unified diff against old_content.
|
||||
"""
|
||||
if not changed_items:
|
||||
return ""
|
||||
parts = []
|
||||
for item in changed_items:
|
||||
path = item.get("path") or item.get("entry", "unknown")
|
||||
content = item.get("content", "")
|
||||
old_content = item.get("old_content", "")
|
||||
new_lines = content.splitlines(keepends=True)
|
||||
if len(new_lines) <= _DIFF_LINE_THRESHOLD or not old_content:
|
||||
suffix = str(path).rsplit(".", 1)[-1] if "." in str(path) else "text"
|
||||
parts.append(f"### `{path}` (full)\n\n```{suffix}\n{content}\n```")
|
||||
else:
|
||||
old_lines = old_content.splitlines(keepends=True)
|
||||
diff = difflib.unified_diff(old_lines, new_lines, fromfile=str(path), tofile=str(path), lineterm="")
|
||||
diff_text = "\n".join(diff)
|
||||
if diff_text:
|
||||
parts.append(f"### `{path}` (diff)\n\n```diff\n{diff_text}\n```")
|
||||
else:
|
||||
parts.append(f"### `{path}` (no changes detected)")
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ content block serialisation
|
||||
|
||||
def _content_block_to_dict(block) -> dict:
|
||||
@@ -530,22 +587,26 @@ def _get_gemini_history_list(chat):
|
||||
return chat.get_history()
|
||||
return []
|
||||
|
||||
def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
||||
def _send_gemini(md_content: str, user_message: str, base_dir: str,
|
||||
file_items: list[dict] | None = None,
|
||||
discussion_history: str = "") -> str:
|
||||
global _gemini_chat, _gemini_cache, _gemini_cache_md_hash, _gemini_cache_created_at
|
||||
|
||||
try:
|
||||
_ensure_gemini_client(); mcp_client.configure(file_items or [], [base_dir])
|
||||
# Only stable content (files + screenshots) goes in the cached system instruction.
|
||||
# Discussion history is sent as conversation messages so the cache isn't invalidated every turn.
|
||||
sys_instr = f"{_get_combined_system_prompt()}\n\n<context>\n{md_content}\n</context>"
|
||||
tools_decl = [_gemini_tool_declaration()]
|
||||
|
||||
# DYNAMIC CONTEXT: Check if files/context changed mid-session
|
||||
current_md_hash = hash(md_content)
|
||||
current_md_hash = hashlib.md5(md_content.encode()).hexdigest()
|
||||
old_history = None
|
||||
if _gemini_chat and _gemini_cache_md_hash != current_md_hash:
|
||||
old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else []
|
||||
if _gemini_cache:
|
||||
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||
except: pass
|
||||
except Exception as e: _append_comms("OUT", "request", {"message": f"[CACHE DELETE WARN] {e}"})
|
||||
_gemini_chat = None
|
||||
_gemini_cache = None
|
||||
_gemini_cache_created_at = None
|
||||
@@ -558,7 +619,7 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
||||
if elapsed > _GEMINI_CACHE_TTL * 0.9:
|
||||
old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else []
|
||||
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||
except: pass
|
||||
except Exception as e: _append_comms("OUT", "request", {"message": f"[CACHE DELETE WARN] {e}"})
|
||||
_gemini_chat = None
|
||||
_gemini_cache = None
|
||||
_gemini_cache_created_at = None
|
||||
@@ -601,9 +662,16 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
||||
|
||||
_gemini_chat = _gemini_client.chats.create(**kwargs)
|
||||
_gemini_cache_md_hash = current_md_hash
|
||||
|
||||
|
||||
# Inject discussion history as a user message on first chat creation
|
||||
# (only when there's no old_history being restored, i.e., fresh session)
|
||||
if discussion_history and not old_history:
|
||||
_gemini_chat.send_message(f"[DISCUSSION HISTORY]\n\n{discussion_history}")
|
||||
_append_comms("OUT", "request", {"message": f"[HISTORY INJECTED] {len(discussion_history)} chars"})
|
||||
|
||||
_append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"})
|
||||
payload, all_text = user_message, []
|
||||
_cumulative_tool_bytes = 0
|
||||
|
||||
# Strip stale file refreshes and truncate old tool outputs ONCE before
|
||||
# entering the tool loop (not per-round — history entries don't change).
|
||||
@@ -634,37 +702,30 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
||||
if cached_tokens:
|
||||
usage["cache_read_input_tokens"] = cached_tokens
|
||||
|
||||
# Fetch cache stats in the background thread to avoid blocking GUI
|
||||
cache_stats = None
|
||||
try:
|
||||
cache_stats = get_gemini_cache_stats()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
events.emit("response_received", payload={"provider": "gemini", "model": _model, "usage": usage, "round": r_idx, "cache_stats": cache_stats})
|
||||
events.emit("response_received", payload={"provider": "gemini", "model": _model, "usage": usage, "round": r_idx})
|
||||
|
||||
reason = resp.candidates[0].finish_reason.name if resp.candidates and hasattr(resp.candidates[0], "finish_reason") else "STOP"
|
||||
|
||||
_append_comms("IN", "response", {"round": r_idx, "stop_reason": reason, "text": txt, "tool_calls": [{"name": c.name, "args": dict(c.args)} for c in calls], "usage": usage})
|
||||
|
||||
# Guard: if Gemini reports input tokens approaching the limit, drop oldest history pairs
|
||||
# Guard: proactively trim history when input tokens exceed 40% of limit
|
||||
total_in = usage.get("input_tokens", 0)
|
||||
if total_in > _GEMINI_MAX_INPUT_TOKENS and _gemini_chat and _get_gemini_history_list(_gemini_chat):
|
||||
if total_in > _GEMINI_MAX_INPUT_TOKENS * 0.4 and _gemini_chat and _get_gemini_history_list(_gemini_chat):
|
||||
hist = _get_gemini_history_list(_gemini_chat)
|
||||
dropped = 0
|
||||
# Drop oldest pairs (user+model) but keep at least the last 2 entries
|
||||
while len(hist) > 4 and total_in > _GEMINI_MAX_INPUT_TOKENS * 0.7:
|
||||
while len(hist) > 4 and total_in > _GEMINI_MAX_INPUT_TOKENS * 0.3:
|
||||
# Drop in pairs (user + model) to maintain alternating roles required by Gemini
|
||||
saved = 0
|
||||
for _ in range(2):
|
||||
if not hist: break
|
||||
for p in hist[0].parts:
|
||||
if hasattr(p, "text") and p.text:
|
||||
saved += len(p.text) // 4
|
||||
saved += int(len(p.text) / _CHARS_PER_TOKEN)
|
||||
elif hasattr(p, "function_response") and p.function_response:
|
||||
r = getattr(p.function_response, "response", {})
|
||||
if isinstance(r, dict):
|
||||
saved += len(str(r.get("output", ""))) // 4
|
||||
saved += int(len(str(r.get("output", ""))) / _CHARS_PER_TOKEN)
|
||||
hist.pop(0)
|
||||
dropped += 1
|
||||
total_in -= max(saved, 200)
|
||||
@@ -689,15 +750,23 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
||||
if i == len(calls) - 1:
|
||||
if file_items:
|
||||
file_items, changed = _reread_file_items(file_items)
|
||||
ctx = _build_file_context_text(changed)
|
||||
ctx = _build_file_diff_text(changed)
|
||||
if ctx:
|
||||
out += f"\n\n[SYSTEM: FILES UPDATED]\n\n{ctx}"
|
||||
if r_idx == MAX_TOOL_ROUNDS: out += "\n\n[SYSTEM: MAX ROUNDS. PROVIDE FINAL ANSWER.]"
|
||||
|
||||
out = _truncate_tool_output(out)
|
||||
_cumulative_tool_bytes += len(out)
|
||||
f_resps.append(types.Part.from_function_response(name=name, response={"output": out}))
|
||||
log.append({"tool_use_id": name, "content": out})
|
||||
events.emit("tool_execution", payload={"status": "completed", "tool": name, "result": out, "round": r_idx})
|
||||
|
||||
|
||||
if _cumulative_tool_bytes > _MAX_TOOL_OUTPUT_BYTES:
|
||||
f_resps.append(types.Part.from_text(
|
||||
f"SYSTEM WARNING: Cumulative tool output exceeded {_MAX_TOOL_OUTPUT_BYTES // 1000}KB budget. Provide your final answer now."
|
||||
))
|
||||
_append_comms("OUT", "request", {"message": f"[TOOL OUTPUT BUDGET EXCEEDED: {_cumulative_tool_bytes} bytes]"})
|
||||
|
||||
_append_comms("OUT", "tool_result_send", {"results": log})
|
||||
payload = f_resps
|
||||
|
||||
@@ -955,7 +1024,7 @@ def _repair_anthropic_history(history: list[dict]):
|
||||
})
|
||||
|
||||
|
||||
def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
||||
def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None, discussion_history: str = "") -> str:
|
||||
try:
|
||||
_ensure_anthropic_client()
|
||||
mcp_client.configure(file_items or [], [base_dir])
|
||||
@@ -969,7 +1038,11 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
||||
context_blocks = _build_chunked_context_blocks(context_text)
|
||||
system_blocks = stable_blocks + context_blocks
|
||||
|
||||
user_content = [{"type": "text", "text": user_message}]
|
||||
# Prepend discussion history to the first user message if this is a fresh session
|
||||
if discussion_history and not _anthropic_history:
|
||||
user_content = [{"type": "text", "text": f"[DISCUSSION HISTORY]\n\n{discussion_history}\n\n---\n\n{user_message}"}]
|
||||
else:
|
||||
user_content = [{"type": "text", "text": user_message}]
|
||||
|
||||
# COMPRESS HISTORY: Truncate massive tool outputs from previous turns
|
||||
for msg in _anthropic_history:
|
||||
@@ -1000,6 +1073,7 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
||||
})
|
||||
|
||||
all_text_parts = []
|
||||
_cumulative_tool_bytes = 0
|
||||
|
||||
# We allow MAX_TOOL_ROUNDS, plus 1 final loop to get the text synthesis
|
||||
for round_idx in range(MAX_TOOL_ROUNDS + 2):
|
||||
@@ -1086,10 +1160,12 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
||||
_append_comms("OUT", "tool_call", {"name": b_name, "id": b_id, "args": b_input})
|
||||
output = mcp_client.dispatch(b_name, b_input)
|
||||
_append_comms("IN", "tool_result", {"name": b_name, "id": b_id, "output": output})
|
||||
truncated = _truncate_tool_output(output)
|
||||
_cumulative_tool_bytes += len(truncated)
|
||||
tool_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": b_id,
|
||||
"content": output,
|
||||
"content": truncated,
|
||||
})
|
||||
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
||||
elif b_name == TOOL_NAME:
|
||||
@@ -1105,17 +1181,26 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
||||
"id": b_id,
|
||||
"output": output,
|
||||
})
|
||||
truncated = _truncate_tool_output(output)
|
||||
_cumulative_tool_bytes += len(truncated)
|
||||
tool_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": b_id,
|
||||
"content": output,
|
||||
"content": truncated,
|
||||
})
|
||||
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
||||
|
||||
if _cumulative_tool_bytes > _MAX_TOOL_OUTPUT_BYTES:
|
||||
tool_results.append({
|
||||
"type": "text",
|
||||
"text": f"SYSTEM WARNING: Cumulative tool output exceeded {_MAX_TOOL_OUTPUT_BYTES // 1000}KB budget. Provide your final answer now."
|
||||
})
|
||||
_append_comms("OUT", "request", {"message": f"[TOOL OUTPUT BUDGET EXCEEDED: {_cumulative_tool_bytes} bytes]"})
|
||||
|
||||
# Refresh file context after tool calls — only inject CHANGED files
|
||||
if file_items:
|
||||
file_items, changed = _reread_file_items(file_items)
|
||||
refreshed_ctx = _build_file_context_text(changed)
|
||||
refreshed_ctx = _build_file_diff_text(changed)
|
||||
if refreshed_ctx:
|
||||
tool_results.append({
|
||||
"type": "text",
|
||||
@@ -1160,21 +1245,26 @@ def send(
|
||||
user_message: str,
|
||||
base_dir: str = ".",
|
||||
file_items: list[dict] | None = None,
|
||||
discussion_history: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Send a message to the active provider.
|
||||
|
||||
md_content : aggregated markdown string from aggregate.run()
|
||||
user_message: the user question / instruction
|
||||
base_dir : project base directory (for PowerShell tool calls)
|
||||
file_items : list of file dicts from aggregate.build_file_items() for
|
||||
dynamic context refresh after tool calls
|
||||
md_content : aggregated markdown string (for Gemini: stable content only,
|
||||
for Anthropic: full content including history)
|
||||
user_message : the user question / instruction
|
||||
base_dir : project base directory (for PowerShell tool calls)
|
||||
file_items : list of file dicts from aggregate.build_file_items() for
|
||||
dynamic context refresh after tool calls
|
||||
discussion_history : discussion history text (used by Gemini to inject as
|
||||
conversation message instead of caching it)
|
||||
"""
|
||||
if _provider == "gemini":
|
||||
return _send_gemini(md_content, user_message, base_dir, file_items)
|
||||
elif _provider == "anthropic":
|
||||
return _send_anthropic(md_content, user_message, base_dir, file_items)
|
||||
raise ValueError(f"unknown provider: {_provider}")
|
||||
with _send_lock:
|
||||
if _provider == "gemini":
|
||||
return _send_gemini(md_content, user_message, base_dir, file_items, discussion_history)
|
||||
elif _provider == "anthropic":
|
||||
return _send_anthropic(md_content, user_message, base_dir, file_items, discussion_history)
|
||||
raise ValueError(f"unknown provider: {_provider}")
|
||||
|
||||
def get_history_bleed_stats() -> dict:
|
||||
"""
|
||||
@@ -1182,7 +1272,9 @@ def get_history_bleed_stats() -> dict:
|
||||
"""
|
||||
if _provider == "anthropic":
|
||||
# For Anthropic, we have a robust estimator
|
||||
current_tokens = _estimate_prompt_tokens([], _anthropic_history)
|
||||
with _anthropic_history_lock:
|
||||
history_snapshot = list(_anthropic_history)
|
||||
current_tokens = _estimate_prompt_tokens([], history_snapshot)
|
||||
limit_tokens = _ANTHROPIC_MAX_PROMPT_TOKENS
|
||||
percentage = (current_tokens / limit_tokens) * 100 if limit_tokens > 0 else 0
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import time
|
||||
|
||||
class ApiHookClient:
|
||||
def __init__(self, base_url="http://127.0.0.1:8999", max_retries=5, retry_delay=2):
|
||||
def __init__(self, base_url="http://127.0.0.1:8999", max_retries=3, retry_delay=1):
|
||||
self.base_url = base_url
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
@@ -29,9 +29,9 @@ class ApiHookClient:
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
if method == 'GET':
|
||||
response = requests.get(url, timeout=5)
|
||||
response = requests.get(url, timeout=2)
|
||||
elif method == 'POST':
|
||||
response = requests.post(url, json=data, headers=headers, timeout=5)
|
||||
response = requests.post(url, json=data, headers=headers, timeout=2)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
@@ -83,53 +83,3 @@ class ApiHookClient:
|
||||
|
||||
def post_gui(self, gui_data):
|
||||
return self._make_request('POST', '/api/gui', data=gui_data)
|
||||
|
||||
def select_tab(self, tab_bar, tab):
|
||||
"""Tells the GUI to switch to a specific tab in a tab bar."""
|
||||
return self.post_gui({
|
||||
"action": "select_tab",
|
||||
"tab_bar": tab_bar,
|
||||
"tab": tab
|
||||
})
|
||||
|
||||
def select_list_item(self, listbox, item_value):
|
||||
"""Tells the GUI to select an item in a listbox by its value."""
|
||||
return self.post_gui({
|
||||
"action": "select_list_item",
|
||||
"listbox": listbox,
|
||||
"item_value": item_value
|
||||
})
|
||||
|
||||
def set_value(self, item, value):
|
||||
"""Sets the value of a GUI item."""
|
||||
return self.post_gui({
|
||||
"action": "set_value",
|
||||
"item": item,
|
||||
"value": value
|
||||
})
|
||||
|
||||
def click(self, item, *args, **kwargs):
|
||||
"""Simulates a click on a GUI button or item."""
|
||||
user_data = kwargs.pop('user_data', None)
|
||||
return self.post_gui({
|
||||
"action": "click",
|
||||
"item": item,
|
||||
"args": args,
|
||||
"kwargs": kwargs,
|
||||
"user_data": user_data
|
||||
})
|
||||
|
||||
def get_indicator_state(self, tag):
|
||||
"""Checks if an indicator is shown using the diagnostics endpoint."""
|
||||
# Mapping tag to the keys used in diagnostics endpoint
|
||||
mapping = {
|
||||
"thinking_indicator": "thinking",
|
||||
"operations_live_indicator": "live",
|
||||
"prior_session_indicator": "prior"
|
||||
}
|
||||
key = mapping.get(tag, tag)
|
||||
try:
|
||||
diag = self._make_request('GET', '/api/gui/diagnostics')
|
||||
return {"tag": tag, "shown": diag.get(key, False)}
|
||||
except Exception as e:
|
||||
return {"tag": tag, "shown": False, "error": str(e)}
|
||||
|
||||
46
api_hooks.py
46
api_hooks.py
@@ -21,12 +21,11 @@ class HookHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
|
||||
elif self.path == '/api/project':
|
||||
import project_manager
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
flat = project_manager.flat_config(app.project)
|
||||
self.wfile.write(json.dumps({'project': flat}).encode('utf-8'))
|
||||
self.wfile.write(
|
||||
json.dumps({'project': app.project}).encode('utf-8'))
|
||||
elif self.path == '/api/session':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
@@ -42,35 +41,6 @@ class HookHandler(BaseHTTPRequestHandler):
|
||||
if hasattr(app, 'perf_monitor'):
|
||||
metrics = app.perf_monitor.get_metrics()
|
||||
self.wfile.write(json.dumps({'performance': metrics}).encode('utf-8'))
|
||||
elif self.path == '/api/gui/diagnostics':
|
||||
# Safe way to query multiple states at once via the main thread queue
|
||||
event = threading.Event()
|
||||
result = {}
|
||||
|
||||
def check_all():
|
||||
import dearpygui.dearpygui as dpg
|
||||
try:
|
||||
result["thinking"] = dpg.is_item_shown("thinking_indicator") if dpg.does_item_exist("thinking_indicator") else False
|
||||
result["live"] = dpg.is_item_shown("operations_live_indicator") if dpg.does_item_exist("operations_live_indicator") else False
|
||||
result["prior"] = dpg.is_item_shown("prior_session_indicator") if dpg.does_item_exist("prior_session_indicator") else False
|
||||
finally:
|
||||
event.set()
|
||||
|
||||
with app._pending_gui_tasks_lock:
|
||||
app._pending_gui_tasks.append({
|
||||
"action": "custom_callback",
|
||||
"callback": check_all
|
||||
})
|
||||
|
||||
if event.wait(timeout=2):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(result).encode('utf-8'))
|
||||
else:
|
||||
self.send_response(504)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'error': 'timeout'}).encode('utf-8'))
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
@@ -100,6 +70,11 @@ class HookHandler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(
|
||||
json.dumps({'status': 'updated'}).encode('utf-8'))
|
||||
elif self.path == '/api/gui':
|
||||
if not hasattr(app, '_pending_gui_tasks'):
|
||||
app._pending_gui_tasks = []
|
||||
if not hasattr(app, '_pending_gui_tasks_lock'):
|
||||
app._pending_gui_tasks_lock = threading.Lock()
|
||||
|
||||
with app._pending_gui_tasks_lock:
|
||||
app._pending_gui_tasks.append(data)
|
||||
|
||||
@@ -130,13 +105,6 @@ class HookServer:
|
||||
def start(self):
|
||||
if not getattr(self.app, 'test_hooks_enabled', False):
|
||||
return
|
||||
|
||||
# Ensure the app has the task queue and lock initialized
|
||||
if not hasattr(self.app, '_pending_gui_tasks'):
|
||||
self.app._pending_gui_tasks = []
|
||||
if not hasattr(self.app, '_pending_gui_tasks_lock'):
|
||||
self.app._pending_gui_tasks_lock = threading.Lock()
|
||||
|
||||
self.server = HookServerInstance(('127.0.0.1', self.port), HookHandler, self.app)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
@@ -15,5 +15,4 @@ To serve as an expert-level utility for personal developer use on small projects
|
||||
- **In-Depth Toolset Access:** MCP-like file exploration, URL fetching, search, and dynamic context aggregation embedded within a multi-viewport Dear PyGui/ImGui interface.
|
||||
- **Integrated Workspace:** A consolidated Hub-based layout (Context, AI Settings, Discussion, Operations) designed for expert multi-monitor workflows.
|
||||
- **Session Analysis:** Ability to load and visualize historical session logs with a dedicated tinted "Prior Session" viewing mode.
|
||||
- **Performance Diagnostics:** Built-in telemetry for FPS, Frame Time, and CPU usage, with a dedicated Diagnostics Panel and AI API hooks for performance analysis.
|
||||
- **Automated UX Verification:** A robust IPC mechanism via API hooks allows for human-like simulation walkthroughs and automated regression testing of the full GUI lifecycle.
|
||||
- **Performance Diagnostics:** Built-in telemetry for FPS, Frame Time, and CPU usage, with a dedicated Diagnostics Panel and AI API hooks for performance analysis.
|
||||
@@ -15,8 +15,6 @@
|
||||
- **tomli-w:** For writing TOML configuration files.
|
||||
- **psutil:** For system and process monitoring (CPU/Memory telemetry).
|
||||
- **uv:** An extremely fast Python package and project manager.
|
||||
- **pytest:** For unit and integration testing, leveraging custom fixtures for live GUI verification.
|
||||
- **ApiHookClient:** A dedicated IPC client for automated GUI interaction and state inspection.
|
||||
|
||||
## Architectural Patterns
|
||||
- **Event-Driven Metrics:** Uses a custom `EventEmitter` to decouple API lifecycle events from UI rendering, improving performance and responsiveness.
|
||||
@@ -9,7 +9,7 @@ This file tracks all major tracks for the project. Each track has its own detail
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Make a human-like test ux interaction where the AI creates a small python project, engages in a 5-turn discussion, and verifies history/session management features via API hooks.**
|
||||
- [ ] **Track: Make a human-like test ux interaction where the AI creates a small python project, engages in a 5-turn discussion, and verifies history/session management features via API hooks.**
|
||||
*Link: [./tracks/live_ux_test_20260223/](./tracks/live_ux_test_20260223/)*
|
||||
|
||||
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
# Implementation Plan: Human-Like UX Interaction Test
|
||||
|
||||
## Phase 1: Infrastructure & Automation Core [checkpoint: 7626531]
|
||||
## Phase 1: Infrastructure & Automation Core
|
||||
Establish the foundation for driving the GUI via API hooks and simulation logic.
|
||||
|
||||
- [x] Task: Extend `ApiHookClient` with methods for tab switching and listbox selection if missing. f36d539
|
||||
- [x] Task: Implement `TestUserAgent` class to manage dynamic response generation and action delays. d326242
|
||||
- [x] Task: Write Tests (Verify basic hook connectivity and simulated delays) f36d539
|
||||
- [x] Task: Implement basic 'ping-pong' interaction via hooks. bfe9ef0
|
||||
- [x] Task: Harden API hook thread-safety and simplify GUI state polling. 8bd280e
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 1: Infrastructure & Automation Core' (Protocol in workflow.md) 7626531
|
||||
- [ ] Task: Extend `ApiHookClient` with methods for tab switching and listbox selection if missing.
|
||||
- [ ] Task: Implement `TestUserAgent` class to manage dynamic response generation and action delays.
|
||||
- [ ] Task: Write Tests (Verify basic hook connectivity and simulated delays)
|
||||
- [ ] Task: Implement basic 'ping-pong' interaction via hooks.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Infrastructure & Automation Core' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Workflow Simulation [checkpoint: 9c4a72c]
|
||||
## Phase 2: Workflow Simulation
|
||||
Build the core interaction loop for project creation and AI discussion.
|
||||
|
||||
- [x] Task: Implement 'New Project' scaffolding script (creating a tiny console program). bd5dc16
|
||||
- [x] Task: Implement 5-turn discussion loop logic with sub-agent responses. bd5dc16
|
||||
- [x] Task: Write Tests (Verify state changes in Discussion Hub during simulated chat) 6d16438
|
||||
- [x] Task: Implement 'Thinking' and 'Live' indicator verification logic. 6d16438
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 2: Workflow Simulation' (Protocol in workflow.md) 9c4a72c
|
||||
- [ ] Task: Implement 'New Project' scaffolding script (creating a tiny console program).
|
||||
- [ ] Task: Implement 5-turn discussion loop logic with sub-agent responses.
|
||||
- [ ] Task: Write Tests (Verify state changes in Discussion Hub during simulated chat)
|
||||
- [ ] Task: Implement 'Thinking' and 'Live' indicator verification logic.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 2: Workflow Simulation' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: History & Session Verification [checkpoint: 0f04e06]
|
||||
## Phase 3: History & Session Verification
|
||||
Simulate complex session management and historical audit features.
|
||||
|
||||
- [x] Task: Implement discussion switching logic (creating/switching between named discussions). 5e1b965
|
||||
- [x] Task: Implement 'Load Prior Log' simulation and 'Tinted Mode' detection. 5e1b965
|
||||
- [x] Task: Write Tests (Verify log loading and tab navigation consistency) 5e1b965
|
||||
- [x] Task: Implement truncation limit verification (forcing a long history and checking bleed). 5e1b965
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 3: History & Session Verification' (Protocol in workflow.md) 0f04e06
|
||||
- [ ] Task: Implement discussion switching logic (creating/switching between named discussions).
|
||||
- [ ] Task: Implement 'Load Prior Log' simulation and 'Tinted Mode' detection.
|
||||
- [ ] Task: Write Tests (Verify log loading and tab navigation consistency)
|
||||
- [ ] Task: Implement truncation limit verification (forcing a long history and checking bleed).
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 3: History & Session Verification' (Protocol in workflow.md)
|
||||
|
||||
## Phase 4: Final Integration & Regression [checkpoint: 8e63b31]
|
||||
## Phase 4: Final Integration & Regression
|
||||
Consolidate the simulation into end-user artifacts and CI tests.
|
||||
|
||||
- [x] Task: Create `live_walkthrough.py` with full visual feedback and manual sign-off. 8bd280e
|
||||
- [x] Task: Create `tests/test_live_workflow.py` for automated regression testing. 8bd280e
|
||||
- [x] Task: Perform a full visual walkthrough and verify 'human-readable' pace. 8e63b31
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 4: Final Integration & Regression' (Protocol in workflow.md) 8e63b31
|
||||
- [ ] Task: Create `live_walkthrough.py` with full visual feedback and manual sign-off.
|
||||
- [ ] Task: Create `tests/test_live_workflow.py` for automated regression testing.
|
||||
- [ ] Task: Perform a full visual walkthrough and verify 'human-readable' pace.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 4: Final Integration & Regression' (Protocol in workflow.md)
|
||||
|
||||
17
config.toml
17
config.toml
@@ -16,6 +16,19 @@ scale = 1.0
|
||||
paths = [
|
||||
"manual_slop.toml",
|
||||
"C:/projects/forth/bootslop/bootslop.toml",
|
||||
"C:\\projects\\manual_slop\\tests\\temp_project.toml",
|
||||
]
|
||||
active = "C:\\projects\\manual_slop\\tests\\temp_project.toml"
|
||||
active = "manual_slop.toml"
|
||||
|
||||
[gui.show_windows]
|
||||
Projects = true
|
||||
Files = true
|
||||
Screenshots = true
|
||||
"Discussion History" = true
|
||||
Provider = true
|
||||
Message = true
|
||||
Response = true
|
||||
"Tool Calls" = true
|
||||
"Comms History" = true
|
||||
"System Prompts" = true
|
||||
Theme = true
|
||||
Diagnostics = true
|
||||
|
||||
97
gui.py
97
gui.py
@@ -129,7 +129,7 @@ def _add_text_field(parent: str, label: str, value: str):
|
||||
if wrap:
|
||||
with dpg.child_window(height=80, border=True):
|
||||
# add_input_text for selection
|
||||
dpg.add_input_text(default_value=value, multiline=True, readonly=True, width=-1, height=-1)
|
||||
dpg.add_input_text(default_value=value, multiline=True, readonly=True, width=-1, height=-1, border=False)
|
||||
else:
|
||||
dpg.add_input_text(
|
||||
default_value=value,
|
||||
@@ -140,14 +140,14 @@ def _add_text_field(parent: str, label: str, value: str):
|
||||
)
|
||||
else:
|
||||
# Short selectable text
|
||||
dpg.add_input_text(default_value=value if value else "(empty)", readonly=True, width=-1)
|
||||
dpg.add_input_text(default_value=value if value else "(empty)", readonly=True, width=-1, border=False)
|
||||
|
||||
|
||||
def _add_kv_row(parent: str, key: str, val, val_color=None):
|
||||
"""Single key: value row, horizontally laid out."""
|
||||
with dpg.group(horizontal=True, parent=parent):
|
||||
dpg.add_text(f"{key}:", color=_LABEL_COLOR)
|
||||
dpg.add_input_text(default_value=str(val), readonly=True, width=-1)
|
||||
dpg.add_input_text(default_value=str(val), readonly=True, width=-1, border=False)
|
||||
|
||||
|
||||
def _render_usage(parent: str, usage: dict):
|
||||
@@ -1168,9 +1168,9 @@ class App:
|
||||
hint="New discussion name",
|
||||
width=-180,
|
||||
)
|
||||
dpg.add_button(label="Create", tag="btn_disc_create", callback=self.cb_disc_create)
|
||||
dpg.add_button(label="Rename", tag="btn_disc_rename", callback=self.cb_disc_rename)
|
||||
dpg.add_button(label="Delete", tag="btn_disc_delete", callback=self.cb_disc_delete)
|
||||
dpg.add_button(label="Create", callback=self.cb_disc_create)
|
||||
dpg.add_button(label="Rename", callback=self.cb_disc_rename)
|
||||
dpg.add_button(label="Delete", callback=self.cb_disc_delete)
|
||||
|
||||
def _make_remove_file_cb(self, idx: int):
|
||||
def cb():
|
||||
@@ -1506,28 +1506,6 @@ class App:
|
||||
self._rebuild_projects_list()
|
||||
self._update_status(f"created project: {name}")
|
||||
|
||||
def _cb_new_project_automated(self, path):
|
||||
"""Automated version of cb_new_project that doesn't show a dialog."""
|
||||
if not path:
|
||||
return
|
||||
name = Path(path).stem
|
||||
proj = project_manager.default_project(name)
|
||||
project_manager.save_project(proj, path)
|
||||
if path not in self.project_paths:
|
||||
self.project_paths.append(path)
|
||||
|
||||
# Safely queue project switch and list rebuild for the main thread
|
||||
def main_thread_work():
|
||||
self._switch_project(path)
|
||||
self._rebuild_projects_list()
|
||||
self._update_status(f"created project: {name}")
|
||||
|
||||
with self._pending_gui_tasks_lock:
|
||||
self._pending_gui_tasks.append({
|
||||
"action": "custom_callback",
|
||||
"callback": main_thread_work
|
||||
})
|
||||
|
||||
def cb_browse_git_dir(self):
|
||||
root = hide_tk_root()
|
||||
d = filedialog.askdirectory(title="Select Git Directory")
|
||||
@@ -1904,9 +1882,6 @@ class App:
|
||||
no_close=False,
|
||||
no_collapse=True,
|
||||
):
|
||||
with dpg.group(tag="automated_actions_group", show=False):
|
||||
dpg.add_button(tag="btn_project_new_automated", callback=lambda s, a, u: self._cb_new_project_automated(u))
|
||||
|
||||
with dpg.tab_bar():
|
||||
with dpg.tab(label="Projects"):
|
||||
proj_meta = self.project.get("project", {})
|
||||
@@ -1944,9 +1919,9 @@ class App:
|
||||
with dpg.child_window(tag="projects_scroll", height=120, border=True):
|
||||
pass
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_button(label="Add Project", tag="btn_project_add", callback=self.cb_add_project)
|
||||
dpg.add_button(label="New Project", tag="btn_project_new", callback=self.cb_new_project)
|
||||
dpg.add_button(label="Save All", tag="btn_project_save", callback=self.cb_save_config)
|
||||
dpg.add_button(label="Add Project", callback=self.cb_add_project)
|
||||
dpg.add_button(label="New Project", callback=self.cb_new_project)
|
||||
dpg.add_button(label="Save All", callback=self.cb_save_config)
|
||||
dpg.add_checkbox(
|
||||
tag="project_word_wrap",
|
||||
label="Word-Wrap (Read-only panels)",
|
||||
@@ -2093,7 +2068,7 @@ class App:
|
||||
dpg.add_button(label="+All", callback=self.cb_disc_expand_all)
|
||||
dpg.add_text("Keep Pairs:", color=(160, 160, 160))
|
||||
dpg.add_input_int(tag="disc_truncate_pairs", default_value=2, width=80, min_value=1)
|
||||
dpg.add_button(label="Truncate", tag="btn_disc_truncate", callback=self.cb_disc_truncate)
|
||||
dpg.add_button(label="Truncate", callback=self.cb_disc_truncate)
|
||||
dpg.add_button(label="Clear All", callback=self.cb_disc_clear)
|
||||
dpg.add_button(label="Save", callback=self.cb_disc_save)
|
||||
|
||||
@@ -2125,10 +2100,10 @@ class App:
|
||||
height=200,
|
||||
)
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_button(label="Gen + Send", tag="btn_gen_send", callback=self.cb_generate_send)
|
||||
dpg.add_button(label="MD Only", tag="btn_md_only", callback=self.cb_md_only)
|
||||
dpg.add_button(label="Reset", tag="btn_reset", callback=self.cb_reset_session)
|
||||
dpg.add_button(label="-> History", tag="btn_to_history", callback=self.cb_append_message_to_history)
|
||||
dpg.add_button(label="Gen + Send", callback=self.cb_generate_send)
|
||||
dpg.add_button(label="MD Only", callback=self.cb_md_only)
|
||||
dpg.add_button(label="Reset", callback=self.cb_reset_session)
|
||||
dpg.add_button(label="-> History", callback=self.cb_append_message_to_history)
|
||||
|
||||
with dpg.tab(label="AI Response"):
|
||||
dpg.add_input_text(
|
||||
@@ -2158,13 +2133,13 @@ class App:
|
||||
dpg.add_spacer(width=20)
|
||||
dpg.add_text("LIVE", tag="operations_live_indicator", color=(100, 255, 100), show=False)
|
||||
|
||||
with dpg.tab_bar(tag="operations_tabs"):
|
||||
with dpg.tab(label="Comms Log", tag="tab_comms"):
|
||||
with dpg.tab_bar():
|
||||
with dpg.tab(label="Comms Log"):
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_text("Status: idle", tag="ai_status", color=(200, 220, 160))
|
||||
dpg.add_spacer(width=16)
|
||||
dpg.add_button(label="Clear", callback=self.cb_clear_comms)
|
||||
dpg.add_button(label="Load Log", tag="btn_load_log", callback=self.cb_load_prior_log)
|
||||
dpg.add_button(label="Load Log", callback=self.cb_load_prior_log)
|
||||
dpg.add_button(label="Exit Prior", tag="exit_prior_btn", callback=self.cb_exit_prior_session, show=False)
|
||||
|
||||
dpg.add_text("PRIOR SESSION VIEW", tag="prior_session_indicator", color=(255, 100, 100), show=False)
|
||||
@@ -2173,7 +2148,7 @@ class App:
|
||||
with dpg.child_window(tag="comms_scroll", height=-1, border=False, horizontal_scrollbar=True):
|
||||
pass
|
||||
|
||||
with dpg.tab(label="Tool Log", tag="tab_tool"):
|
||||
with dpg.tab(label="Tool Log"):
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_text("Tool call history")
|
||||
dpg.add_button(label="Clear", callback=self.cb_clear_tool_log)
|
||||
@@ -2326,46 +2301,10 @@ class App:
|
||||
dpg.set_value(item, val)
|
||||
elif action == "click":
|
||||
item = task.get("item")
|
||||
args = task.get("args", [])
|
||||
kwargs = task.get("kwargs", {})
|
||||
user_data = task.get("user_data")
|
||||
if item and dpg.does_item_exist(item):
|
||||
cb = dpg.get_item_callback(item)
|
||||
if cb:
|
||||
try:
|
||||
# DPG callbacks can have (sender, app_data, user_data)
|
||||
# If we have specific args/kwargs we use them,
|
||||
# otherwise we try to follow the DPG pattern.
|
||||
if args or kwargs:
|
||||
cb(*args, **kwargs)
|
||||
elif user_data is not None:
|
||||
cb(item, None, user_data)
|
||||
else:
|
||||
cb()
|
||||
except Exception as e:
|
||||
print(f"Error in GUI hook callback for {item}: {e}")
|
||||
elif action == "select_tab":
|
||||
tab_bar = task.get("tab_bar")
|
||||
tab = task.get("tab")
|
||||
if tab_bar and dpg.does_item_exist(tab_bar):
|
||||
dpg.set_value(tab_bar, tab)
|
||||
elif action == "select_list_item":
|
||||
listbox = task.get("listbox")
|
||||
val = task.get("item_value")
|
||||
if listbox and dpg.does_item_exist(listbox):
|
||||
dpg.set_value(listbox, val)
|
||||
cb = dpg.get_item_callback(listbox)
|
||||
if cb:
|
||||
# Dear PyGui callbacks for listbox usually receive (sender, app_data, user_data)
|
||||
# app_data is the selected value.
|
||||
cb(listbox, val)
|
||||
elif action == "custom_callback":
|
||||
cb = task.get("callback")
|
||||
if cb:
|
||||
try:
|
||||
cb()
|
||||
except Exception as e:
|
||||
print(f"Error in custom GUI hook callback: {e}")
|
||||
elif action == "refresh_api_metrics":
|
||||
self._refresh_api_metrics(task.get("payload", {}))
|
||||
except Exception as e:
|
||||
|
||||
558
gui_2.py
558
gui_2.py
@@ -4,6 +4,8 @@ import threading
|
||||
import time
|
||||
import math
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, Tk
|
||||
import aggregate
|
||||
@@ -14,6 +16,9 @@ import session_logger
|
||||
import project_manager
|
||||
import theme_2 as theme
|
||||
import tomllib
|
||||
import numpy as np
|
||||
import api_hooks
|
||||
from performance_monitor import PerformanceMonitor
|
||||
|
||||
from imgui_bundle import imgui, hello_imgui, immapp
|
||||
|
||||
@@ -56,6 +61,15 @@ KIND_COLORS = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_res
|
||||
HEAVY_KEYS = {"message", "text", "script", "output", "content"}
|
||||
|
||||
DISC_ROLES = ["User", "AI", "Vendor API", "System"]
|
||||
AGENT_TOOL_NAMES = ["run_powershell", "read_file", "list_directory", "search_files", "get_file_summary", "web_search", "fetch_url"]
|
||||
|
||||
def truncate_entries(entries: list[dict], max_pairs: int) -> list[dict]:
|
||||
if max_pairs <= 0:
|
||||
return []
|
||||
target_count = max_pairs * 2
|
||||
if len(entries) <= target_count:
|
||||
return entries
|
||||
return entries[-target_count:]
|
||||
|
||||
def _parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict]:
|
||||
known = roles if roles is not None else DISC_ROLES
|
||||
@@ -119,6 +133,7 @@ class App:
|
||||
self.ui_project_main_context = proj_meta.get("main_context", "")
|
||||
self.ui_project_system_prompt = proj_meta.get("system_prompt", "")
|
||||
self.ui_word_wrap = proj_meta.get("word_wrap", True)
|
||||
self.ui_summary_only = proj_meta.get("summary_only", False)
|
||||
self.ui_auto_add_history = disc_sec.get("auto_add", False)
|
||||
|
||||
self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "")
|
||||
@@ -137,9 +152,10 @@ class App:
|
||||
self.last_file_items: list = []
|
||||
|
||||
self.send_thread: threading.Thread | None = None
|
||||
self._send_thread_lock = threading.Lock()
|
||||
self.models_thread: threading.Thread | None = None
|
||||
|
||||
self.show_windows = {
|
||||
_default_windows = {
|
||||
"Projects": True,
|
||||
"Files": True,
|
||||
"Screenshots": True,
|
||||
@@ -151,7 +167,10 @@ class App:
|
||||
"Comms History": True,
|
||||
"System Prompts": True,
|
||||
"Theme": True,
|
||||
"Diagnostics": False,
|
||||
}
|
||||
saved = self.config.get("gui", {}).get("show_windows", {})
|
||||
self.show_windows = {k: saved.get(k, v) for k, v in _default_windows.items()}
|
||||
self.show_script_output = False
|
||||
self.show_text_viewer = False
|
||||
self.text_viewer_title = ""
|
||||
@@ -181,12 +200,53 @@ class App:
|
||||
|
||||
self._scroll_disc_to_bottom = False
|
||||
|
||||
# GUI Task Queue (thread-safe, for event handlers and hook server)
|
||||
self._pending_gui_tasks: list[dict] = []
|
||||
self._pending_gui_tasks_lock = threading.Lock()
|
||||
|
||||
# Session usage tracking
|
||||
self.session_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}
|
||||
|
||||
# Token budget / cache telemetry
|
||||
self._token_budget_pct = 0.0
|
||||
self._token_budget_current = 0
|
||||
self._token_budget_limit = 0
|
||||
self._gemini_cache_text = ""
|
||||
|
||||
# Discussion truncation
|
||||
self.ui_disc_truncate_pairs: int = 2
|
||||
|
||||
# Agent tools config
|
||||
agent_tools_cfg = self.project.get("agent", {}).get("tools", {})
|
||||
self.ui_agent_tools: dict[str, bool] = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES}
|
||||
|
||||
# Prior session log viewing
|
||||
self.is_viewing_prior_session = False
|
||||
self.prior_session_entries: list[dict] = []
|
||||
|
||||
# API Hooks
|
||||
self.test_hooks_enabled = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1")
|
||||
|
||||
# Performance monitoring
|
||||
self.perf_monitor = PerformanceMonitor()
|
||||
self.perf_history = {"frame_time": [0.0]*100, "fps": [0.0]*100, "cpu": [0.0]*100, "input_lag": [0.0]*100}
|
||||
self._perf_last_update = 0.0
|
||||
|
||||
# Auto-save timer (every 60s)
|
||||
self._autosave_interval = 60.0
|
||||
self._last_autosave = time.time()
|
||||
|
||||
session_logger.open_session()
|
||||
ai_client.set_provider(self.current_provider, self.current_model)
|
||||
ai_client.confirm_and_run_callback = self._confirm_and_run
|
||||
ai_client.comms_log_callback = self._on_comms_entry
|
||||
ai_client.tool_log_callback = self._on_tool_log
|
||||
|
||||
# AI client event subscriptions
|
||||
ai_client.events.on("request_start", self._on_api_event)
|
||||
ai_client.events.on("response_received", self._on_api_event)
|
||||
ai_client.events.on("tool_execution", self._on_api_event)
|
||||
|
||||
# ---------------------------------------------------------------- project loading
|
||||
|
||||
def _load_active_project(self):
|
||||
@@ -253,6 +313,10 @@ class App:
|
||||
self.ui_project_main_context = proj.get("project", {}).get("main_context", "")
|
||||
self.ui_auto_add_history = proj.get("discussion", {}).get("auto_add", False)
|
||||
self.ui_word_wrap = proj.get("project", {}).get("word_wrap", True)
|
||||
self.ui_summary_only = proj.get("project", {}).get("summary_only", False)
|
||||
|
||||
agent_tools_cfg = proj.get("agent", {}).get("tools", {})
|
||||
self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES}
|
||||
|
||||
def _save_active_project(self):
|
||||
if self.active_project_path:
|
||||
@@ -337,6 +401,76 @@ class App:
|
||||
def _on_tool_log(self, script: str, result: str):
|
||||
session_logger.log_tool_call(script, result, None)
|
||||
|
||||
def _on_api_event(self, *args, **kwargs):
|
||||
payload = kwargs.get("payload", {})
|
||||
with self._pending_gui_tasks_lock:
|
||||
self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload})
|
||||
|
||||
def _process_pending_gui_tasks(self):
|
||||
if not self._pending_gui_tasks:
|
||||
return
|
||||
with self._pending_gui_tasks_lock:
|
||||
tasks = self._pending_gui_tasks[:]
|
||||
self._pending_gui_tasks.clear()
|
||||
for task in tasks:
|
||||
try:
|
||||
action = task.get("action")
|
||||
if action == "refresh_api_metrics":
|
||||
self._refresh_api_metrics(task.get("payload", {}))
|
||||
except Exception as e:
|
||||
print(f"Error executing GUI task: {e}")
|
||||
|
||||
def _recalculate_session_usage(self):
|
||||
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}
|
||||
for entry in ai_client.get_comms_log():
|
||||
if entry.get("kind") == "response" and "usage" in entry.get("payload", {}):
|
||||
u = entry["payload"]["usage"]
|
||||
for k in usage.keys():
|
||||
usage[k] += u.get(k, 0) or 0
|
||||
self.session_usage = usage
|
||||
|
||||
def _refresh_api_metrics(self, payload: dict):
|
||||
self._recalculate_session_usage()
|
||||
try:
|
||||
stats = ai_client.get_history_bleed_stats()
|
||||
self._token_budget_pct = stats.get("percentage", 0.0) / 100.0
|
||||
self._token_budget_current = stats.get("current", 0)
|
||||
self._token_budget_limit = stats.get("limit", 0)
|
||||
except Exception:
|
||||
pass
|
||||
cache_stats = payload.get("cache_stats")
|
||||
if cache_stats:
|
||||
count = cache_stats.get("cache_count", 0)
|
||||
size_bytes = cache_stats.get("total_size_bytes", 0)
|
||||
self._gemini_cache_text = f"Gemini Caches: {count} ({size_bytes / 1024:.1f} KB)"
|
||||
|
||||
def cb_load_prior_log(self):
|
||||
root = hide_tk_root()
|
||||
path = filedialog.askopenfilename(
|
||||
title="Load Session Log",
|
||||
initialdir="logs",
|
||||
filetypes=[("Log/JSONL", "*.log *.jsonl"), ("All Files", "*.*")]
|
||||
)
|
||||
root.destroy()
|
||||
if not path:
|
||||
return
|
||||
entries = []
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
self.ai_status = f"log load error: {e}"
|
||||
return
|
||||
self.prior_session_entries = entries
|
||||
self.is_viewing_prior_session = True
|
||||
self.ai_status = f"viewing prior session: {Path(path).name} ({len(entries)} entries)"
|
||||
|
||||
def _confirm_and_run(self, script: str, base_dir: str) -> str | None:
|
||||
dialog = ConfirmDialog(script, base_dir)
|
||||
with self._pending_dialog_lock:
|
||||
@@ -373,6 +507,11 @@ class App:
|
||||
proj["project"]["system_prompt"] = self.ui_project_system_prompt
|
||||
proj["project"]["main_context"] = self.ui_project_main_context
|
||||
proj["project"]["word_wrap"] = self.ui_word_wrap
|
||||
proj["project"]["summary_only"] = self.ui_summary_only
|
||||
|
||||
proj.setdefault("agent", {}).setdefault("tools", {})
|
||||
for t_name in AGENT_TOOL_NAMES:
|
||||
proj["agent"]["tools"][t_name] = self.ui_agent_tools.get(t_name, True)
|
||||
|
||||
self._flush_disc_entries_to_project()
|
||||
disc_sec = proj.setdefault("discussion", {})
|
||||
@@ -390,15 +529,26 @@ class App:
|
||||
}
|
||||
self.config["ai"]["system_prompt"] = self.ui_global_system_prompt
|
||||
self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path}
|
||||
self.config["gui"] = {"show_windows": self.show_windows}
|
||||
theme.save_to_config(self.config)
|
||||
|
||||
def _do_generate(self) -> tuple[str, Path, list]:
|
||||
def _do_generate(self) -> tuple[str, Path, list, str, str]:
|
||||
"""Returns (full_md, output_path, file_items, stable_md, discussion_text)."""
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
save_config(self.config)
|
||||
flat = project_manager.flat_config(self.project, self.active_discussion)
|
||||
return aggregate.run(flat)
|
||||
full_md, path, file_items = aggregate.run(flat)
|
||||
# Build stable markdown (no history) for Gemini caching
|
||||
screenshot_base_dir = Path(flat.get("screenshots", {}).get("base_dir", "."))
|
||||
screenshots = flat.get("screenshots", {}).get("paths", [])
|
||||
summary_only = flat.get("project", {}).get("summary_only", False)
|
||||
stable_md = aggregate.build_markdown_no_history(file_items, screenshot_base_dir, screenshots, summary_only=summary_only)
|
||||
# Build discussion history text separately
|
||||
history = flat.get("discussion", {}).get("history", [])
|
||||
discussion_text = aggregate.build_discussion_text(history)
|
||||
return full_md, path, file_items, stable_md, discussion_text
|
||||
|
||||
def _fetch_models(self, provider: str):
|
||||
self.ai_status = "fetching models..."
|
||||
@@ -445,6 +595,23 @@ class App:
|
||||
# ---------------------------------------------------------------- gui
|
||||
|
||||
def _gui_func(self):
|
||||
self.perf_monitor.start_frame()
|
||||
|
||||
# Process GUI task queue
|
||||
self._process_pending_gui_tasks()
|
||||
|
||||
# Auto-save (every 60s)
|
||||
now = time.time()
|
||||
if now - self._last_autosave >= self._autosave_interval:
|
||||
self._last_autosave = now
|
||||
try:
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
save_config(self.config)
|
||||
except Exception:
|
||||
pass # silent — don't disrupt the GUI loop
|
||||
|
||||
# Sync pending comms
|
||||
with self._pending_comms_lock:
|
||||
for c in self._pending_comms:
|
||||
@@ -576,6 +743,14 @@ class App:
|
||||
self.ai_status = "config saved"
|
||||
|
||||
ch, self.ui_word_wrap = imgui.checkbox("Word-Wrap (Read-only panels)", self.ui_word_wrap)
|
||||
ch, self.ui_summary_only = imgui.checkbox("Summary Only (send file structure, not full content)", self.ui_summary_only)
|
||||
|
||||
if imgui.collapsing_header("Agent Tools"):
|
||||
for t_name in AGENT_TOOL_NAMES:
|
||||
val = self.ui_agent_tools.get(t_name, True)
|
||||
ch, val = imgui.checkbox(f"Enable {t_name}", val)
|
||||
if ch:
|
||||
self.ui_agent_tools[t_name] = val
|
||||
imgui.end()
|
||||
|
||||
# ---- Files
|
||||
@@ -655,7 +830,50 @@ class App:
|
||||
if self.show_windows["Discussion History"]:
|
||||
exp, self.show_windows["Discussion History"] = imgui.begin("Discussion History", self.show_windows["Discussion History"])
|
||||
if exp:
|
||||
if imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open):
|
||||
# THINKING indicator
|
||||
is_thinking = self.ai_status in ["sending..."]
|
||||
if is_thinking:
|
||||
val = math.sin(time.time() * 10 * math.pi)
|
||||
alpha = 1.0 if val > 0 else 0.0
|
||||
imgui.text_colored(imgui.ImVec4(1.0, 0.39, 0.39, alpha), "THINKING...")
|
||||
imgui.separator()
|
||||
|
||||
# Prior session viewing mode
|
||||
if self.is_viewing_prior_session:
|
||||
imgui.push_style_color(imgui.Col_.child_bg, vec4(50, 40, 20))
|
||||
imgui.text_colored(vec4(255, 200, 100), "VIEWING PRIOR SESSION")
|
||||
imgui.same_line()
|
||||
if imgui.button("Exit Prior Session"):
|
||||
self.is_viewing_prior_session = False
|
||||
self.prior_session_entries.clear()
|
||||
imgui.separator()
|
||||
imgui.begin_child("prior_scroll", imgui.ImVec2(0, 0), False)
|
||||
for idx, entry in enumerate(self.prior_session_entries):
|
||||
imgui.push_id(f"prior_{idx}")
|
||||
kind = entry.get("kind", entry.get("type", ""))
|
||||
imgui.text_colored(C_LBL, f"#{idx+1}")
|
||||
imgui.same_line()
|
||||
ts = entry.get("ts", entry.get("timestamp", ""))
|
||||
if ts:
|
||||
imgui.text_colored(vec4(160, 160, 160), str(ts))
|
||||
imgui.same_line()
|
||||
imgui.text_colored(C_KEY, str(kind))
|
||||
payload = entry.get("payload", entry)
|
||||
text = payload.get("text", payload.get("message", payload.get("content", "")))
|
||||
if text:
|
||||
preview = str(text).replace("\n", " ")[:200]
|
||||
if self.ui_word_wrap:
|
||||
imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
||||
imgui.text(preview)
|
||||
imgui.pop_text_wrap_pos()
|
||||
else:
|
||||
imgui.text(preview)
|
||||
imgui.separator()
|
||||
imgui.pop_id()
|
||||
imgui.end_child()
|
||||
imgui.pop_style_color()
|
||||
|
||||
if not self.is_viewing_prior_session and imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open):
|
||||
names = self._get_discussion_names()
|
||||
|
||||
if imgui.begin_combo("##disc_sel", self.active_discussion):
|
||||
@@ -702,106 +920,121 @@ class App:
|
||||
if imgui.button("Delete"):
|
||||
self._delete_discussion(self.active_discussion)
|
||||
|
||||
imgui.separator()
|
||||
if imgui.button("+ Entry"):
|
||||
self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||
imgui.same_line()
|
||||
if imgui.button("-All"):
|
||||
for e in self.disc_entries: e["collapsed"] = True
|
||||
imgui.same_line()
|
||||
if imgui.button("+All"):
|
||||
for e in self.disc_entries: e["collapsed"] = False
|
||||
imgui.same_line()
|
||||
if imgui.button("Clear All"):
|
||||
self.disc_entries.clear()
|
||||
imgui.same_line()
|
||||
if imgui.button("Save"):
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
save_config(self.config)
|
||||
self.ai_status = "discussion saved"
|
||||
|
||||
ch, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history)
|
||||
imgui.separator()
|
||||
|
||||
if imgui.collapsing_header("Roles"):
|
||||
imgui.begin_child("roles_scroll", imgui.ImVec2(0, 100), True)
|
||||
for i, r in enumerate(self.disc_roles):
|
||||
if imgui.button(f"x##r{i}"):
|
||||
self.disc_roles.pop(i)
|
||||
break
|
||||
imgui.same_line()
|
||||
imgui.text(r)
|
||||
imgui.end_child()
|
||||
ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input)
|
||||
imgui.same_line()
|
||||
if imgui.button("Add"):
|
||||
r = self.ui_disc_new_role_input.strip()
|
||||
if r and r not in self.disc_roles:
|
||||
self.disc_roles.append(r)
|
||||
self.ui_disc_new_role_input = ""
|
||||
|
||||
imgui.separator()
|
||||
imgui.begin_child("disc_scroll", imgui.ImVec2(0, 0), False)
|
||||
for i, entry in enumerate(self.disc_entries):
|
||||
imgui.push_id(str(i))
|
||||
collapsed = entry.get("collapsed", False)
|
||||
read_mode = entry.get("read_mode", False)
|
||||
|
||||
if imgui.button("+" if collapsed else "-"):
|
||||
entry["collapsed"] = not collapsed
|
||||
imgui.same_line()
|
||||
|
||||
imgui.set_next_item_width(120)
|
||||
if imgui.begin_combo("##role", entry["role"]):
|
||||
for r in self.disc_roles:
|
||||
if imgui.selectable(r, r == entry["role"])[0]:
|
||||
entry["role"] = r
|
||||
imgui.end_combo()
|
||||
|
||||
if not collapsed:
|
||||
imgui.same_line()
|
||||
if imgui.button("[Edit]" if read_mode else "[Read]"):
|
||||
entry["read_mode"] = not read_mode
|
||||
|
||||
ts_str = entry.get("ts", "")
|
||||
if ts_str:
|
||||
imgui.same_line()
|
||||
imgui.text_colored(vec4(120, 120, 100), ts_str)
|
||||
|
||||
if collapsed:
|
||||
imgui.same_line()
|
||||
if imgui.button("Ins"):
|
||||
self.disc_entries.insert(i, {"role": "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||
imgui.same_line()
|
||||
self._render_text_viewer(f"Entry #{i+1}", entry["content"])
|
||||
imgui.same_line()
|
||||
if imgui.button("Del"):
|
||||
self.disc_entries.pop(i)
|
||||
imgui.pop_id()
|
||||
break
|
||||
imgui.same_line()
|
||||
preview = entry["content"].replace("\n", " ")[:60]
|
||||
if len(entry["content"]) > 60: preview += "..."
|
||||
imgui.text_colored(vec4(160, 160, 150), preview)
|
||||
|
||||
if not collapsed:
|
||||
if read_mode:
|
||||
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
|
||||
if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
||||
imgui.text(entry["content"])
|
||||
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
|
||||
imgui.end_child()
|
||||
else:
|
||||
ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150))
|
||||
|
||||
if not self.is_viewing_prior_session:
|
||||
imgui.separator()
|
||||
imgui.pop_id()
|
||||
if self._scroll_disc_to_bottom:
|
||||
imgui.set_scroll_here_y(1.0)
|
||||
self._scroll_disc_to_bottom = False
|
||||
imgui.end_child()
|
||||
if imgui.button("+ Entry"):
|
||||
self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||
imgui.same_line()
|
||||
if imgui.button("-All"):
|
||||
for e in self.disc_entries: e["collapsed"] = True
|
||||
imgui.same_line()
|
||||
if imgui.button("+All"):
|
||||
for e in self.disc_entries: e["collapsed"] = False
|
||||
imgui.same_line()
|
||||
if imgui.button("Clear All"):
|
||||
self.disc_entries.clear()
|
||||
imgui.same_line()
|
||||
if imgui.button("Save"):
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
save_config(self.config)
|
||||
self.ai_status = "discussion saved"
|
||||
imgui.same_line()
|
||||
if imgui.button("Load Log"):
|
||||
self.cb_load_prior_log()
|
||||
|
||||
ch, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history)
|
||||
|
||||
# Truncation controls
|
||||
imgui.text("Keep Pairs:")
|
||||
imgui.same_line()
|
||||
imgui.set_next_item_width(80)
|
||||
ch, self.ui_disc_truncate_pairs = imgui.input_int("##trunc_pairs", self.ui_disc_truncate_pairs, 1)
|
||||
if self.ui_disc_truncate_pairs < 1: self.ui_disc_truncate_pairs = 1
|
||||
imgui.same_line()
|
||||
if imgui.button("Truncate"):
|
||||
self.disc_entries = truncate_entries(self.disc_entries, self.ui_disc_truncate_pairs)
|
||||
self.ai_status = f"history truncated to {self.ui_disc_truncate_pairs} pairs"
|
||||
imgui.separator()
|
||||
|
||||
if imgui.collapsing_header("Roles"):
|
||||
imgui.begin_child("roles_scroll", imgui.ImVec2(0, 100), True)
|
||||
for i, r in enumerate(self.disc_roles):
|
||||
if imgui.button(f"x##r{i}"):
|
||||
self.disc_roles.pop(i)
|
||||
break
|
||||
imgui.same_line()
|
||||
imgui.text(r)
|
||||
imgui.end_child()
|
||||
ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input)
|
||||
imgui.same_line()
|
||||
if imgui.button("Add"):
|
||||
r = self.ui_disc_new_role_input.strip()
|
||||
if r and r not in self.disc_roles:
|
||||
self.disc_roles.append(r)
|
||||
self.ui_disc_new_role_input = ""
|
||||
|
||||
imgui.separator()
|
||||
imgui.begin_child("disc_scroll", imgui.ImVec2(0, 0), False)
|
||||
for i, entry in enumerate(self.disc_entries):
|
||||
imgui.push_id(str(i))
|
||||
collapsed = entry.get("collapsed", False)
|
||||
read_mode = entry.get("read_mode", False)
|
||||
|
||||
if imgui.button("+" if collapsed else "-"):
|
||||
entry["collapsed"] = not collapsed
|
||||
imgui.same_line()
|
||||
|
||||
imgui.set_next_item_width(120)
|
||||
if imgui.begin_combo("##role", entry["role"]):
|
||||
for r in self.disc_roles:
|
||||
if imgui.selectable(r, r == entry["role"])[0]:
|
||||
entry["role"] = r
|
||||
imgui.end_combo()
|
||||
|
||||
if not collapsed:
|
||||
imgui.same_line()
|
||||
if imgui.button("[Edit]" if read_mode else "[Read]"):
|
||||
entry["read_mode"] = not read_mode
|
||||
|
||||
ts_str = entry.get("ts", "")
|
||||
if ts_str:
|
||||
imgui.same_line()
|
||||
imgui.text_colored(vec4(120, 120, 100), ts_str)
|
||||
|
||||
if collapsed:
|
||||
imgui.same_line()
|
||||
if imgui.button("Ins"):
|
||||
self.disc_entries.insert(i, {"role": "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||
imgui.same_line()
|
||||
self._render_text_viewer(f"Entry #{i+1}", entry["content"])
|
||||
imgui.same_line()
|
||||
if imgui.button("Del"):
|
||||
self.disc_entries.pop(i)
|
||||
imgui.pop_id()
|
||||
break
|
||||
imgui.same_line()
|
||||
preview = entry["content"].replace("\n", " ")[:60]
|
||||
if len(entry["content"]) > 60: preview += "..."
|
||||
imgui.text_colored(vec4(160, 160, 150), preview)
|
||||
|
||||
if not collapsed:
|
||||
if read_mode:
|
||||
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
|
||||
if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
||||
imgui.text(entry["content"])
|
||||
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
|
||||
imgui.end_child()
|
||||
else:
|
||||
ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150))
|
||||
|
||||
imgui.separator()
|
||||
imgui.pop_id()
|
||||
if self._scroll_disc_to_bottom:
|
||||
imgui.set_scroll_here_y(1.0)
|
||||
self._scroll_disc_to_bottom = False
|
||||
imgui.end_child()
|
||||
imgui.end()
|
||||
|
||||
# ---- Provider
|
||||
@@ -836,16 +1069,48 @@ class App:
|
||||
ch, self.temperature = imgui.slider_float("Temperature", self.temperature, 0.0, 2.0, "%.2f")
|
||||
ch, self.max_tokens = imgui.input_int("Max Tokens (Output)", self.max_tokens, 1024)
|
||||
ch, self.history_trunc_limit = imgui.input_int("History Truncation Limit", self.history_trunc_limit, 1024)
|
||||
|
||||
imgui.separator()
|
||||
imgui.text("Telemetry")
|
||||
usage = self.session_usage
|
||||
total = usage["input_tokens"] + usage["output_tokens"]
|
||||
imgui.text_colored(C_RES, f"Tokens: {total:,} (In: {usage['input_tokens']:,} Out: {usage['output_tokens']:,})")
|
||||
if usage["cache_read_input_tokens"]:
|
||||
imgui.text_colored(C_LBL, f" Cache Read: {usage['cache_read_input_tokens']:,} Creation: {usage['cache_creation_input_tokens']:,}")
|
||||
imgui.text("Token Budget:")
|
||||
imgui.progress_bar(self._token_budget_pct, imgui.ImVec2(-1, 0), f"{self._token_budget_current:,} / {self._token_budget_limit:,}")
|
||||
if self._gemini_cache_text:
|
||||
imgui.text_colored(C_SUB, self._gemini_cache_text)
|
||||
imgui.end()
|
||||
|
||||
# ---- Message
|
||||
if self.show_windows["Message"]:
|
||||
exp, self.show_windows["Message"] = imgui.begin("Message", self.show_windows["Message"])
|
||||
if exp:
|
||||
# LIVE indicator
|
||||
is_live = self.ai_status in ["running powershell...", "fetching url...", "searching web...", "powershell done, awaiting AI..."]
|
||||
if is_live:
|
||||
val = math.sin(time.time() * 10 * math.pi)
|
||||
alpha = 1.0 if val > 0 else 0.0
|
||||
imgui.text_colored(imgui.ImVec4(0.39, 1.0, 0.39, alpha), "LIVE")
|
||||
imgui.separator()
|
||||
|
||||
ch, self.ui_ai_input = imgui.input_text_multiline("##ai_in", self.ui_ai_input, imgui.ImVec2(-1, -40))
|
||||
|
||||
# Keyboard shortcuts
|
||||
io = imgui.get_io()
|
||||
ctrl_enter = io.key_ctrl and imgui.is_key_pressed(imgui.Key.enter)
|
||||
ctrl_l = io.key_ctrl and imgui.is_key_pressed(imgui.Key.l)
|
||||
if ctrl_l:
|
||||
self.ui_ai_input = ""
|
||||
|
||||
imgui.separator()
|
||||
if imgui.button("Gen + Send"):
|
||||
if not (self.send_thread and self.send_thread.is_alive()):
|
||||
send_busy = False
|
||||
with self._send_thread_lock:
|
||||
if self.send_thread and self.send_thread.is_alive():
|
||||
send_busy = True
|
||||
if imgui.button("Gen + Send") or ctrl_enter:
|
||||
if not send_busy:
|
||||
try:
|
||||
md, path, file_items, stable_md, disc_text = self._do_generate()
|
||||
self.last_md = md
|
||||
@@ -859,14 +1124,17 @@ class App:
|
||||
base_dir = self.ui_files_base_dir
|
||||
csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()])
|
||||
ai_client.set_custom_system_prompt("\n\n".join(csp))
|
||||
|
||||
ai_client.set_model_params(self.temperature, self.max_tokens, self.history_trunc_limit)
|
||||
ai_client.set_agent_tools(self.ui_agent_tools)
|
||||
send_md = stable_md
|
||||
send_disc = disc_text
|
||||
|
||||
def do_send():
|
||||
if self.ui_auto_add_history:
|
||||
with self._pending_history_adds_lock:
|
||||
self._pending_history_adds.append({"role": "User", "content": user_msg, "collapsed": False, "ts": project_manager.now_ts()})
|
||||
try:
|
||||
resp = ai_client.send(self.last_md, user_msg, base_dir, self.last_file_items)
|
||||
resp = ai_client.send(send_md, user_msg, base_dir, self.last_file_items, send_disc)
|
||||
self.ai_response = resp
|
||||
self.ai_status = "done"
|
||||
self._trigger_blink = True
|
||||
@@ -887,9 +1155,10 @@ class App:
|
||||
if self.ui_auto_add_history:
|
||||
with self._pending_history_adds_lock:
|
||||
self._pending_history_adds.append({"role": "System", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()})
|
||||
|
||||
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
||||
self.send_thread.start()
|
||||
|
||||
with self._send_thread_lock:
|
||||
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
||||
self.send_thread.start()
|
||||
imgui.same_line()
|
||||
if imgui.button("MD Only"):
|
||||
try:
|
||||
@@ -1168,6 +1437,67 @@ class App:
|
||||
if ch: theme.set_scale(scale)
|
||||
imgui.end()
|
||||
|
||||
# ---- Diagnostics
|
||||
if self.show_windows["Diagnostics"]:
|
||||
exp, self.show_windows["Diagnostics"] = imgui.begin("Diagnostics", self.show_windows["Diagnostics"])
|
||||
if exp:
|
||||
now = time.time()
|
||||
if now - self._perf_last_update >= 0.5:
|
||||
self._perf_last_update = now
|
||||
metrics = self.perf_monitor.get_metrics()
|
||||
self.perf_history["frame_time"].pop(0)
|
||||
self.perf_history["frame_time"].append(metrics.get("last_frame_time_ms", 0.0))
|
||||
self.perf_history["fps"].pop(0)
|
||||
self.perf_history["fps"].append(metrics.get("fps", 0.0))
|
||||
self.perf_history["cpu"].pop(0)
|
||||
self.perf_history["cpu"].append(metrics.get("cpu_percent", 0.0))
|
||||
self.perf_history["input_lag"].pop(0)
|
||||
self.perf_history["input_lag"].append(metrics.get("input_lag_ms", 0.0))
|
||||
|
||||
metrics = self.perf_monitor.get_metrics()
|
||||
imgui.text("Performance Telemetry")
|
||||
imgui.separator()
|
||||
|
||||
if imgui.begin_table("perf_table", 2, imgui.TableFlags_.borders_inner_h):
|
||||
imgui.table_setup_column("Metric")
|
||||
imgui.table_setup_column("Value")
|
||||
imgui.table_headers_row()
|
||||
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text("FPS")
|
||||
imgui.table_next_column()
|
||||
imgui.text(f"{metrics.get('fps', 0.0):.1f}")
|
||||
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text("Frame Time (ms)")
|
||||
imgui.table_next_column()
|
||||
imgui.text(f"{metrics.get('last_frame_time_ms', 0.0):.2f}")
|
||||
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text("CPU %")
|
||||
imgui.table_next_column()
|
||||
imgui.text(f"{metrics.get('cpu_percent', 0.0):.1f}")
|
||||
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text("Input Lag (ms)")
|
||||
imgui.table_next_column()
|
||||
imgui.text(f"{metrics.get('input_lag_ms', 0.0):.1f}")
|
||||
|
||||
imgui.end_table()
|
||||
|
||||
imgui.separator()
|
||||
imgui.text("Frame Time (ms)")
|
||||
imgui.plot_lines("##ft_plot", np.array(self.perf_history["frame_time"], dtype=np.float32), overlay_text="frame_time", graph_size=imgui.ImVec2(-1, 60))
|
||||
imgui.text("CPU %")
|
||||
imgui.plot_lines("##cpu_plot", np.array(self.perf_history["cpu"], dtype=np.float32), overlay_text="cpu", graph_size=imgui.ImVec2(-1, 60))
|
||||
imgui.end()
|
||||
|
||||
self.perf_monitor.end_frame()
|
||||
|
||||
# ---- Modals / Popups
|
||||
with self._pending_dialog_lock:
|
||||
dlg = self._pending_dialog
|
||||
@@ -1293,10 +1623,16 @@ class App:
|
||||
self.runner_params.callbacks.post_init = self._post_init
|
||||
|
||||
self._fetch_models(self.current_provider)
|
||||
|
||||
|
||||
# Start API hooks server (if enabled)
|
||||
self.hook_server = api_hooks.HookServer(self)
|
||||
self.hook_server.start()
|
||||
|
||||
immapp.run(self.runner_params)
|
||||
|
||||
# On exit
|
||||
self.hook_server.stop()
|
||||
self.perf_monitor.stop()
|
||||
ai_client.cleanup() # Destroy active API caches to stop billing
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@ DockId=0x00000015,0
|
||||
|
||||
[Window][Discussion History]
|
||||
Pos=598,128
|
||||
Size=712,619
|
||||
Size=554,619
|
||||
Collapsed=0
|
||||
DockId=0x0000000E,0
|
||||
|
||||
@@ -39,7 +39,7 @@ DockId=0x0000000A,0
|
||||
|
||||
[Window][Message]
|
||||
Pos=598,749
|
||||
Size=712,451
|
||||
Size=554,451
|
||||
Collapsed=0
|
||||
DockId=0x0000000C,0
|
||||
|
||||
@@ -50,22 +50,22 @@ Collapsed=0
|
||||
DockId=0x00000010,0
|
||||
|
||||
[Window][Tool Calls]
|
||||
Pos=1312,733
|
||||
Size=368,144
|
||||
Pos=1154,733
|
||||
Size=526,144
|
||||
Collapsed=0
|
||||
DockId=0x00000008,0
|
||||
|
||||
[Window][Comms History]
|
||||
Pos=1312,879
|
||||
Size=368,321
|
||||
Pos=1154,879
|
||||
Size=526,321
|
||||
Collapsed=0
|
||||
DockId=0x00000006,0
|
||||
|
||||
[Window][System Prompts]
|
||||
Pos=1312,0
|
||||
Size=368,731
|
||||
Pos=1154,0
|
||||
Size=286,731
|
||||
Collapsed=0
|
||||
DockId=0x00000007,0
|
||||
DockId=0x00000017,0
|
||||
|
||||
[Window][Theme]
|
||||
Pos=209,173
|
||||
@@ -78,11 +78,17 @@ Pos=379,324
|
||||
Size=900,700
|
||||
Collapsed=0
|
||||
|
||||
[Window][Diagnostics]
|
||||
Pos=1442,0
|
||||
Size=238,731
|
||||
Collapsed=0
|
||||
DockId=0x00000018,0
|
||||
|
||||
[Docking][Data]
|
||||
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=138,161 Size=1680,1200 Split=X
|
||||
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=346,232 Size=1680,1200 Split=X
|
||||
DockNode ID=0x00000011 Parent=0xAFC85805 SizeRef=207,1200 Selected=0x0469CA7A
|
||||
DockNode ID=0x00000012 Parent=0xAFC85805 SizeRef=1559,1200 Split=X
|
||||
DockNode ID=0x00000003 Parent=0x00000012 SizeRef=1189,1200 Split=X
|
||||
DockNode ID=0x00000003 Parent=0x00000012 SizeRef=943,1200 Split=X
|
||||
DockNode ID=0x00000001 Parent=0x00000003 SizeRef=387,1200 Split=Y Selected=0x8CA2375C
|
||||
DockNode ID=0x00000009 Parent=0x00000001 SizeRef=405,911 Split=Y Selected=0x8CA2375C
|
||||
DockNode ID=0x0000000F Parent=0x00000009 SizeRef=405,733 Split=Y Selected=0x8CA2375C
|
||||
@@ -92,14 +98,16 @@ DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=138,161 Size=1680,12
|
||||
DockNode ID=0x00000014 Parent=0x0000000F SizeRef=405,337 Selected=0xDA22FEDA
|
||||
DockNode ID=0x00000010 Parent=0x00000009 SizeRef=405,176 Selected=0x0D5A5273
|
||||
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=405,287 Selected=0xA07B5F14
|
||||
DockNode ID=0x00000002 Parent=0x00000003 SizeRef=800,1200 Split=Y
|
||||
DockNode ID=0x00000002 Parent=0x00000003 SizeRef=554,1200 Split=Y
|
||||
DockNode ID=0x0000000B Parent=0x00000002 SizeRef=1010,747 Split=Y
|
||||
DockNode ID=0x0000000D Parent=0x0000000B SizeRef=1010,126 CentralNode=1
|
||||
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1010,619 Selected=0x5D11106F
|
||||
DockNode ID=0x0000000C Parent=0x00000002 SizeRef=1010,451 Selected=0x66CFB56E
|
||||
DockNode ID=0x00000004 Parent=0x00000012 SizeRef=368,1200 Split=Y Selected=0xDD6419BC
|
||||
DockNode ID=0x00000004 Parent=0x00000012 SizeRef=526,1200 Split=Y Selected=0xDD6419BC
|
||||
DockNode ID=0x00000005 Parent=0x00000004 SizeRef=261,877 Split=Y Selected=0xDD6419BC
|
||||
DockNode ID=0x00000007 Parent=0x00000005 SizeRef=261,731 Selected=0xDD6419BC
|
||||
DockNode ID=0x00000007 Parent=0x00000005 SizeRef=261,731 Split=X Selected=0xDD6419BC
|
||||
DockNode ID=0x00000017 Parent=0x00000007 SizeRef=286,731 Selected=0xDD6419BC
|
||||
DockNode ID=0x00000018 Parent=0x00000007 SizeRef=238,731 Selected=0xB4CBF21A
|
||||
DockNode ID=0x00000008 Parent=0x00000005 SizeRef=261,144 Selected=0x1D56B311
|
||||
DockNode ID=0x00000006 Parent=0x00000004 SizeRef=261,321 Selected=0x8B4EBFA6
|
||||
|
||||
|
||||
@@ -65,7 +65,10 @@ def configure(file_items: list[dict], extra_base_dirs: list[str] | None = None):
|
||||
for item in file_items:
|
||||
p = item.get("path")
|
||||
if p is not None:
|
||||
rp = Path(p).resolve()
|
||||
try:
|
||||
rp = Path(p).resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
rp = Path(p).resolve()
|
||||
_allowed_paths.add(rp)
|
||||
_base_dirs.add(rp.parent)
|
||||
|
||||
@@ -82,8 +85,13 @@ def _is_allowed(path: Path) -> bool:
|
||||
A path is allowed if:
|
||||
- it is explicitly in _allowed_paths, OR
|
||||
- it is contained within (or equal to) one of the _base_dirs
|
||||
All paths are resolved (follows symlinks) before comparison to prevent
|
||||
symlink-based path traversal.
|
||||
"""
|
||||
rp = path.resolve()
|
||||
try:
|
||||
rp = path.resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
rp = path.resolve()
|
||||
if rp in _allowed_paths:
|
||||
return True
|
||||
for bd in _base_dirs:
|
||||
@@ -104,7 +112,10 @@ def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]:
|
||||
p = Path(raw_path)
|
||||
if not p.is_absolute() and _primary_base_dir:
|
||||
p = _primary_base_dir / p
|
||||
p = p.resolve()
|
||||
try:
|
||||
p = p.resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
p = p.resolve()
|
||||
except Exception as e:
|
||||
return None, f"ERROR: invalid path '{raw_path}': {e}"
|
||||
if not _is_allowed(p):
|
||||
@@ -269,7 +280,8 @@ def web_search(query: str) -> str:
|
||||
url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query)
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||
try:
|
||||
html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore')
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
html = resp.read().decode('utf-8', errors='ignore')
|
||||
parser = _DDGParser()
|
||||
parser.feed(html)
|
||||
if not parser.results:
|
||||
@@ -292,7 +304,8 @@ def fetch_url(url: str) -> str:
|
||||
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||
try:
|
||||
html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore')
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
html = resp.read().decode('utf-8', errors='ignore')
|
||||
parser = _TextExtractor()
|
||||
parser.feed(html)
|
||||
full_text = " ".join(parser.text)
|
||||
|
||||
@@ -35,5 +35,5 @@ active = "main"
|
||||
|
||||
[discussion.discussions.main]
|
||||
git_commit = ""
|
||||
last_updated = "2026-02-23T19:01:39"
|
||||
last_updated = "2026-02-23T16:52:30"
|
||||
history = []
|
||||
|
||||
@@ -26,6 +26,7 @@ scripts/generated/
|
||||
Where <ts> = YYYYMMDD_HHMMSS of when this session was started.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import datetime
|
||||
import json
|
||||
import threading
|
||||
@@ -71,6 +72,8 @@ def open_session():
|
||||
_tool_fh.write(f"# Tool-call log — session {_ts}\n\n")
|
||||
_tool_fh.flush()
|
||||
|
||||
atexit.register(close_session)
|
||||
|
||||
|
||||
def close_session():
|
||||
"""Flush and close both log files. Called on clean exit (optional)."""
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import random
|
||||
from api_hook_client import ApiHookClient
|
||||
from simulation.workflow_sim import WorkflowSimulator
|
||||
|
||||
def main():
|
||||
client = ApiHookClient()
|
||||
print("=== Manual Slop: Live UX Walkthrough ===")
|
||||
print("Connecting to GUI...")
|
||||
if not client.wait_for_server(timeout=10):
|
||||
print("Error: Could not connect to GUI. Ensure it is running with --enable-test-hooks")
|
||||
return
|
||||
|
||||
sim = WorkflowSimulator(client)
|
||||
|
||||
# 1. Start Clean
|
||||
print("\n[Action] Resetting Session...")
|
||||
client.click("btn_reset")
|
||||
time.sleep(2)
|
||||
|
||||
# 2. Project Scaffolding
|
||||
project_name = f"LiveTest_{int(time.time())}"
|
||||
# Use actual project dir for realism
|
||||
git_dir = os.path.abspath(".")
|
||||
|
||||
print(f"\n[Action] Scaffolding Project: {project_name}")
|
||||
sim.setup_new_project(project_name, git_dir)
|
||||
|
||||
# Enable auto-add so results appear in history automatically
|
||||
client.set_value("auto_add_history", True)
|
||||
time.sleep(1)
|
||||
|
||||
# 3. Discussion Loop (3 turns for speed, but logic supports more)
|
||||
turns = [
|
||||
"Hi! I want to create a simple python script called 'hello.py' that prints the current date and time. Can you write it for me?",
|
||||
"That looks great. Can you also add a feature to print the name of the operating system?",
|
||||
"Excellent. Now, please create a requirements.txt file with 'requests' in it."
|
||||
]
|
||||
|
||||
for i, msg in enumerate(turns):
|
||||
print(f"\n--- Turn {i+1} ---")
|
||||
|
||||
# Switch to Comms Log to see the send
|
||||
client.select_tab("operations_tabs", "tab_comms")
|
||||
|
||||
sim.run_discussion_turn(msg)
|
||||
|
||||
# Check thinking indicator
|
||||
state = client.get_indicator_state("thinking_indicator")
|
||||
if state.get('shown'):
|
||||
print("[Status] Thinking indicator is visible.")
|
||||
|
||||
# Switch to Tool Log halfway through wait
|
||||
time.sleep(2)
|
||||
client.select_tab("operations_tabs", "tab_tool")
|
||||
|
||||
# Wait for AI response if not already finished
|
||||
# (run_discussion_turn already waits, so we just observe)
|
||||
|
||||
# 4. History Management
|
||||
print("\n[Action] Creating new discussion thread...")
|
||||
sim.create_discussion("Refinement")
|
||||
|
||||
print("\n[Action] Switching back to Default...")
|
||||
sim.switch_discussion("Default")
|
||||
|
||||
# 5. Manual Sign-off Simulation
|
||||
print("\n=== Walkthrough Complete ===")
|
||||
print("Please verify the following in the GUI:")
|
||||
print("1. The project metadata reflects the new project.")
|
||||
print("2. The discussion history contains the 3 turns.")
|
||||
print("3. The 'Refinement' discussion exists in the list.")
|
||||
print("\nWalkthrough finished successfully.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,57 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
||||
# Ensure project root is in path
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from api_hook_client import ApiHookClient
|
||||
from simulation.user_agent import UserSimAgent
|
||||
|
||||
def main():
|
||||
client = ApiHookClient()
|
||||
print("Waiting for hook server...")
|
||||
if not client.wait_for_server(timeout=5):
|
||||
print("Hook server not found. Start GUI with --enable-test-hooks")
|
||||
return
|
||||
|
||||
sim_agent = UserSimAgent(client)
|
||||
|
||||
# 1. Reset session to start clean
|
||||
print("Resetting session...")
|
||||
client.click("btn_reset")
|
||||
time.sleep(2) # Give it time to clear
|
||||
|
||||
# 2. Initial message
|
||||
initial_msg = "Hello! I want to create a simple python script that prints 'Hello World'. Can you help me?"
|
||||
print(f"
|
||||
[USER]: {initial_msg}")
|
||||
client.set_value("ai_input", initial_msg)
|
||||
client.click("btn_gen_send")
|
||||
|
||||
# 3. Wait for AI response
|
||||
print("Waiting for AI response...", end="", flush=True)
|
||||
last_entry_count = 0
|
||||
for _ in range(60): # 60 seconds max
|
||||
time.sleep(1)
|
||||
print(".", end="", flush=True)
|
||||
session = client.get_session()
|
||||
entries = session.get('session', {}).get('entries', [])
|
||||
|
||||
if len(entries) > last_entry_count:
|
||||
# Something happened
|
||||
last_entry = entries[-1]
|
||||
if last_entry.get('role') == 'AI' and last_entry.get('content'):
|
||||
print(f"
|
||||
|
||||
[AI]: {last_entry.get('content')[:100]}...")
|
||||
print("
|
||||
Ping-pong successful!")
|
||||
return
|
||||
last_entry_count = len(entries)
|
||||
|
||||
print("
|
||||
Timeout waiting for AI response")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,47 +0,0 @@
|
||||
import time
|
||||
import random
|
||||
import ai_client
|
||||
|
||||
class UserSimAgent:
|
||||
def __init__(self, hook_client, model="gemini-2.0-flash"):
|
||||
self.hook_client = hook_client
|
||||
self.model = model
|
||||
self.system_prompt = (
|
||||
"You are a software engineer testing an AI coding assistant called 'Manual Slop'. "
|
||||
"You want to build a small Python project and verify the assistant's capabilities. "
|
||||
"Keep your responses concise and human-like. "
|
||||
"Do not use markdown blocks for your main message unless you are providing code."
|
||||
)
|
||||
|
||||
def generate_response(self, conversation_history):
|
||||
"""
|
||||
Generates a human-like response based on the conversation history.
|
||||
conversation_history: list of dicts with 'role' and 'content'
|
||||
"""
|
||||
# Format history for ai_client
|
||||
# ai_client expects md_content and user_message.
|
||||
# It handles its own internal history.
|
||||
# We want the 'User AI' to have context of what the 'Assistant AI' said.
|
||||
|
||||
# For now, let's just use the last message from Assistant as the prompt.
|
||||
last_ai_msg = ""
|
||||
for entry in reversed(conversation_history):
|
||||
if entry.get('role') == 'AI':
|
||||
last_ai_msg = entry.get('content', '')
|
||||
break
|
||||
|
||||
# We need to set a custom system prompt for the User Simulator
|
||||
ai_client.set_custom_system_prompt(self.system_prompt)
|
||||
|
||||
# We'll use a blank md_content for now as the 'User' doesn't need to read its own files
|
||||
# via the same mechanism, but we could provide it if needed.
|
||||
response = ai_client.send(md_content="", user_message=last_ai_msg)
|
||||
return response
|
||||
|
||||
def perform_action_with_delay(self, action_func, *args, **kwargs):
|
||||
"""
|
||||
Executes an action with a human-like delay.
|
||||
"""
|
||||
delay = random.uniform(0.5, 2.0)
|
||||
time.sleep(delay)
|
||||
return action_func(*args, **kwargs)
|
||||
@@ -1,73 +0,0 @@
|
||||
import time
|
||||
import os
|
||||
from api_hook_client import ApiHookClient
|
||||
from simulation.user_agent import UserSimAgent
|
||||
|
||||
class WorkflowSimulator:
|
||||
def __init__(self, hook_client: ApiHookClient):
|
||||
self.client = hook_client
|
||||
self.user_agent = UserSimAgent(hook_client)
|
||||
|
||||
def setup_new_project(self, name, git_dir):
|
||||
print(f"Setting up new project: {name}")
|
||||
self.client.click("btn_project_new")
|
||||
time.sleep(1)
|
||||
self.client.set_value("project_git_dir", git_dir)
|
||||
self.client.click("btn_project_save")
|
||||
time.sleep(1)
|
||||
|
||||
def create_discussion(self, name):
|
||||
print(f"Creating discussion: {name}")
|
||||
self.client.set_value("disc_new_name_input", name)
|
||||
self.client.click("btn_disc_create")
|
||||
time.sleep(1)
|
||||
|
||||
def switch_discussion(self, name):
|
||||
print(f"Switching to discussion: {name}")
|
||||
self.client.select_list_item("disc_listbox", name)
|
||||
time.sleep(1)
|
||||
|
||||
def load_prior_log(self):
|
||||
print("Loading prior log")
|
||||
self.client.click("btn_load_log")
|
||||
# This usually opens a file dialog which we can't easily automate from here
|
||||
# without more hooks, but we can verify the button click.
|
||||
time.sleep(1)
|
||||
|
||||
def truncate_history(self, pairs):
|
||||
print(f"Truncating history to {pairs} pairs")
|
||||
self.client.set_value("disc_truncate_pairs", pairs)
|
||||
self.client.click("btn_disc_truncate")
|
||||
time.sleep(1)
|
||||
|
||||
def run_discussion_turn(self, user_message=None):
|
||||
if user_message is None:
|
||||
# Generate from AI history
|
||||
session = self.client.get_session()
|
||||
entries = session.get('session', {}).get('entries', [])
|
||||
user_message = self.user_agent.generate_response(entries)
|
||||
|
||||
print(f"\n[USER]: {user_message}")
|
||||
self.client.set_value("ai_input", user_message)
|
||||
self.client.click("btn_gen_send")
|
||||
|
||||
# Wait for AI
|
||||
return self.wait_for_ai_response()
|
||||
|
||||
def wait_for_ai_response(self, timeout=60):
|
||||
print("Waiting for AI response...", end="", flush=True)
|
||||
start_time = time.time()
|
||||
last_count = len(self.client.get_session().get('session', {}).get('entries', []))
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
time.sleep(1)
|
||||
print(".", end="", flush=True)
|
||||
entries = self.client.get_session().get('session', {}).get('entries', [])
|
||||
if len(entries) > last_count:
|
||||
last_entry = entries[-1]
|
||||
if last_entry.get('role') == 'AI' and last_entry.get('content'):
|
||||
print(f"\n[AI]: {last_entry.get('content')[:100]}...")
|
||||
return last_entry
|
||||
|
||||
print("\nTimeout waiting for AI")
|
||||
return None
|
||||
@@ -32,15 +32,11 @@ def live_gui():
|
||||
"""
|
||||
print("\n[Fixture] Starting gui.py --enable-test-hooks...")
|
||||
|
||||
# Ensure logs directory exists
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
log_file = open("logs/gui_test.log", "w", encoding="utf-8")
|
||||
|
||||
# Start gui.py as a subprocess.
|
||||
process = subprocess.Popen(
|
||||
["uv", "run", "python", "gui.py", "--enable-test-hooks"],
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == 'nt' else 0
|
||||
)
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
[project]
|
||||
name = "temp_project"
|
||||
git_dir = "C:\\projects\\manual_slop"
|
||||
system_prompt = ""
|
||||
main_context = ""
|
||||
word_wrap = true
|
||||
|
||||
[output]
|
||||
output_dir = "./md_gen"
|
||||
|
||||
[files]
|
||||
base_dir = "."
|
||||
paths = []
|
||||
|
||||
[screenshots]
|
||||
base_dir = "."
|
||||
paths = []
|
||||
|
||||
[agent.tools]
|
||||
run_powershell = true
|
||||
read_file = true
|
||||
list_directory = true
|
||||
search_files = true
|
||||
get_file_summary = true
|
||||
web_search = true
|
||||
fetch_url = true
|
||||
|
||||
[discussion]
|
||||
roles = [
|
||||
"User",
|
||||
"AI",
|
||||
"Vendor API",
|
||||
"System",
|
||||
]
|
||||
active = "main"
|
||||
auto_add = true
|
||||
|
||||
[discussion.discussions.main]
|
||||
git_commit = ""
|
||||
last_updated = "2026-02-23T19:53:17"
|
||||
history = []
|
||||
@@ -1,75 +0,0 @@
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure project root is in path for imports
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from api_hook_client import ApiHookClient
|
||||
|
||||
def test_api_client_has_extensions():
|
||||
client = ApiHookClient()
|
||||
# These should fail initially as they are not implemented
|
||||
assert hasattr(client, 'select_tab')
|
||||
assert hasattr(client, 'select_list_item')
|
||||
|
||||
def test_select_tab_integration(live_gui):
|
||||
client = ApiHookClient()
|
||||
# We'll need to make sure the tags exist in gui.py
|
||||
# For now, this is a placeholder for the integration test
|
||||
response = client.select_tab("operations_tabs", "tab_tool")
|
||||
assert response == {'status': 'queued'}
|
||||
|
||||
def test_select_list_item_integration(live_gui):
|
||||
client = ApiHookClient()
|
||||
# Assuming 'Default' discussion exists or we can just test that it queues
|
||||
response = client.select_list_item("disc_listbox", "Default")
|
||||
assert response == {'status': 'queued'}
|
||||
|
||||
def test_get_indicator_state_integration(live_gui):
|
||||
client = ApiHookClient()
|
||||
# thinking_indicator is usually hidden unless AI is running
|
||||
response = client.get_indicator_state("thinking_indicator")
|
||||
assert 'shown' in response
|
||||
assert response['tag'] == "thinking_indicator"
|
||||
|
||||
def test_app_processes_new_actions():
|
||||
import gui
|
||||
from unittest.mock import MagicMock, patch
|
||||
import dearpygui.dearpygui as dpg
|
||||
|
||||
dpg.create_context()
|
||||
try:
|
||||
with patch('gui.load_config', return_value={}), \
|
||||
patch('gui.PerformanceMonitor'), \
|
||||
patch('gui.shell_runner'), \
|
||||
patch('gui.project_manager'), \
|
||||
patch.object(gui.App, '_load_active_project'):
|
||||
app = gui.App()
|
||||
|
||||
with patch('dearpygui.dearpygui.set_value') as mock_set_value, \
|
||||
patch('dearpygui.dearpygui.does_item_exist', return_value=True), \
|
||||
patch('dearpygui.dearpygui.get_item_callback') as mock_get_cb:
|
||||
|
||||
# Test select_tab
|
||||
app._pending_gui_tasks.append({
|
||||
"action": "select_tab",
|
||||
"tab_bar": "some_tab_bar",
|
||||
"tab": "some_tab"
|
||||
})
|
||||
app._process_pending_gui_tasks()
|
||||
mock_set_value.assert_any_call("some_tab_bar", "some_tab")
|
||||
|
||||
# Test select_list_item
|
||||
mock_cb = MagicMock()
|
||||
mock_get_cb.return_value = mock_cb
|
||||
app._pending_gui_tasks.append({
|
||||
"action": "select_list_item",
|
||||
"listbox": "some_listbox",
|
||||
"item_value": "some_value"
|
||||
})
|
||||
app._process_pending_gui_tasks()
|
||||
mock_set_value.assert_any_call("some_listbox", "some_value")
|
||||
mock_cb.assert_called_with("some_listbox", "some_value")
|
||||
finally:
|
||||
dpg.destroy_context()
|
||||
@@ -1,88 +0,0 @@
|
||||
import pytest
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure project root is in path
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from api_hook_client import ApiHookClient
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_full_live_workflow(live_gui):
|
||||
"""
|
||||
Integration test that drives the GUI through a full workflow.
|
||||
"""
|
||||
client = ApiHookClient()
|
||||
assert client.wait_for_server(timeout=10)
|
||||
time.sleep(2)
|
||||
|
||||
# 1. Reset
|
||||
client.click("btn_reset")
|
||||
time.sleep(1)
|
||||
|
||||
# 2. Project Setup
|
||||
temp_project_path = os.path.abspath("tests/temp_project.toml")
|
||||
if os.path.exists(temp_project_path):
|
||||
os.remove(temp_project_path)
|
||||
|
||||
client.click("btn_project_new_automated", user_data=temp_project_path)
|
||||
time.sleep(1) # Wait for project creation and switch
|
||||
|
||||
# Verify metadata update
|
||||
proj = client.get_project()
|
||||
|
||||
test_git = os.path.abspath(".")
|
||||
client.set_value("project_git_dir", test_git)
|
||||
client.click("btn_project_save")
|
||||
time.sleep(1)
|
||||
|
||||
proj = client.get_project()
|
||||
# flat_config returns {"project": {...}, "output": ...}
|
||||
# so proj is {"project": {"project": {"git_dir": ...}}}
|
||||
assert proj['project']['project']['git_dir'] == test_git
|
||||
|
||||
# Enable auto-add so the response ends up in history
|
||||
client.set_value("auto_add_history", True)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 3. Discussion Turn
|
||||
client.set_value("ai_input", "Hello! This is an automated test. Just say 'Acknowledged'.")
|
||||
client.click("btn_gen_send")
|
||||
|
||||
# Verify thinking indicator appears (might be brief)
|
||||
thinking_seen = False
|
||||
print("\nPolling for thinking indicator...")
|
||||
for i in range(20):
|
||||
state = client.get_indicator_state("thinking_indicator")
|
||||
if state.get('shown'):
|
||||
thinking_seen = True
|
||||
print(f"Thinking indicator seen at poll {i}")
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
# 4. Wait for response in session
|
||||
success = False
|
||||
print("Waiting for AI response in session...")
|
||||
for i in range(60):
|
||||
session = client.get_session()
|
||||
entries = session.get('session', {}).get('entries', [])
|
||||
if any(e.get('role') == 'AI' for e in entries):
|
||||
success = True
|
||||
print(f"AI response found at second {i}")
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
assert success, "AI failed to respond within 60 seconds"
|
||||
|
||||
# 5. Switch Discussion
|
||||
client.set_value("disc_new_name_input", "AutoDisc")
|
||||
client.click("btn_disc_create")
|
||||
time.sleep(0.5)
|
||||
|
||||
client.select_list_item("disc_listbox", "AutoDisc")
|
||||
time.sleep(0.5)
|
||||
|
||||
# Verify session is empty in new discussion
|
||||
session = client.get_session()
|
||||
assert len(session.get('session', {}).get('entries', [])) == 0
|
||||
@@ -1,22 +0,0 @@
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure project root is in path for imports
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from simulation.user_agent import UserSimAgent
|
||||
|
||||
def test_user_agent_instantiation():
|
||||
agent = UserSimAgent(hook_client=None)
|
||||
assert agent is not None
|
||||
|
||||
def test_perform_action_with_delay():
|
||||
agent = UserSimAgent(hook_client=None)
|
||||
called = False
|
||||
def action():
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
agent.perform_action_with_delay(action)
|
||||
assert called is True
|
||||
@@ -1,47 +0,0 @@
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Ensure project root is in path
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from simulation.workflow_sim import WorkflowSimulator
|
||||
|
||||
def test_simulator_instantiation():
|
||||
client = MagicMock()
|
||||
sim = WorkflowSimulator(client)
|
||||
assert sim is not None
|
||||
|
||||
def test_setup_new_project():
|
||||
client = MagicMock()
|
||||
sim = WorkflowSimulator(client)
|
||||
|
||||
# Mock responses for wait_for_server
|
||||
client.wait_for_server.return_value = True
|
||||
|
||||
sim.setup_new_project("TestProject", "/tmp/test_git")
|
||||
|
||||
# Verify hook calls
|
||||
client.click.assert_any_call("btn_project_new")
|
||||
client.set_value.assert_any_call("project_git_dir", "/tmp/test_git")
|
||||
client.click.assert_any_call("btn_project_save")
|
||||
|
||||
def test_discussion_switching():
|
||||
client = MagicMock()
|
||||
sim = WorkflowSimulator(client)
|
||||
|
||||
sim.create_discussion("NewDisc")
|
||||
client.set_value.assert_called_with("disc_new_name_input", "NewDisc")
|
||||
client.click.assert_called_with("btn_disc_create")
|
||||
|
||||
sim.switch_discussion("NewDisc")
|
||||
client.select_list_item.assert_called_with("disc_listbox", "NewDisc")
|
||||
|
||||
def test_history_truncation():
|
||||
client = MagicMock()
|
||||
sim = WorkflowSimulator(client)
|
||||
|
||||
sim.truncate_history(3)
|
||||
client.set_value.assert_called_with("disc_truncate_pairs", 3)
|
||||
client.click.assert_called_with("btn_disc_truncate")
|
||||
Reference in New Issue
Block a user