20 Commits
cache ... sim

Author SHA1 Message Date
Ed_
fb1117becc Merge branch 'master' into sim 2026-02-23 20:03:45 -05:00
Ed_
df90bad4a1 Merge branch 'master' of https://git.cozyair.dev/ed/manual_slop
# Conflicts:
#	manual_slop.toml
2026-02-23 20:03:21 -05:00
Ed_
9f2ed38845 Merge branch 'master' of https://git.cozyair.dev/ed/manual_slop into sim
# Conflicts:
#	manual_slop.toml
2026-02-23 20:02:58 -05:00
Ed_
59f4df4475 docs(conductor): Synchronize docs for track 'Human-Like UX Interaction Test' 2026-02-23 19:55:25 -05:00
Ed_
c4da60d1c5 chore(conductor): Mark track 'Human-Like UX Interaction Test' as complete 2026-02-23 19:54:47 -05:00
Ed_
47c4117763 conductor(plan): Mark track 'Human-Like UX Interaction Test' as complete 2026-02-23 19:54:36 -05:00
Ed_
8e63b31508 conductor(checkpoint): Phase 4: Final Integration & Regression complete 2026-02-23 19:54:24 -05:00
Ed_
8bd280efc1 feat(simulation): stabilize IPC layer and verify full workflow 2026-02-23 19:53:32 -05:00
Ed_
ba97ccda3c conductor(plan): Mark Phase 3 as complete 2026-02-23 19:28:31 -05:00
Ed_
0f04e066ef conductor(checkpoint): Phase 3: History & Session Verification complete 2026-02-23 19:28:23 -05:00
Ed_
5e1b965311 feat(simulation): add discussion switching and truncation simulation logic 2026-02-23 19:26:51 -05:00
Ed_
fdb9b59d36 conductor(plan): Mark Phase 2 as complete 2026-02-23 19:25:39 -05:00
Ed_
9c4a72c734 conductor(checkpoint): Phase 2: Workflow Simulation complete 2026-02-23 19:25:31 -05:00
Ed_
6d16438477 feat(hooks): add get_indicator_state and verify thinking/live markers 2026-02-23 19:25:08 -05:00
Ed_
bd5dc16715 feat(simulation): implement project scaffolding and discussion loop logic 2026-02-23 19:24:26 -05:00
Ed_
895004ddc5 conductor(plan): Mark Phase 1 as complete 2026-02-23 19:23:40 -05:00
Ed_
76265319a7 conductor(checkpoint): Phase 1: Infrastructure & Automation Core complete 2026-02-23 19:23:31 -05:00
Ed_
bfe9ef014d feat(simulation): add ping-pong interaction script 2026-02-23 19:20:29 -05:00
Ed_
d326242667 feat(simulation): implement UserSimAgent for human-like interaction 2026-02-23 19:20:24 -05:00
Ed_
f36d539c36 feat(hooks): extend ApiHookClient and GUI for tab/listbox control 2026-02-23 19:20:20 -05:00
27 changed files with 987 additions and 723 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -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

View File

@@ -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: return tomllib.load(f)
with open(cred_path, "rb") as f:
return tomllib.load(f)
except FileNotFoundError:
raise FileNotFoundError(
f"Credentials file not found: {cred_path}\n"
f"Create a credentials.toml with:\n"
f" [gemini]\n api_key = \"your-key\"\n"
f" [anthropic]\n api_key = \"your-key\"\n"
f"Or set SLOP_CREDENTIALS env var to a custom path."
)
# ------------------------------------------------------------------ provider errors # ------------------------------------------------------------------ provider errors
@@ -264,8 +244,7 @@ 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,11 +969,7 @@ 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 user_content = [{"type": "text", "text": user_message}]
if discussion_history and not _anthropic_history:
user_content = [{"type": "text", "text": f"[DISCUSSION HISTORY]\n\n{discussion_history}\n\n---\n\n{user_message}"}]
else:
user_content = [{"type": "text", "text": user_message}]
# COMPRESS HISTORY: Truncate massive tool outputs from previous turns # COMPRESS HISTORY: Truncate massive tool outputs from previous turns
for msg in _anthropic_history: for msg in _anthropic_history:
@@ -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,26 +1160,21 @@ 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)
return _send_gemini(md_content, user_message, base_dir, file_items, discussion_history) elif _provider == "anthropic":
elif _provider == "anthropic": return _send_anthropic(md_content, user_message, base_dir, file_items)
return _send_anthropic(md_content, user_message, base_dir, file_items, discussion_history) 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 {

View File

@@ -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)}

