Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb1117becc | |||
| df90bad4a1 | |||
| 9f2ed38845 | |||
| 59f4df4475 | |||
| c4da60d1c5 | |||
| 47c4117763 | |||
| 8e63b31508 | |||
| 8bd280efc1 | |||
| ba97ccda3c | |||
| 0f04e066ef | |||
| 5e1b965311 | |||
| fdb9b59d36 | |||
| 9c4a72c734 | |||
| 6d16438477 | |||
| bd5dc16715 | |||
| 895004ddc5 | |||
| 76265319a7 | |||
| bfe9ef014d | |||
| d326242667 | |||
| f36d539c36 |
15
aggregate.py
15
aggregate.py
@@ -164,18 +164,6 @@ def build_markdown_from_items(file_items: list[dict], screenshot_base_dir: Path,
|
|||||||
return "\n\n---\n\n".join(parts)
|
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:
|
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 = []
|
parts = []
|
||||||
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
|
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
|
||||||
@@ -207,9 +195,8 @@ def run(config: dict) -> tuple[str, Path, list[dict]]:
|
|||||||
output_file = output_dir / f"{namespace}_{increment:03d}.md"
|
output_file = output_dir / f"{namespace}_{increment:03d}.md"
|
||||||
# Build file items once, then construct markdown from them (avoids double I/O)
|
# Build file items once, then construct markdown from them (avoids double I/O)
|
||||||
file_items = build_file_items(base_dir, files)
|
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,
|
markdown = build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history,
|
||||||
summary_only=summary_only)
|
summary_only=False)
|
||||||
output_file.write_text(markdown, encoding="utf-8")
|
output_file.write_text(markdown, encoding="utf-8")
|
||||||
return markdown, output_file, file_items
|
return markdown, output_file, file_items
|
||||||
|
|
||||||
|
|||||||
150
ai_client.py
150
ai_client.py
@@ -15,11 +15,7 @@ import tomllib
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
|
||||||
import difflib
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
|
||||||
import file_cache
|
import file_cache
|
||||||
import mcp_client
|
import mcp_client
|
||||||
import anthropic
|
import anthropic
|
||||||
@@ -55,8 +51,6 @@ _GEMINI_CACHE_TTL = 3600
|
|||||||
|
|
||||||
_anthropic_client = None
|
_anthropic_client = None
|
||||||
_anthropic_history: list[dict] = []
|
_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.
|
# Injected by gui.py - called when AI wants to run a command.
|
||||||
# Signature: (script: str, base_dir: str) -> str | None
|
# Signature: (script: str, base_dir: str) -> str | None
|
||||||
@@ -73,10 +67,6 @@ tool_log_callback = None
|
|||||||
# Increased to allow thorough code exploration before forcing a summary
|
# Increased to allow thorough code exploration before forcing a summary
|
||||||
MAX_TOOL_ROUNDS = 10
|
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.
|
# Maximum characters per text chunk sent to Anthropic.
|
||||||
# Kept well under the ~200k token API limit.
|
# Kept well under the ~200k token API limit.
|
||||||
_ANTHROPIC_CHUNK_SIZE = 120_000
|
_ANTHROPIC_CHUNK_SIZE = 120_000
|
||||||
@@ -138,18 +128,8 @@ def clear_comms_log():
|
|||||||
|
|
||||||
|
|
||||||
def _load_credentials() -> dict:
|
def _load_credentials() -> dict:
|
||||||
cred_path = os.environ.get("SLOP_CREDENTIALS", "credentials.toml")
|
with open("credentials.toml", "rb") as f:
|
||||||
try:
|
|
||||||
with open(cred_path, "rb") as f:
|
|
||||||
return tomllib.load(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
|
# ------------------------------------------------------------------ provider errors
|
||||||
@@ -264,7 +244,6 @@ def reset_session():
|
|||||||
_gemini_cache_md_hash = None
|
_gemini_cache_md_hash = None
|
||||||
_gemini_cache_created_at = None
|
_gemini_cache_created_at = None
|
||||||
_anthropic_client = None
|
_anthropic_client = None
|
||||||
with _anthropic_history_lock:
|
|
||||||
_anthropic_history = []
|
_anthropic_history = []
|
||||||
_CACHED_ANTHROPIC_TOOLS = None
|
_CACHED_ANTHROPIC_TOOLS = None
|
||||||
file_cache.reset_client()
|
file_cache.reset_client()
|
||||||
@@ -456,13 +435,6 @@ def _run_script(script: str, base_dir: str) -> str:
|
|||||||
return output
|
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
|
# ------------------------------------------------------------------ dynamic file context refresh
|
||||||
|
|
||||||
def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||||
@@ -488,7 +460,7 @@ def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
|||||||
refreshed.append(item) # unchanged — skip re-read
|
refreshed.append(item) # unchanged — skip re-read
|
||||||
continue
|
continue
|
||||||
content = p.read_text(encoding="utf-8")
|
content = p.read_text(encoding="utf-8")
|
||||||
new_item = {**item, "old_content": item.get("content", ""), "content": content, "error": False, "mtime": current_mtime}
|
new_item = {**item, "content": content, "error": False, "mtime": current_mtime}
|
||||||
refreshed.append(new_item)
|
refreshed.append(new_item)
|
||||||
changed.append(new_item)
|
changed.append(new_item)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -514,35 +486,6 @@ def _build_file_context_text(file_items: list[dict]) -> str:
|
|||||||
return "\n\n---\n\n".join(parts)
|
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
|
# ------------------------------------------------------------------ content block serialisation
|
||||||
|
|
||||||
def _content_block_to_dict(block) -> dict:
|
def _content_block_to_dict(block) -> dict:
|
||||||
@@ -587,26 +530,22 @@ def _get_gemini_history_list(chat):
|
|||||||
return chat.get_history()
|
return chat.get_history()
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _send_gemini(md_content: str, user_message: str, base_dir: str,
|
def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
||||||
file_items: list[dict] | None = None,
|
|
||||||
discussion_history: str = "") -> str:
|
|
||||||
global _gemini_chat, _gemini_cache, _gemini_cache_md_hash, _gemini_cache_created_at
|
global _gemini_chat, _gemini_cache, _gemini_cache_md_hash, _gemini_cache_created_at
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_ensure_gemini_client(); mcp_client.configure(file_items or [], [base_dir])
|
_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>"
|
sys_instr = f"{_get_combined_system_prompt()}\n\n<context>\n{md_content}\n</context>"
|
||||||
tools_decl = [_gemini_tool_declaration()]
|
tools_decl = [_gemini_tool_declaration()]
|
||||||
|
|
||||||
# DYNAMIC CONTEXT: Check if files/context changed mid-session
|
# DYNAMIC CONTEXT: Check if files/context changed mid-session
|
||||||
current_md_hash = hashlib.md5(md_content.encode()).hexdigest()
|
current_md_hash = hash(md_content)
|
||||||
old_history = None
|
old_history = None
|
||||||
if _gemini_chat and _gemini_cache_md_hash != current_md_hash:
|
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 []
|
old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else []
|
||||||
if _gemini_cache:
|
if _gemini_cache:
|
||||||
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||||
except Exception as e: _append_comms("OUT", "request", {"message": f"[CACHE DELETE WARN] {e}"})
|
except: pass
|
||||||
_gemini_chat = None
|
_gemini_chat = None
|
||||||
_gemini_cache = None
|
_gemini_cache = None
|
||||||
_gemini_cache_created_at = None
|
_gemini_cache_created_at = None
|
||||||
@@ -619,7 +558,7 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str,
|
|||||||
if elapsed > _GEMINI_CACHE_TTL * 0.9:
|
if elapsed > _GEMINI_CACHE_TTL * 0.9:
|
||||||
old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else []
|
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)
|
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||||
except Exception as e: _append_comms("OUT", "request", {"message": f"[CACHE DELETE WARN] {e}"})
|
except: pass
|
||||||
_gemini_chat = None
|
_gemini_chat = None
|
||||||
_gemini_cache = None
|
_gemini_cache = None
|
||||||
_gemini_cache_created_at = None
|
_gemini_cache_created_at = None
|
||||||
@@ -663,15 +602,8 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str,
|
|||||||
_gemini_chat = _gemini_client.chats.create(**kwargs)
|
_gemini_chat = _gemini_client.chats.create(**kwargs)
|
||||||
_gemini_cache_md_hash = current_md_hash
|
_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)}]"})
|
_append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"})
|
||||||
payload, all_text = user_message, []
|
payload, all_text = user_message, []
|
||||||
_cumulative_tool_bytes = 0
|
|
||||||
|
|
||||||
# Strip stale file refreshes and truncate old tool outputs ONCE before
|
# Strip stale file refreshes and truncate old tool outputs ONCE before
|
||||||
# entering the tool loop (not per-round — history entries don't change).
|
# entering the tool loop (not per-round — history entries don't change).
|
||||||
@@ -702,30 +634,37 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str,
|
|||||||
if cached_tokens:
|
if cached_tokens:
|
||||||
usage["cache_read_input_tokens"] = cached_tokens
|
usage["cache_read_input_tokens"] = cached_tokens
|
||||||
|
|
||||||
events.emit("response_received", payload={"provider": "gemini", "model": _model, "usage": usage, "round": r_idx})
|
# 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})
|
||||||
|
|
||||||
reason = resp.candidates[0].finish_reason.name if resp.candidates and hasattr(resp.candidates[0], "finish_reason") else "STOP"
|
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})
|
_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: proactively trim history when input tokens exceed 40% of limit
|
# Guard: if Gemini reports input tokens approaching the limit, drop oldest history pairs
|
||||||
total_in = usage.get("input_tokens", 0)
|
total_in = usage.get("input_tokens", 0)
|
||||||
if total_in > _GEMINI_MAX_INPUT_TOKENS * 0.4 and _gemini_chat and _get_gemini_history_list(_gemini_chat):
|
if total_in > _GEMINI_MAX_INPUT_TOKENS and _gemini_chat and _get_gemini_history_list(_gemini_chat):
|
||||||
hist = _get_gemini_history_list(_gemini_chat)
|
hist = _get_gemini_history_list(_gemini_chat)
|
||||||
dropped = 0
|
dropped = 0
|
||||||
# Drop oldest pairs (user+model) but keep at least the last 2 entries
|
# 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.3:
|
while len(hist) > 4 and total_in > _GEMINI_MAX_INPUT_TOKENS * 0.7:
|
||||||
# Drop in pairs (user + model) to maintain alternating roles required by Gemini
|
# Drop in pairs (user + model) to maintain alternating roles required by Gemini
|
||||||
saved = 0
|
saved = 0
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
if not hist: break
|
if not hist: break
|
||||||
for p in hist[0].parts:
|
for p in hist[0].parts:
|
||||||
if hasattr(p, "text") and p.text:
|
if hasattr(p, "text") and p.text:
|
||||||
saved += int(len(p.text) / _CHARS_PER_TOKEN)
|
saved += len(p.text) // 4
|
||||||
elif hasattr(p, "function_response") and p.function_response:
|
elif hasattr(p, "function_response") and p.function_response:
|
||||||
r = getattr(p.function_response, "response", {})
|
r = getattr(p.function_response, "response", {})
|
||||||
if isinstance(r, dict):
|
if isinstance(r, dict):
|
||||||
saved += int(len(str(r.get("output", ""))) / _CHARS_PER_TOKEN)
|
saved += len(str(r.get("output", ""))) // 4
|
||||||
hist.pop(0)
|
hist.pop(0)
|
||||||
dropped += 1
|
dropped += 1
|
||||||
total_in -= max(saved, 200)
|
total_in -= max(saved, 200)
|
||||||
@@ -750,23 +689,15 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str,
|
|||||||
if i == len(calls) - 1:
|
if i == len(calls) - 1:
|
||||||
if file_items:
|
if file_items:
|
||||||
file_items, changed = _reread_file_items(file_items)
|
file_items, changed = _reread_file_items(file_items)
|
||||||
ctx = _build_file_diff_text(changed)
|
ctx = _build_file_context_text(changed)
|
||||||
if ctx:
|
if ctx:
|
||||||
out += f"\n\n[SYSTEM: FILES UPDATED]\n\n{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.]"
|
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}))
|
f_resps.append(types.Part.from_function_response(name=name, response={"output": out}))
|
||||||
log.append({"tool_use_id": name, "content": out})
|
log.append({"tool_use_id": name, "content": out})
|
||||||
events.emit("tool_execution", payload={"status": "completed", "tool": name, "result": out, "round": r_idx})
|
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})
|
_append_comms("OUT", "tool_result_send", {"results": log})
|
||||||
payload = f_resps
|
payload = f_resps
|
||||||
|
|
||||||
@@ -1024,7 +955,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, discussion_history: str = "") -> str:
|
def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
||||||
try:
|
try:
|
||||||
_ensure_anthropic_client()
|
_ensure_anthropic_client()
|
||||||
mcp_client.configure(file_items or [], [base_dir])
|
mcp_client.configure(file_items or [], [base_dir])
|
||||||
@@ -1038,10 +969,6 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
|||||||
context_blocks = _build_chunked_context_blocks(context_text)
|
context_blocks = _build_chunked_context_blocks(context_text)
|
||||||
system_blocks = stable_blocks + context_blocks
|
system_blocks = stable_blocks + context_blocks
|
||||||
|
|
||||||
# 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}]
|
user_content = [{"type": "text", "text": user_message}]
|
||||||
|
|
||||||
# COMPRESS HISTORY: Truncate massive tool outputs from previous turns
|
# COMPRESS HISTORY: Truncate massive tool outputs from previous turns
|
||||||
@@ -1073,7 +1000,6 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
|||||||
})
|
})
|
||||||
|
|
||||||
all_text_parts = []
|
all_text_parts = []
|
||||||
_cumulative_tool_bytes = 0
|
|
||||||
|
|
||||||
# We allow MAX_TOOL_ROUNDS, plus 1 final loop to get the text synthesis
|
# We allow MAX_TOOL_ROUNDS, plus 1 final loop to get the text synthesis
|
||||||
for round_idx in range(MAX_TOOL_ROUNDS + 2):
|
for round_idx in range(MAX_TOOL_ROUNDS + 2):
|
||||||
@@ -1160,12 +1086,10 @@ 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})
|
_append_comms("OUT", "tool_call", {"name": b_name, "id": b_id, "args": b_input})
|
||||||
output = mcp_client.dispatch(b_name, b_input)
|
output = mcp_client.dispatch(b_name, b_input)
|
||||||
_append_comms("IN", "tool_result", {"name": b_name, "id": b_id, "output": output})
|
_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({
|
tool_results.append({
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": b_id,
|
"tool_use_id": b_id,
|
||||||
"content": truncated,
|
"content": output,
|
||||||
})
|
})
|
||||||
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
||||||
elif b_name == TOOL_NAME:
|
elif b_name == TOOL_NAME:
|
||||||
@@ -1181,26 +1105,17 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
|||||||
"id": b_id,
|
"id": b_id,
|
||||||
"output": output,
|
"output": output,
|
||||||
})
|
})
|
||||||
truncated = _truncate_tool_output(output)
|
|
||||||
_cumulative_tool_bytes += len(truncated)
|
|
||||||
tool_results.append({
|
tool_results.append({
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": b_id,
|
"tool_use_id": b_id,
|
||||||
"content": truncated,
|
"content": output,
|
||||||
})
|
})
|
||||||
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
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
|
# Refresh file context after tool calls — only inject CHANGED files
|
||||||
if file_items:
|
if file_items:
|
||||||
file_items, changed = _reread_file_items(file_items)
|
file_items, changed = _reread_file_items(file_items)
|
||||||
refreshed_ctx = _build_file_diff_text(changed)
|
refreshed_ctx = _build_file_context_text(changed)
|
||||||
if refreshed_ctx:
|
if refreshed_ctx:
|
||||||
tool_results.append({
|
tool_results.append({
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -1245,25 +1160,20 @@ def send(
|
|||||||
user_message: str,
|
user_message: str,
|
||||||
base_dir: str = ".",
|
base_dir: str = ".",
|
||||||
file_items: list[dict] | None = None,
|
file_items: list[dict] | None = None,
|
||||||
discussion_history: str = "",
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Send a message to the active provider.
|
Send a message to the active provider.
|
||||||
|
|
||||||
md_content : aggregated markdown string (for Gemini: stable content only,
|
md_content : aggregated markdown string from aggregate.run()
|
||||||
for Anthropic: full content including history)
|
user_message: the user question / instruction
|
||||||
user_message : the user question / instruction
|
|
||||||
base_dir : project base directory (for PowerShell tool calls)
|
base_dir : project base directory (for PowerShell tool calls)
|
||||||
file_items : list of file dicts from aggregate.build_file_items() for
|
file_items : list of file dicts from aggregate.build_file_items() for
|
||||||
dynamic context refresh after tool calls
|
dynamic context refresh after tool calls
|
||||||
discussion_history : discussion history text (used by Gemini to inject as
|
|
||||||
conversation message instead of caching it)
|
|
||||||
"""
|
"""
|
||||||
with _send_lock:
|
|
||||||
if _provider == "gemini":
|
if _provider == "gemini":
|
||||||
return _send_gemini(md_content, user_message, base_dir, file_items, discussion_history)
|
return _send_gemini(md_content, user_message, base_dir, file_items)
|
||||||
elif _provider == "anthropic":
|
elif _provider == "anthropic":
|
||||||
return _send_anthropic(md_content, user_message, base_dir, file_items, discussion_history)
|
return _send_anthropic(md_content, user_message, base_dir, file_items)
|
||||||
raise ValueError(f"unknown provider: {_provider}")
|
raise ValueError(f"unknown provider: {_provider}")
|
||||||
|
|
||||||
def get_history_bleed_stats() -> dict:
|
def get_history_bleed_stats() -> dict:
|
||||||
@@ -1272,9 +1182,7 @@ def get_history_bleed_stats() -> dict:
|
|||||||
"""
|
"""
|
||||||
if _provider == "anthropic":
|
if _provider == "anthropic":
|
||||||
# For Anthropic, we have a robust estimator
|
# For Anthropic, we have a robust estimator
|
||||||
with _anthropic_history_lock:
|
current_tokens = _estimate_prompt_tokens([], _anthropic_history)
|
||||||
history_snapshot = list(_anthropic_history)
|
|
||||||
current_tokens = _estimate_prompt_tokens([], history_snapshot)
|
|
||||||
limit_tokens = _ANTHROPIC_MAX_PROMPT_TOKENS
|
limit_tokens = _ANTHROPIC_MAX_PROMPT_TOKENS
|
||||||
percentage = (current_tokens / limit_tokens) * 100 if limit_tokens > 0 else 0
|
percentage = (current_tokens / limit_tokens) * 100 if limit_tokens > 0 else 0
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
class ApiHookClient:
|
class ApiHookClient:
|
||||||
def __init__(self, base_url="http://127.0.0.1:8999", max_retries=3, retry_delay=1):
|
def __init__(self, base_url="http://127.0.0.1:8999", max_retries=5, retry_delay=2):
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.max_retries = max_retries
|
self.max_retries = max_retries
|
||||||
self.retry_delay = retry_delay
|
self.retry_delay = retry_delay
|
||||||
@@ -29,9 +29,9 @@ class ApiHookClient:
|
|||||||
for attempt in range(self.max_retries + 1):
|
for attempt in range(self.max_retries + 1):
|
||||||
try:
|
try:
|
||||||
if method == 'GET':
|
if method == 'GET':
|
||||||
response = requests.get(url, timeout=2)
|
response = requests.get(url, timeout=5)
|
||||||
elif method == 'POST':
|
elif method == 'POST':
|
||||||
response = requests.post(url, json=data, headers=headers, timeout=2)
|
response = requests.post(url, json=data, headers=headers, timeout=5)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||||
|
|
||||||
@@ -83,3 +83,53 @@ class ApiHookClient:
|
|||||||
|
|
||||||
def post_gui(self, gui_data):
|
def post_gui(self, gui_data):
|
||||||
return self._make_request('POST', '/api/gui', data=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,11 +21,12 @@ class HookHandler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
|
self.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
|
||||||
elif self.path == '/api/project':
|
elif self.path == '/api/project':
|
||||||
|
import project_manager
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header('Content-Type', 'application/json')
|
self.send_header('Content-Type', 'application/json')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(
|
flat = project_manager.flat_config(app.project)
|
||||||
json.dumps({'project': app.project}).encode('utf-8'))
|
self.wfile.write(json.dumps({'project': flat}).encode('utf-8'))
|
||||||
elif self.path == '/api/session':
|
elif self.path == '/api/session':
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header('Content-Type', 'application/json')
|
self.send_header('Content-Type', 'application/json')
|
||||||
@@ -41,6 +42,35 @@ class HookHandler(BaseHTTPRequestHandler):
|
|||||||
if hasattr(app, 'perf_monitor'):
|
if hasattr(app, 'perf_monitor'):
|
||||||
metrics = app.perf_monitor.get_metrics()
|
metrics = app.perf_monitor.get_metrics()
|
||||||
self.wfile.write(json.dumps({'performance': metrics}).encode('utf-8'))
|
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:
|
else:
|
||||||
self.send_response(404)
|
self.send_response(404)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
@@ -70,11 +100,6 @@ class HookHandler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(
|
self.wfile.write(
|
||||||
json.dumps({'status': 'updated'}).encode('utf-8'))
|
json.dumps({'status': 'updated'}).encode('utf-8'))
|
||||||
elif self.path == '/api/gui':
|
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:
|
with app._pending_gui_tasks_lock:
|
||||||
app._pending_gui_tasks.append(data)
|
app._pending_gui_tasks.append(data)
|
||||||
|
|
||||||
@@ -105,6 +130,13 @@ class HookServer:
|
|||||||
def start(self):
|
def start(self):
|
||||||
if not getattr(self.app, 'test_hooks_enabled', False):
|
if not getattr(self.app, 'test_hooks_enabled', False):
|
||||||
return
|
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.server = HookServerInstance(('127.0.0.1', self.port), HookHandler, self.app)
|
||||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ To serve as an expert-level utility for personal developer use on small projects
|
|||||||
- **Integrated Workspace:** A consolidated Hub-based layout (Context, AI Settings, Discussion, Operations) designed for expert multi-monitor workflows.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
- **tomli-w:** For writing TOML configuration files.
|
- **tomli-w:** For writing TOML configuration files.
|
||||||
- **psutil:** For system and process monitoring (CPU/Memory telemetry).
|
- **psutil:** For system and process monitoring (CPU/Memory telemetry).
|
||||||
- **uv:** An extremely fast Python package and project manager.
|
- **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
|
## Architectural Patterns
|
||||||
- **Event-Driven Metrics:** Uses a custom `EventEmitter` to decouple API lifecycle events from UI rendering, improving performance and responsiveness.
|
- **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
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] **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.**
|
- [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.**
|
||||||
*Link: [./tracks/live_ux_test_20260223/](./tracks/live_ux_test_20260223/)*
|
*Link: [./tracks/live_ux_test_20260223/](./tracks/live_ux_test_20260223/)*
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,37 @@
|
|||||||
# Implementation Plan: Human-Like UX Interaction Test
|
# Implementation Plan: Human-Like UX Interaction Test
|
||||||
|
|
||||||
## Phase 1: Infrastructure & Automation Core
|
## Phase 1: Infrastructure & Automation Core [checkpoint: 7626531]
|
||||||
Establish the foundation for driving the GUI via API hooks and simulation logic.
|
Establish the foundation for driving the GUI via API hooks and simulation logic.
|
||||||
|
|
||||||
- [ ] Task: Extend `ApiHookClient` with methods for tab switching and listbox selection if missing.
|
- [x] Task: Extend `ApiHookClient` with methods for tab switching and listbox selection if missing. f36d539
|
||||||
- [ ] Task: Implement `TestUserAgent` class to manage dynamic response generation and action delays.
|
- [x] Task: Implement `TestUserAgent` class to manage dynamic response generation and action delays. d326242
|
||||||
- [ ] Task: Write Tests (Verify basic hook connectivity and simulated delays)
|
- [x] Task: Write Tests (Verify basic hook connectivity and simulated delays) f36d539
|
||||||
- [ ] Task: Implement basic 'ping-pong' interaction via hooks.
|
- [x] Task: Implement basic 'ping-pong' interaction via hooks. bfe9ef0
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Infrastructure & Automation Core' (Protocol in workflow.md)
|
- [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
|
||||||
|
|
||||||
## Phase 2: Workflow Simulation
|
## Phase 2: Workflow Simulation [checkpoint: 9c4a72c]
|
||||||
Build the core interaction loop for project creation and AI discussion.
|
Build the core interaction loop for project creation and AI discussion.
|
||||||
|
|
||||||
- [ ] Task: Implement 'New Project' scaffolding script (creating a tiny console program).
|
- [x] Task: Implement 'New Project' scaffolding script (creating a tiny console program). bd5dc16
|
||||||
- [ ] Task: Implement 5-turn discussion loop logic with sub-agent responses.
|
- [x] Task: Implement 5-turn discussion loop logic with sub-agent responses. bd5dc16
|
||||||
- [ ] Task: Write Tests (Verify state changes in Discussion Hub during simulated chat)
|
- [x] Task: Write Tests (Verify state changes in Discussion Hub during simulated chat) 6d16438
|
||||||
- [ ] Task: Implement 'Thinking' and 'Live' indicator verification logic.
|
- [x] Task: Implement 'Thinking' and 'Live' indicator verification logic. 6d16438
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Phase 2: Workflow Simulation' (Protocol in workflow.md)
|
- [x] Task: Conductor - User Manual Verification 'Phase 2: Workflow Simulation' (Protocol in workflow.md) 9c4a72c
|
||||||
|
|
||||||
## Phase 3: History & Session Verification
|
## Phase 3: History & Session Verification [checkpoint: 0f04e06]
|
||||||
Simulate complex session management and historical audit features.
|
Simulate complex session management and historical audit features.
|
||||||
|
|
||||||
- [ ] Task: Implement discussion switching logic (creating/switching between named discussions).
|
- [x] Task: Implement discussion switching logic (creating/switching between named discussions). 5e1b965
|
||||||
- [ ] Task: Implement 'Load Prior Log' simulation and 'Tinted Mode' detection.
|
- [x] Task: Implement 'Load Prior Log' simulation and 'Tinted Mode' detection. 5e1b965
|
||||||
- [ ] Task: Write Tests (Verify log loading and tab navigation consistency)
|
- [x] Task: Write Tests (Verify log loading and tab navigation consistency) 5e1b965
|
||||||
- [ ] Task: Implement truncation limit verification (forcing a long history and checking bleed).
|
- [x] Task: Implement truncation limit verification (forcing a long history and checking bleed). 5e1b965
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Phase 3: History & Session Verification' (Protocol in workflow.md)
|
- [x] Task: Conductor - User Manual Verification 'Phase 3: History & Session Verification' (Protocol in workflow.md) 0f04e06
|
||||||
|
|
||||||
## Phase 4: Final Integration & Regression
|
## Phase 4: Final Integration & Regression [checkpoint: 8e63b31]
|
||||||
Consolidate the simulation into end-user artifacts and CI tests.
|
Consolidate the simulation into end-user artifacts and CI tests.
|
||||||
|
|
||||||
- [ ] Task: Create `live_walkthrough.py` with full visual feedback and manual sign-off.
|
- [x] Task: Create `live_walkthrough.py` with full visual feedback and manual sign-off. 8bd280e
|
||||||
- [ ] Task: Create `tests/test_live_workflow.py` for automated regression testing.
|
- [x] Task: Create `tests/test_live_workflow.py` for automated regression testing. 8bd280e
|
||||||
- [ ] Task: Perform a full visual walkthrough and verify 'human-readable' pace.
|
- [x] Task: Perform a full visual walkthrough and verify 'human-readable' pace. 8e63b31
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Phase 4: Final Integration & Regression' (Protocol in workflow.md)
|
- [x] Task: Conductor - User Manual Verification 'Phase 4: Final Integration & Regression' (Protocol in workflow.md) 8e63b31
|
||||||
|
|||||||
17
config.toml
17
config.toml
@@ -16,19 +16,6 @@ scale = 1.0
|
|||||||
paths = [
|
paths = [
|
||||||
"manual_slop.toml",
|
"manual_slop.toml",
|
||||||
"C:/projects/forth/bootslop/bootslop.toml",
|
"C:/projects/forth/bootslop/bootslop.toml",
|
||||||
|
"C:\\projects\\manual_slop\\tests\\temp_project.toml",
|
||||||
]
|
]
|
||||||
active = "manual_slop.toml"
|
active = "C:\\projects\\manual_slop\\tests\\temp_project.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:
|
if wrap:
|
||||||
with dpg.child_window(height=80, border=True):
|
with dpg.child_window(height=80, border=True):
|
||||||
# add_input_text for selection
|
# add_input_text for selection
|
||||||
dpg.add_input_text(default_value=value, multiline=True, readonly=True, width=-1, height=-1, border=False)
|
dpg.add_input_text(default_value=value, multiline=True, readonly=True, width=-1, height=-1)
|
||||||
else:
|
else:
|
||||||
dpg.add_input_text(
|
dpg.add_input_text(
|
||||||
default_value=value,
|
default_value=value,
|
||||||
@@ -140,14 +140,14 @@ def _add_text_field(parent: str, label: str, value: str):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Short selectable text
|
# Short selectable text
|
||||||
dpg.add_input_text(default_value=value if value else "(empty)", readonly=True, width=-1, border=False)
|
dpg.add_input_text(default_value=value if value else "(empty)", readonly=True, width=-1)
|
||||||
|
|
||||||
|
|
||||||
def _add_kv_row(parent: str, key: str, val, val_color=None):
|
def _add_kv_row(parent: str, key: str, val, val_color=None):
|
||||||
"""Single key: value row, horizontally laid out."""
|
"""Single key: value row, horizontally laid out."""
|
||||||
with dpg.group(horizontal=True, parent=parent):
|
with dpg.group(horizontal=True, parent=parent):
|
||||||
dpg.add_text(f"{key}:", color=_LABEL_COLOR)
|
dpg.add_text(f"{key}:", color=_LABEL_COLOR)
|
||||||
dpg.add_input_text(default_value=str(val), readonly=True, width=-1, border=False)
|
dpg.add_input_text(default_value=str(val), readonly=True, width=-1)
|
||||||
|
|
||||||
|
|
||||||
def _render_usage(parent: str, usage: dict):
|
def _render_usage(parent: str, usage: dict):
|
||||||
@@ -1168,9 +1168,9 @@ class App:
|
|||||||
hint="New discussion name",
|
hint="New discussion name",
|
||||||
width=-180,
|
width=-180,
|
||||||
)
|
)
|
||||||
dpg.add_button(label="Create", callback=self.cb_disc_create)
|
dpg.add_button(label="Create", tag="btn_disc_create", callback=self.cb_disc_create)
|
||||||
dpg.add_button(label="Rename", callback=self.cb_disc_rename)
|
dpg.add_button(label="Rename", tag="btn_disc_rename", callback=self.cb_disc_rename)
|
||||||
dpg.add_button(label="Delete", callback=self.cb_disc_delete)
|
dpg.add_button(label="Delete", tag="btn_disc_delete", callback=self.cb_disc_delete)
|
||||||
|
|
||||||
def _make_remove_file_cb(self, idx: int):
|
def _make_remove_file_cb(self, idx: int):
|
||||||
def cb():
|
def cb():
|
||||||
@@ -1506,6 +1506,28 @@ class App:
|
|||||||
self._rebuild_projects_list()
|
self._rebuild_projects_list()
|
||||||
self._update_status(f"created project: {name}")
|
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):
|
def cb_browse_git_dir(self):
|
||||||
root = hide_tk_root()
|
root = hide_tk_root()
|
||||||
d = filedialog.askdirectory(title="Select Git Directory")
|
d = filedialog.askdirectory(title="Select Git Directory")
|
||||||
@@ -1882,6 +1904,9 @@ class App:
|
|||||||
no_close=False,
|
no_close=False,
|
||||||
no_collapse=True,
|
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_bar():
|
||||||
with dpg.tab(label="Projects"):
|
with dpg.tab(label="Projects"):
|
||||||
proj_meta = self.project.get("project", {})
|
proj_meta = self.project.get("project", {})
|
||||||
@@ -1919,9 +1944,9 @@ class App:
|
|||||||
with dpg.child_window(tag="projects_scroll", height=120, border=True):
|
with dpg.child_window(tag="projects_scroll", height=120, border=True):
|
||||||
pass
|
pass
|
||||||
with dpg.group(horizontal=True):
|
with dpg.group(horizontal=True):
|
||||||
dpg.add_button(label="Add Project", callback=self.cb_add_project)
|
dpg.add_button(label="Add Project", tag="btn_project_add", callback=self.cb_add_project)
|
||||||
dpg.add_button(label="New Project", callback=self.cb_new_project)
|
dpg.add_button(label="New Project", tag="btn_project_new", callback=self.cb_new_project)
|
||||||
dpg.add_button(label="Save All", callback=self.cb_save_config)
|
dpg.add_button(label="Save All", tag="btn_project_save", callback=self.cb_save_config)
|
||||||
dpg.add_checkbox(
|
dpg.add_checkbox(
|
||||||
tag="project_word_wrap",
|
tag="project_word_wrap",
|
||||||
label="Word-Wrap (Read-only panels)",
|
label="Word-Wrap (Read-only panels)",
|
||||||
@@ -2068,7 +2093,7 @@ class App:
|
|||||||
dpg.add_button(label="+All", callback=self.cb_disc_expand_all)
|
dpg.add_button(label="+All", callback=self.cb_disc_expand_all)
|
||||||
dpg.add_text("Keep Pairs:", color=(160, 160, 160))
|
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_input_int(tag="disc_truncate_pairs", default_value=2, width=80, min_value=1)
|
||||||
dpg.add_button(label="Truncate", callback=self.cb_disc_truncate)
|
dpg.add_button(label="Truncate", tag="btn_disc_truncate", callback=self.cb_disc_truncate)
|
||||||
dpg.add_button(label="Clear All", callback=self.cb_disc_clear)
|
dpg.add_button(label="Clear All", callback=self.cb_disc_clear)
|
||||||
dpg.add_button(label="Save", callback=self.cb_disc_save)
|
dpg.add_button(label="Save", callback=self.cb_disc_save)
|
||||||
|
|
||||||
@@ -2100,10 +2125,10 @@ class App:
|
|||||||
height=200,
|
height=200,
|
||||||
)
|
)
|
||||||
with dpg.group(horizontal=True):
|
with dpg.group(horizontal=True):
|
||||||
dpg.add_button(label="Gen + Send", callback=self.cb_generate_send)
|
dpg.add_button(label="Gen + Send", tag="btn_gen_send", callback=self.cb_generate_send)
|
||||||
dpg.add_button(label="MD Only", callback=self.cb_md_only)
|
dpg.add_button(label="MD Only", tag="btn_md_only", callback=self.cb_md_only)
|
||||||
dpg.add_button(label="Reset", callback=self.cb_reset_session)
|
dpg.add_button(label="Reset", tag="btn_reset", callback=self.cb_reset_session)
|
||||||
dpg.add_button(label="-> History", callback=self.cb_append_message_to_history)
|
dpg.add_button(label="-> History", tag="btn_to_history", callback=self.cb_append_message_to_history)
|
||||||
|
|
||||||
with dpg.tab(label="AI Response"):
|
with dpg.tab(label="AI Response"):
|
||||||
dpg.add_input_text(
|
dpg.add_input_text(
|
||||||
@@ -2133,13 +2158,13 @@ class App:
|
|||||||
dpg.add_spacer(width=20)
|
dpg.add_spacer(width=20)
|
||||||
dpg.add_text("LIVE", tag="operations_live_indicator", color=(100, 255, 100), show=False)
|
dpg.add_text("LIVE", tag="operations_live_indicator", color=(100, 255, 100), show=False)
|
||||||
|
|
||||||
with dpg.tab_bar():
|
with dpg.tab_bar(tag="operations_tabs"):
|
||||||
with dpg.tab(label="Comms Log"):
|
with dpg.tab(label="Comms Log", tag="tab_comms"):
|
||||||
with dpg.group(horizontal=True):
|
with dpg.group(horizontal=True):
|
||||||
dpg.add_text("Status: idle", tag="ai_status", color=(200, 220, 160))
|
dpg.add_text("Status: idle", tag="ai_status", color=(200, 220, 160))
|
||||||
dpg.add_spacer(width=16)
|
dpg.add_spacer(width=16)
|
||||||
dpg.add_button(label="Clear", callback=self.cb_clear_comms)
|
dpg.add_button(label="Clear", callback=self.cb_clear_comms)
|
||||||
dpg.add_button(label="Load Log", callback=self.cb_load_prior_log)
|
dpg.add_button(label="Load Log", tag="btn_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_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)
|
dpg.add_text("PRIOR SESSION VIEW", tag="prior_session_indicator", color=(255, 100, 100), show=False)
|
||||||
@@ -2148,7 +2173,7 @@ class App:
|
|||||||
with dpg.child_window(tag="comms_scroll", height=-1, border=False, horizontal_scrollbar=True):
|
with dpg.child_window(tag="comms_scroll", height=-1, border=False, horizontal_scrollbar=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
with dpg.tab(label="Tool Log"):
|
with dpg.tab(label="Tool Log", tag="tab_tool"):
|
||||||
with dpg.group(horizontal=True):
|
with dpg.group(horizontal=True):
|
||||||
dpg.add_text("Tool call history")
|
dpg.add_text("Tool call history")
|
||||||
dpg.add_button(label="Clear", callback=self.cb_clear_tool_log)
|
dpg.add_button(label="Clear", callback=self.cb_clear_tool_log)
|
||||||
@@ -2301,10 +2326,46 @@ class App:
|
|||||||
dpg.set_value(item, val)
|
dpg.set_value(item, val)
|
||||||
elif action == "click":
|
elif action == "click":
|
||||||
item = task.get("item")
|
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):
|
if item and dpg.does_item_exist(item):
|
||||||
cb = dpg.get_item_callback(item)
|
cb = dpg.get_item_callback(item)
|
||||||
if cb:
|
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()
|
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":
|
elif action == "refresh_api_metrics":
|
||||||
self._refresh_api_metrics(task.get("payload", {}))
|
self._refresh_api_metrics(task.get("payload", {}))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
352
gui_2.py
352
gui_2.py
@@ -4,8 +4,6 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import math
|
import math
|
||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tkinter import filedialog, Tk
|
from tkinter import filedialog, Tk
|
||||||
import aggregate
|
import aggregate
|
||||||
@@ -16,9 +14,6 @@ import session_logger
|
|||||||
import project_manager
|
import project_manager
|
||||||
import theme_2 as theme
|
import theme_2 as theme
|
||||||
import tomllib
|
import tomllib
|
||||||
import numpy as np
|
|
||||||
import api_hooks
|
|
||||||
from performance_monitor import PerformanceMonitor
|
|
||||||
|
|
||||||
from imgui_bundle import imgui, hello_imgui, immapp
|
from imgui_bundle import imgui, hello_imgui, immapp
|
||||||
|
|
||||||
@@ -61,15 +56,6 @@ KIND_COLORS = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_res
|
|||||||
HEAVY_KEYS = {"message", "text", "script", "output", "content"}
|
HEAVY_KEYS = {"message", "text", "script", "output", "content"}
|
||||||
|
|
||||||
DISC_ROLES = ["User", "AI", "Vendor API", "System"]
|
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]:
|
def _parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict]:
|
||||||
known = roles if roles is not None else DISC_ROLES
|
known = roles if roles is not None else DISC_ROLES
|
||||||
@@ -133,7 +119,6 @@ class App:
|
|||||||
self.ui_project_main_context = proj_meta.get("main_context", "")
|
self.ui_project_main_context = proj_meta.get("main_context", "")
|
||||||
self.ui_project_system_prompt = proj_meta.get("system_prompt", "")
|
self.ui_project_system_prompt = proj_meta.get("system_prompt", "")
|
||||||
self.ui_word_wrap = proj_meta.get("word_wrap", True)
|
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_auto_add_history = disc_sec.get("auto_add", False)
|
||||||
|
|
||||||
self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "")
|
self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "")
|
||||||
@@ -152,10 +137,9 @@ class App:
|
|||||||
self.last_file_items: list = []
|
self.last_file_items: list = []
|
||||||
|
|
||||||
self.send_thread: threading.Thread | None = None
|
self.send_thread: threading.Thread | None = None
|
||||||
self._send_thread_lock = threading.Lock()
|
|
||||||
self.models_thread: threading.Thread | None = None
|
self.models_thread: threading.Thread | None = None
|
||||||
|
|
||||||
_default_windows = {
|
self.show_windows = {
|
||||||
"Projects": True,
|
"Projects": True,
|
||||||
"Files": True,
|
"Files": True,
|
||||||
"Screenshots": True,
|
"Screenshots": True,
|
||||||
@@ -167,10 +151,7 @@ class App:
|
|||||||
"Comms History": True,
|
"Comms History": True,
|
||||||
"System Prompts": True,
|
"System Prompts": True,
|
||||||
"Theme": 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_script_output = False
|
||||||
self.show_text_viewer = False
|
self.show_text_viewer = False
|
||||||
self.text_viewer_title = ""
|
self.text_viewer_title = ""
|
||||||
@@ -200,53 +181,12 @@ class App:
|
|||||||
|
|
||||||
self._scroll_disc_to_bottom = False
|
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()
|
session_logger.open_session()
|
||||||
ai_client.set_provider(self.current_provider, self.current_model)
|
ai_client.set_provider(self.current_provider, self.current_model)
|
||||||
ai_client.confirm_and_run_callback = self._confirm_and_run
|
ai_client.confirm_and_run_callback = self._confirm_and_run
|
||||||
ai_client.comms_log_callback = self._on_comms_entry
|
ai_client.comms_log_callback = self._on_comms_entry
|
||||||
ai_client.tool_log_callback = self._on_tool_log
|
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
|
# ---------------------------------------------------------------- project loading
|
||||||
|
|
||||||
def _load_active_project(self):
|
def _load_active_project(self):
|
||||||
@@ -313,10 +253,6 @@ class App:
|
|||||||
self.ui_project_main_context = proj.get("project", {}).get("main_context", "")
|
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_auto_add_history = proj.get("discussion", {}).get("auto_add", False)
|
||||||
self.ui_word_wrap = proj.get("project", {}).get("word_wrap", True)
|
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):
|
def _save_active_project(self):
|
||||||
if self.active_project_path:
|
if self.active_project_path:
|
||||||
@@ -401,76 +337,6 @@ class App:
|
|||||||
def _on_tool_log(self, script: str, result: str):
|
def _on_tool_log(self, script: str, result: str):
|
||||||
session_logger.log_tool_call(script, result, None)
|
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:
|
def _confirm_and_run(self, script: str, base_dir: str) -> str | None:
|
||||||
dialog = ConfirmDialog(script, base_dir)
|
dialog = ConfirmDialog(script, base_dir)
|
||||||
with self._pending_dialog_lock:
|
with self._pending_dialog_lock:
|
||||||
@@ -507,11 +373,6 @@ class App:
|
|||||||
proj["project"]["system_prompt"] = self.ui_project_system_prompt
|
proj["project"]["system_prompt"] = self.ui_project_system_prompt
|
||||||
proj["project"]["main_context"] = self.ui_project_main_context
|
proj["project"]["main_context"] = self.ui_project_main_context
|
||||||
proj["project"]["word_wrap"] = self.ui_word_wrap
|
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()
|
self._flush_disc_entries_to_project()
|
||||||
disc_sec = proj.setdefault("discussion", {})
|
disc_sec = proj.setdefault("discussion", {})
|
||||||
@@ -529,26 +390,15 @@ class App:
|
|||||||
}
|
}
|
||||||
self.config["ai"]["system_prompt"] = self.ui_global_system_prompt
|
self.config["ai"]["system_prompt"] = self.ui_global_system_prompt
|
||||||
self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path}
|
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)
|
theme.save_to_config(self.config)
|
||||||
|
|
||||||
def _do_generate(self) -> tuple[str, Path, list, str, str]:
|
def _do_generate(self) -> tuple[str, Path, list]:
|
||||||
"""Returns (full_md, output_path, file_items, stable_md, discussion_text)."""
|
|
||||||
self._flush_to_project()
|
self._flush_to_project()
|
||||||
self._save_active_project()
|
self._save_active_project()
|
||||||
self._flush_to_config()
|
self._flush_to_config()
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
flat = project_manager.flat_config(self.project, self.active_discussion)
|
flat = project_manager.flat_config(self.project, self.active_discussion)
|
||||||
full_md, path, file_items = aggregate.run(flat)
|
return 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):
|
def _fetch_models(self, provider: str):
|
||||||
self.ai_status = "fetching models..."
|
self.ai_status = "fetching models..."
|
||||||
@@ -595,23 +445,6 @@ class App:
|
|||||||
# ---------------------------------------------------------------- gui
|
# ---------------------------------------------------------------- gui
|
||||||
|
|
||||||
def _gui_func(self):
|
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
|
# Sync pending comms
|
||||||
with self._pending_comms_lock:
|
with self._pending_comms_lock:
|
||||||
for c in self._pending_comms:
|
for c in self._pending_comms:
|
||||||
@@ -743,14 +576,6 @@ class App:
|
|||||||
self.ai_status = "config saved"
|
self.ai_status = "config saved"
|
||||||
|
|
||||||
ch, self.ui_word_wrap = imgui.checkbox("Word-Wrap (Read-only panels)", self.ui_word_wrap)
|
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()
|
imgui.end()
|
||||||
|
|
||||||
# ---- Files
|
# ---- Files
|
||||||
@@ -830,50 +655,7 @@ class App:
|
|||||||
if self.show_windows["Discussion History"]:
|
if self.show_windows["Discussion History"]:
|
||||||
exp, self.show_windows["Discussion History"] = imgui.begin("Discussion History", self.show_windows["Discussion History"])
|
exp, self.show_windows["Discussion History"] = imgui.begin("Discussion History", self.show_windows["Discussion History"])
|
||||||
if exp:
|
if exp:
|
||||||
# THINKING indicator
|
if imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open):
|
||||||
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()
|
names = self._get_discussion_names()
|
||||||
|
|
||||||
if imgui.begin_combo("##disc_sel", self.active_discussion):
|
if imgui.begin_combo("##disc_sel", self.active_discussion):
|
||||||
@@ -920,7 +702,6 @@ class App:
|
|||||||
if imgui.button("Delete"):
|
if imgui.button("Delete"):
|
||||||
self._delete_discussion(self.active_discussion)
|
self._delete_discussion(self.active_discussion)
|
||||||
|
|
||||||
if not self.is_viewing_prior_session:
|
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
if imgui.button("+ Entry"):
|
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()})
|
self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||||
@@ -940,22 +721,8 @@ class App:
|
|||||||
self._flush_to_config()
|
self._flush_to_config()
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
self.ai_status = "discussion saved"
|
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)
|
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()
|
imgui.separator()
|
||||||
|
|
||||||
if imgui.collapsing_header("Roles"):
|
if imgui.collapsing_header("Roles"):
|
||||||
@@ -1069,48 +836,16 @@ class App:
|
|||||||
ch, self.temperature = imgui.slider_float("Temperature", self.temperature, 0.0, 2.0, "%.2f")
|
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.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)
|
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()
|
imgui.end()
|
||||||
|
|
||||||
# ---- Message
|
# ---- Message
|
||||||
if self.show_windows["Message"]:
|
if self.show_windows["Message"]:
|
||||||
exp, self.show_windows["Message"] = imgui.begin("Message", self.show_windows["Message"])
|
exp, self.show_windows["Message"] = imgui.begin("Message", self.show_windows["Message"])
|
||||||
if exp:
|
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))
|
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()
|
imgui.separator()
|
||||||
send_busy = False
|
if imgui.button("Gen + Send"):
|
||||||
with self._send_thread_lock:
|
if not (self.send_thread and self.send_thread.is_alive()):
|
||||||
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:
|
try:
|
||||||
md, path, file_items, stable_md, disc_text = self._do_generate()
|
md, path, file_items, stable_md, disc_text = self._do_generate()
|
||||||
self.last_md = md
|
self.last_md = md
|
||||||
@@ -1124,17 +859,14 @@ class App:
|
|||||||
base_dir = self.ui_files_base_dir
|
base_dir = self.ui_files_base_dir
|
||||||
csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()])
|
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_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():
|
def do_send():
|
||||||
if self.ui_auto_add_history:
|
if self.ui_auto_add_history:
|
||||||
with self._pending_history_adds_lock:
|
with self._pending_history_adds_lock:
|
||||||
self._pending_history_adds.append({"role": "User", "content": user_msg, "collapsed": False, "ts": project_manager.now_ts()})
|
self._pending_history_adds.append({"role": "User", "content": user_msg, "collapsed": False, "ts": project_manager.now_ts()})
|
||||||
try:
|
try:
|
||||||
resp = ai_client.send(send_md, user_msg, base_dir, self.last_file_items, send_disc)
|
resp = ai_client.send(self.last_md, user_msg, base_dir, self.last_file_items)
|
||||||
self.ai_response = resp
|
self.ai_response = resp
|
||||||
self.ai_status = "done"
|
self.ai_status = "done"
|
||||||
self._trigger_blink = True
|
self._trigger_blink = True
|
||||||
@@ -1156,7 +888,6 @@ class App:
|
|||||||
with self._pending_history_adds_lock:
|
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._pending_history_adds.append({"role": "System", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()})
|
||||||
|
|
||||||
with self._send_thread_lock:
|
|
||||||
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
||||||
self.send_thread.start()
|
self.send_thread.start()
|
||||||
imgui.same_line()
|
imgui.same_line()
|
||||||
@@ -1437,67 +1168,6 @@ class App:
|
|||||||
if ch: theme.set_scale(scale)
|
if ch: theme.set_scale(scale)
|
||||||
imgui.end()
|
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
|
# ---- Modals / Popups
|
||||||
with self._pending_dialog_lock:
|
with self._pending_dialog_lock:
|
||||||
dlg = self._pending_dialog
|
dlg = self._pending_dialog
|
||||||
@@ -1624,15 +1294,9 @@ class App:
|
|||||||
|
|
||||||
self._fetch_models(self.current_provider)
|
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)
|
immapp.run(self.runner_params)
|
||||||
|
|
||||||
# On exit
|
# On exit
|
||||||
self.hook_server.stop()
|
|
||||||
self.perf_monitor.stop()
|
|
||||||
ai_client.cleanup() # Destroy active API caches to stop billing
|
ai_client.cleanup() # Destroy active API caches to stop billing
|
||||||
self._flush_to_project()
|
self._flush_to_project()
|
||||||
self._save_active_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]
|
[Window][Discussion History]
|
||||||
Pos=598,128
|
Pos=598,128
|
||||||
Size=554,619
|
Size=712,619
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x0000000E,0
|
DockId=0x0000000E,0
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ DockId=0x0000000A,0
|
|||||||
|
|
||||||
[Window][Message]
|
[Window][Message]
|
||||||
Pos=598,749
|
Pos=598,749
|
||||||
Size=554,451
|
Size=712,451
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x0000000C,0
|
DockId=0x0000000C,0
|
||||||
|
|
||||||
@@ -50,22 +50,22 @@ Collapsed=0
|
|||||||
DockId=0x00000010,0
|
DockId=0x00000010,0
|
||||||
|
|
||||||
[Window][Tool Calls]
|
[Window][Tool Calls]
|
||||||
Pos=1154,733
|
Pos=1312,733
|
||||||
Size=526,144
|
Size=368,144
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000008,0
|
DockId=0x00000008,0
|
||||||
|
|
||||||
[Window][Comms History]
|
[Window][Comms History]
|
||||||
Pos=1154,879
|
Pos=1312,879
|
||||||
Size=526,321
|
Size=368,321
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000006,0
|
DockId=0x00000006,0
|
||||||
|
|
||||||
[Window][System Prompts]
|
[Window][System Prompts]
|
||||||
Pos=1154,0
|
Pos=1312,0
|
||||||
Size=286,731
|
Size=368,731
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000017,0
|
DockId=0x00000007,0
|
||||||
|
|
||||||
[Window][Theme]
|
[Window][Theme]
|
||||||
Pos=209,173
|
Pos=209,173
|
||||||
@@ -78,17 +78,11 @@ Pos=379,324
|
|||||||
Size=900,700
|
Size=900,700
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Diagnostics]
|
|
||||||
Pos=1442,0
|
|
||||||
Size=238,731
|
|
||||||
Collapsed=0
|
|
||||||
DockId=0x00000018,0
|
|
||||||
|
|
||||||
[Docking][Data]
|
[Docking][Data]
|
||||||
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=346,232 Size=1680,1200 Split=X
|
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=138,161 Size=1680,1200 Split=X
|
||||||
DockNode ID=0x00000011 Parent=0xAFC85805 SizeRef=207,1200 Selected=0x0469CA7A
|
DockNode ID=0x00000011 Parent=0xAFC85805 SizeRef=207,1200 Selected=0x0469CA7A
|
||||||
DockNode ID=0x00000012 Parent=0xAFC85805 SizeRef=1559,1200 Split=X
|
DockNode ID=0x00000012 Parent=0xAFC85805 SizeRef=1559,1200 Split=X
|
||||||
DockNode ID=0x00000003 Parent=0x00000012 SizeRef=943,1200 Split=X
|
DockNode ID=0x00000003 Parent=0x00000012 SizeRef=1189,1200 Split=X
|
||||||
DockNode ID=0x00000001 Parent=0x00000003 SizeRef=387,1200 Split=Y Selected=0x8CA2375C
|
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=0x00000009 Parent=0x00000001 SizeRef=405,911 Split=Y Selected=0x8CA2375C
|
||||||
DockNode ID=0x0000000F Parent=0x00000009 SizeRef=405,733 Split=Y Selected=0x8CA2375C
|
DockNode ID=0x0000000F Parent=0x00000009 SizeRef=405,733 Split=Y Selected=0x8CA2375C
|
||||||
@@ -98,16 +92,14 @@ DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=346,232 Size=1680,12
|
|||||||
DockNode ID=0x00000014 Parent=0x0000000F SizeRef=405,337 Selected=0xDA22FEDA
|
DockNode ID=0x00000014 Parent=0x0000000F SizeRef=405,337 Selected=0xDA22FEDA
|
||||||
DockNode ID=0x00000010 Parent=0x00000009 SizeRef=405,176 Selected=0x0D5A5273
|
DockNode ID=0x00000010 Parent=0x00000009 SizeRef=405,176 Selected=0x0D5A5273
|
||||||
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=405,287 Selected=0xA07B5F14
|
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=405,287 Selected=0xA07B5F14
|
||||||
DockNode ID=0x00000002 Parent=0x00000003 SizeRef=554,1200 Split=Y
|
DockNode ID=0x00000002 Parent=0x00000003 SizeRef=800,1200 Split=Y
|
||||||
DockNode ID=0x0000000B Parent=0x00000002 SizeRef=1010,747 Split=Y
|
DockNode ID=0x0000000B Parent=0x00000002 SizeRef=1010,747 Split=Y
|
||||||
DockNode ID=0x0000000D Parent=0x0000000B SizeRef=1010,126 CentralNode=1
|
DockNode ID=0x0000000D Parent=0x0000000B SizeRef=1010,126 CentralNode=1
|
||||||
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1010,619 Selected=0x5D11106F
|
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1010,619 Selected=0x5D11106F
|
||||||
DockNode ID=0x0000000C Parent=0x00000002 SizeRef=1010,451 Selected=0x66CFB56E
|
DockNode ID=0x0000000C Parent=0x00000002 SizeRef=1010,451 Selected=0x66CFB56E
|
||||||
DockNode ID=0x00000004 Parent=0x00000012 SizeRef=526,1200 Split=Y Selected=0xDD6419BC
|
DockNode ID=0x00000004 Parent=0x00000012 SizeRef=368,1200 Split=Y Selected=0xDD6419BC
|
||||||
DockNode ID=0x00000005 Parent=0x00000004 SizeRef=261,877 Split=Y Selected=0xDD6419BC
|
DockNode ID=0x00000005 Parent=0x00000004 SizeRef=261,877 Split=Y Selected=0xDD6419BC
|
||||||
DockNode ID=0x00000007 Parent=0x00000005 SizeRef=261,731 Split=X Selected=0xDD6419BC
|
DockNode ID=0x00000007 Parent=0x00000005 SizeRef=261,731 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=0x00000008 Parent=0x00000005 SizeRef=261,144 Selected=0x1D56B311
|
||||||
DockNode ID=0x00000006 Parent=0x00000004 SizeRef=261,321 Selected=0x8B4EBFA6
|
DockNode ID=0x00000006 Parent=0x00000004 SizeRef=261,321 Selected=0x8B4EBFA6
|
||||||
|
|
||||||
|
|||||||
@@ -65,9 +65,6 @@ def configure(file_items: list[dict], extra_base_dirs: list[str] | None = None):
|
|||||||
for item in file_items:
|
for item in file_items:
|
||||||
p = item.get("path")
|
p = item.get("path")
|
||||||
if p is not None:
|
if p is not None:
|
||||||
try:
|
|
||||||
rp = Path(p).resolve(strict=True)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
rp = Path(p).resolve()
|
rp = Path(p).resolve()
|
||||||
_allowed_paths.add(rp)
|
_allowed_paths.add(rp)
|
||||||
_base_dirs.add(rp.parent)
|
_base_dirs.add(rp.parent)
|
||||||
@@ -85,12 +82,7 @@ def _is_allowed(path: Path) -> bool:
|
|||||||
A path is allowed if:
|
A path is allowed if:
|
||||||
- it is explicitly in _allowed_paths, OR
|
- it is explicitly in _allowed_paths, OR
|
||||||
- it is contained within (or equal to) one of the _base_dirs
|
- 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.
|
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
rp = path.resolve(strict=True)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
rp = path.resolve()
|
rp = path.resolve()
|
||||||
if rp in _allowed_paths:
|
if rp in _allowed_paths:
|
||||||
return True
|
return True
|
||||||
@@ -112,9 +104,6 @@ def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]:
|
|||||||
p = Path(raw_path)
|
p = Path(raw_path)
|
||||||
if not p.is_absolute() and _primary_base_dir:
|
if not p.is_absolute() and _primary_base_dir:
|
||||||
p = _primary_base_dir / p
|
p = _primary_base_dir / p
|
||||||
try:
|
|
||||||
p = p.resolve(strict=True)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
p = p.resolve()
|
p = p.resolve()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return None, f"ERROR: invalid path '{raw_path}': {e}"
|
return None, f"ERROR: invalid path '{raw_path}': {e}"
|
||||||
@@ -280,8 +269,7 @@ def web_search(query: str) -> str:
|
|||||||
url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query)
|
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)'})
|
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore')
|
||||||
html = resp.read().decode('utf-8', errors='ignore')
|
|
||||||
parser = _DDGParser()
|
parser = _DDGParser()
|
||||||
parser.feed(html)
|
parser.feed(html)
|
||||||
if not parser.results:
|
if not parser.results:
|
||||||
@@ -304,8 +292,7 @@ def fetch_url(url: str) -> str:
|
|||||||
|
|
||||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore')
|
||||||
html = resp.read().decode('utf-8', errors='ignore')
|
|
||||||
parser = _TextExtractor()
|
parser = _TextExtractor()
|
||||||
parser.feed(html)
|
parser.feed(html)
|
||||||
full_text = " ".join(parser.text)
|
full_text = " ".join(parser.text)
|
||||||
|
|||||||
@@ -35,5 +35,5 @@ active = "main"
|
|||||||
|
|
||||||
[discussion.discussions.main]
|
[discussion.discussions.main]
|
||||||
git_commit = ""
|
git_commit = ""
|
||||||
last_updated = "2026-02-23T16:52:30"
|
last_updated = "2026-02-23T19:01:39"
|
||||||
history = []
|
history = []
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ scripts/generated/
|
|||||||
Where <ts> = YYYYMMDD_HHMMSS of when this session was started.
|
Where <ts> = YYYYMMDD_HHMMSS of when this session was started.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import atexit
|
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
@@ -72,8 +71,6 @@ def open_session():
|
|||||||
_tool_fh.write(f"# Tool-call log — session {_ts}\n\n")
|
_tool_fh.write(f"# Tool-call log — session {_ts}\n\n")
|
||||||
_tool_fh.flush()
|
_tool_fh.flush()
|
||||||
|
|
||||||
atexit.register(close_session)
|
|
||||||
|
|
||||||
|
|
||||||
def close_session():
|
def close_session():
|
||||||
"""Flush and close both log files. Called on clean exit (optional)."""
|
"""Flush and close both log files. Called on clean exit (optional)."""
|
||||||
|
|||||||
78
simulation/live_walkthrough.py
Normal file
78
simulation/live_walkthrough.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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()
|
||||||
57
simulation/ping_pong.py
Normal file
57
simulation/ping_pong.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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()
|
||||||
47
simulation/user_agent.py
Normal file
47
simulation/user_agent.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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)
|
||||||
73
simulation/workflow_sim.py
Normal file
73
simulation/workflow_sim.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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,11 +32,15 @@ def live_gui():
|
|||||||
"""
|
"""
|
||||||
print("\n[Fixture] Starting gui.py --enable-test-hooks...")
|
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.
|
# Start gui.py as a subprocess.
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
["uv", "run", "python", "gui.py", "--enable-test-hooks"],
|
["uv", "run", "python", "gui.py", "--enable-test-hooks"],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=log_file,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=log_file,
|
||||||
text=True,
|
text=True,
|
||||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == 'nt' else 0
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == 'nt' else 0
|
||||||
)
|
)
|
||||||
|
|||||||
41
tests/temp_project.toml
Normal file
41
tests/temp_project.toml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[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 = []
|
||||||
75
tests/test_api_hook_extensions.py
Normal file
75
tests/test_api_hook_extensions.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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()
|
||||||
88
tests/test_live_workflow.py
Normal file
88
tests/test_live_workflow.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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
|
||||||
22
tests/test_user_agent.py
Normal file
22
tests/test_user_agent.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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
|
||||||
47
tests/test_workflow_sim.py
Normal file
47
tests/test_workflow_sim.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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