View File

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

View File

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

View File

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

View File

@@ -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/)*

View File

@@ -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

View File

@@ -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
View File

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

548
gui_2.py
View File

@@ -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,121 +702,106 @@ 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()}) imgui.same_line()
imgui.same_line() if imgui.button("-All"):
if imgui.button("-All"): for e in self.disc_entries: e["collapsed"] = True
for e in self.disc_entries: e["collapsed"] = True imgui.same_line()
imgui.same_line() if imgui.button("+All"):
if imgui.button("+All"): for e in self.disc_entries: e["collapsed"] = False
for e in self.disc_entries: e["collapsed"] = False imgui.same_line()
imgui.same_line() if imgui.button("Clear All"):
if imgui.button("Clear All"): self.disc_entries.clear()
self.disc_entries.clear() imgui.same_line()
imgui.same_line() if imgui.button("Save"):
if imgui.button("Save"): 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) 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)
imgui.separator()
# Truncation controls if imgui.collapsing_header("Roles"):
imgui.text("Keep Pairs:") imgui.begin_child("roles_scroll", imgui.ImVec2(0, 100), True)
imgui.same_line() for i, r in enumerate(self.disc_roles):
imgui.set_next_item_width(80) if imgui.button(f"x##r{i}"):
ch, self.ui_disc_truncate_pairs = imgui.input_int("##trunc_pairs", self.ui_disc_truncate_pairs, 1) self.disc_roles.pop(i)
if self.ui_disc_truncate_pairs < 1: self.ui_disc_truncate_pairs = 1 break
imgui.same_line()
if imgui.button("Truncate"):
self.disc_entries = truncate_entries(self.disc_entries, self.ui_disc_truncate_pairs)
self.ai_status = f"history truncated to {self.ui_disc_truncate_pairs} pairs"
imgui.separator()
if imgui.collapsing_header("Roles"):
imgui.begin_child("roles_scroll", imgui.ImVec2(0, 100), True)
for i, r in enumerate(self.disc_roles):
if imgui.button(f"x##r{i}"):
self.disc_roles.pop(i)
break
imgui.same_line()
imgui.text(r)
imgui.end_child()
ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input)
imgui.same_line() imgui.same_line()
if imgui.button("Add"): imgui.text(r)
r = self.ui_disc_new_role_input.strip()
if r and r not in self.disc_roles:
self.disc_roles.append(r)
self.ui_disc_new_role_input = ""
imgui.separator()
imgui.begin_child("disc_scroll", imgui.ImVec2(0, 0), False)
for i, entry in enumerate(self.disc_entries):
imgui.push_id(str(i))
collapsed = entry.get("collapsed", False)
read_mode = entry.get("read_mode", False)
if imgui.button("+" if collapsed else "-"):
entry["collapsed"] = not collapsed
imgui.same_line()
imgui.set_next_item_width(120)
if imgui.begin_combo("##role", entry["role"]):
for r in self.disc_roles:
if imgui.selectable(r, r == entry["role"])[0]:
entry["role"] = r
imgui.end_combo()
if not collapsed:
imgui.same_line()
if imgui.button("[Edit]" if read_mode else "[Read]"):
entry["read_mode"] = not read_mode
ts_str = entry.get("ts", "")
if ts_str:
imgui.same_line()
imgui.text_colored(vec4(120, 120, 100), ts_str)
if collapsed:
imgui.same_line()
if imgui.button("Ins"):
self.disc_entries.insert(i, {"role": "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
imgui.same_line()
self._render_text_viewer(f"Entry #{i+1}", entry["content"])
imgui.same_line()
if imgui.button("Del"):
self.disc_entries.pop(i)
imgui.pop_id()
break
imgui.same_line()
preview = entry["content"].replace("\n", " ")[:60]
if len(entry["content"]) > 60: preview += "..."
imgui.text_colored(vec4(160, 160, 150), preview)
if not collapsed:
if read_mode:
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
imgui.text(entry["content"])
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
imgui.end_child()
else:
ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150))
imgui.separator()
imgui.pop_id()
if self._scroll_disc_to_bottom:
imgui.set_scroll_here_y(1.0)
self._scroll_disc_to_bottom = False
imgui.end_child() imgui.end_child()
ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input)
imgui.same_line()
if imgui.button("Add"):
r = self.ui_disc_new_role_input.strip()
if r and r not in self.disc_roles:
self.disc_roles.append(r)
self.ui_disc_new_role_input = ""
imgui.separator()
imgui.begin_child("disc_scroll", imgui.ImVec2(0, 0), False)
for i, entry in enumerate(self.disc_entries):
imgui.push_id(str(i))
collapsed = entry.get("collapsed", False)
read_mode = entry.get("read_mode", False)
if imgui.button("+" if collapsed else "-"):
entry["collapsed"] = not collapsed
imgui.same_line()
imgui.set_next_item_width(120)
if imgui.begin_combo("##role", entry["role"]):
for r in self.disc_roles:
if imgui.selectable(r, r == entry["role"])[0]:
entry["role"] = r
imgui.end_combo()
if not collapsed:
imgui.same_line()
if imgui.button("[Edit]" if read_mode else "[Read]"):
entry["read_mode"] = not read_mode
ts_str = entry.get("ts", "")
if ts_str:
imgui.same_line()
imgui.text_colored(vec4(120, 120, 100), ts_str)
if collapsed:
imgui.same_line()
if imgui.button("Ins"):
self.disc_entries.insert(i, {"role": "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
imgui.same_line()
self._render_text_viewer(f"Entry #{i+1}", entry["content"])
imgui.same_line()
if imgui.button("Del"):
self.disc_entries.pop(i)
imgui.pop_id()
break
imgui.same_line()
preview = entry["content"].replace("\n", " ")[:60]
if len(entry["content"]) > 60: preview += "..."
imgui.text_colored(vec4(160, 160, 150), preview)
if not collapsed:
if read_mode:
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
imgui.text(entry["content"])
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
imgui.end_child()
else:
ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150))
imgui.separator()
imgui.pop_id()
if self._scroll_disc_to_bottom:
imgui.set_scroll_here_y(1.0)
self._scroll_disc_to_bottom = False
imgui.end_child()
imgui.end() imgui.end()
# ---- Provider # ---- Provider
@@ -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,9 +888,8 @@ 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()
if imgui.button("MD Only"): if imgui.button("MD Only"):
try: try:
@@ -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

View File

@@ -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

View File

@@ -65,10 +65,7 @@ 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()
rp = Path(p).resolve(strict=True)
except (OSError, ValueError):
rp = Path(p).resolve()
_allowed_paths.add(rp) _allowed_paths.add(rp)
_base_dirs.add(rp.parent) _base_dirs.add(rp.parent)
@@ -85,13 +82,8 @@ 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()
rp = path.resolve(strict=True)
except (OSError, ValueError):
rp = path.resolve()
if rp in _allowed_paths: if rp in _allowed_paths:
return True return True
for bd in _base_dirs: for bd in _base_dirs:
@@ -112,10 +104,7 @@ 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()
p = p.resolve(strict=True)
except (OSError, ValueError):
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}"
if not _is_allowed(p): if not _is_allowed(p):
@@ -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)

View File

@@ -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 = []

View File

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

View 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
View 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
View 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)

View 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

View File

@@ -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
View 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 = []

View 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()

View 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
View 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

View 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")