Compare commits
8 Commits
450c17b96e
...
cache
| Author | SHA1 | Date | |
|---|---|---|---|
| bd8551d282 | |||
| 69401365be | |||
| 75e1cf84fe | |||
| 1d674c3a1e | |||
| 1db5ac57ec | |||
| d8e42a697b | |||
| 050d995660 | |||
| 0c5ac55053 |
+14
-1
@@ -164,6 +164,18 @@ def build_markdown_from_items(file_items: list[dict], screenshot_base_dir: Path,
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
def build_markdown_no_history(file_items: list[dict], screenshot_base_dir: Path, screenshots: list[str], summary_only: bool = False) -> str:
|
||||
"""Build markdown with only files + screenshots (no history). Used for stable caching."""
|
||||
return build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history=[], summary_only=summary_only)
|
||||
|
||||
|
||||
def build_discussion_text(history: list[str]) -> str:
|
||||
"""Build just the discussion history section text. Returns empty string if no history."""
|
||||
if not history:
|
||||
return ""
|
||||
return "## Discussion History\n\n" + build_discussion_section(history)
|
||||
|
||||
|
||||
def build_markdown(base_dir: Path, files: list[str], screenshot_base_dir: Path, screenshots: list[str], history: list[str], summary_only: bool = False) -> str:
|
||||
parts = []
|
||||
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
|
||||
@@ -195,8 +207,9 @@ def run(config: dict) -> tuple[str, Path, list[dict]]:
|
||||
output_file = output_dir / f"{namespace}_{increment:03d}.md"
|
||||
# Build file items once, then construct markdown from them (avoids double I/O)
|
||||
file_items = build_file_items(base_dir, files)
|
||||
summary_only = config.get("project", {}).get("summary_only", False)
|
||||
markdown = build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history,
|
||||
summary_only=False)
|
||||
summary_only=summary_only)
|
||||
output_file.write_text(markdown, encoding="utf-8")
|
||||
return markdown, output_file, file_items
|
||||
|
||||
|
||||
+132
-40
@@ -15,7 +15,11 @@ import tomllib
|
||||
import json
|
||||
import time
|
||||
import datetime
|
||||
import hashlib
|
||||
import difflib
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import os
|
||||
import file_cache
|
||||
import mcp_client
|
||||
import anthropic
|
||||
@@ -51,6 +55,8 @@ _GEMINI_CACHE_TTL = 3600
|
||||
|
||||
_anthropic_client = None
|
||||
_anthropic_history: list[dict] = []
|
||||
_anthropic_history_lock = threading.Lock()
|
||||
_send_lock = threading.Lock()
|
||||
|
||||
# Injected by gui.py - called when AI wants to run a command.
|
||||
# Signature: (script: str, base_dir: str) -> str | None
|
||||
@@ -67,6 +73,10 @@ tool_log_callback = None
|
||||
# Increased to allow thorough code exploration before forcing a summary
|
||||
MAX_TOOL_ROUNDS = 10
|
||||
|
||||
# Maximum cumulative bytes of tool output allowed per send() call.
|
||||
# Prevents unbounded memory growth during long tool-calling loops.
|
||||
_MAX_TOOL_OUTPUT_BYTES = 500_000
|
||||
|
||||
# Maximum characters per text chunk sent to Anthropic.
|
||||
# Kept well under the ~200k token API limit.
|
||||
_ANTHROPIC_CHUNK_SIZE = 120_000
|
||||
@@ -128,8 +138,18 @@ def clear_comms_log():
|
||||
|
||||
|
||||
def _load_credentials() -> dict:
|
||||
with open("credentials.toml", "rb") as f:
|
||||
return tomllib.load(f)
|
||||
cred_path = os.environ.get("SLOP_CREDENTIALS", "credentials.toml")
|
||||
try:
|
||||
with open(cred_path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(
|
||||
f"Credentials file not found: {cred_path}\n"
|
||||
f"Create a credentials.toml with:\n"
|
||||
f" [gemini]\n api_key = \"your-key\"\n"
|
||||
f" [anthropic]\n api_key = \"your-key\"\n"
|
||||
f"Or set SLOP_CREDENTIALS env var to a custom path."
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ provider errors
|
||||
@@ -244,7 +264,8 @@ def reset_session():
|
||||
_gemini_cache_md_hash = None
|
||||
_gemini_cache_created_at = None
|
||||
_anthropic_client = None
|
||||
_anthropic_history = []
|
||||
with _anthropic_history_lock:
|
||||
_anthropic_history = []
|
||||
_CACHED_ANTHROPIC_TOOLS = None
|
||||
file_cache.reset_client()
|
||||
|
||||
@@ -435,6 +456,13 @@ def _run_script(script: str, base_dir: str) -> str:
|
||||
return output
|
||||
|
||||
|
||||
def _truncate_tool_output(output: str) -> str:
|
||||
"""Truncate tool output to _history_trunc_limit chars before sending to API."""
|
||||
if _history_trunc_limit > 0 and len(output) > _history_trunc_limit:
|
||||
return output[:_history_trunc_limit] + "\n\n... [TRUNCATED BY SYSTEM TO SAVE TOKENS.]"
|
||||
return output
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ dynamic file context refresh
|
||||
|
||||
def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
@@ -460,7 +488,7 @@ def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
refreshed.append(item) # unchanged — skip re-read
|
||||
continue
|
||||
content = p.read_text(encoding="utf-8")
|
||||
new_item = {**item, "content": content, "error": False, "mtime": current_mtime}
|
||||
new_item = {**item, "old_content": item.get("content", ""), "content": content, "error": False, "mtime": current_mtime}
|
||||
refreshed.append(new_item)
|
||||
changed.append(new_item)
|
||||
except Exception as e:
|
||||
@@ -486,6 +514,35 @@ def _build_file_context_text(file_items: list[dict]) -> str:
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
_DIFF_LINE_THRESHOLD = 200
|
||||
|
||||
def _build_file_diff_text(changed_items: list[dict]) -> str:
|
||||
"""
|
||||
Build text for changed files. Small files (<= _DIFF_LINE_THRESHOLD lines)
|
||||
get full content; large files get a unified diff against old_content.
|
||||
"""
|
||||
if not changed_items:
|
||||
return ""
|
||||
parts = []
|
||||
for item in changed_items:
|
||||
path = item.get("path") or item.get("entry", "unknown")
|
||||
content = item.get("content", "")
|
||||
old_content = item.get("old_content", "")
|
||||
new_lines = content.splitlines(keepends=True)
|
||||
if len(new_lines) <= _DIFF_LINE_THRESHOLD or not old_content:
|
||||
suffix = str(path).rsplit(".", 1)[-1] if "." in str(path) else "text"
|
||||
parts.append(f"### `{path}` (full)\n\n```{suffix}\n{content}\n```")
|
||||
else:
|
||||
old_lines = old_content.splitlines(keepends=True)
|
||||
diff = difflib.unified_diff(old_lines, new_lines, fromfile=str(path), tofile=str(path), lineterm="")
|
||||
diff_text = "\n".join(diff)
|
||||
if diff_text:
|
||||
parts.append(f"### `{path}` (diff)\n\n```diff\n{diff_text}\n```")
|
||||
else:
|
||||
parts.append(f"### `{path}` (no changes detected)")
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ content block serialisation
|
||||
|
||||
def _content_block_to_dict(block) -> dict:
|
||||
@@ -530,22 +587,26 @@ def _get_gemini_history_list(chat):
|
||||
return chat.get_history()
|
||||
return []
|
||||
|
||||
def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
||||
def _send_gemini(md_content: str, user_message: str, base_dir: str,
|
||||
file_items: list[dict] | None = None,
|
||||
discussion_history: str = "") -> str:
|
||||
global _gemini_chat, _gemini_cache, _gemini_cache_md_hash, _gemini_cache_created_at
|
||||
|
||||
try:
|
||||
_ensure_gemini_client(); mcp_client.configure(file_items or [], [base_dir])
|
||||
# Only stable content (files + screenshots) goes in the cached system instruction.
|
||||
# Discussion history is sent as conversation messages so the cache isn't invalidated every turn.
|
||||
sys_instr = f"{_get_combined_system_prompt()}\n\n<context>\n{md_content}\n</context>"
|
||||
tools_decl = [_gemini_tool_declaration()]
|
||||
|
||||
# DYNAMIC CONTEXT: Check if files/context changed mid-session
|
||||
current_md_hash = hash(md_content)
|
||||
current_md_hash = hashlib.md5(md_content.encode()).hexdigest()
|
||||
old_history = None
|
||||
if _gemini_chat and _gemini_cache_md_hash != current_md_hash:
|
||||
old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else []
|
||||
if _gemini_cache:
|
||||
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||
except: pass
|
||||
except Exception as e: _append_comms("OUT", "request", {"message": f"[CACHE DELETE WARN] {e}"})
|
||||
_gemini_chat = None
|
||||
_gemini_cache = None
|
||||
_gemini_cache_created_at = None
|
||||
@@ -558,7 +619,7 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
||||
if elapsed > _GEMINI_CACHE_TTL * 0.9:
|
||||
old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else []
|
||||
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||
except: pass
|
||||
except Exception as e: _append_comms("OUT", "request", {"message": f"[CACHE DELETE WARN] {e}"})
|
||||
_gemini_chat = None
|
||||
_gemini_cache = None
|
||||
_gemini_cache_created_at = None
|
||||
@@ -601,9 +662,16 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
||||
|
||||
_gemini_chat = _gemini_client.chats.create(**kwargs)
|
||||
_gemini_cache_md_hash = current_md_hash
|
||||
|
||||
|
||||
# Inject discussion history as a user message on first chat creation
|
||||
# (only when there's no old_history being restored, i.e., fresh session)
|
||||
if discussion_history and not old_history:
|
||||
_gemini_chat.send_message(f"[DISCUSSION HISTORY]\n\n{discussion_history}")
|
||||
_append_comms("OUT", "request", {"message": f"[HISTORY INJECTED] {len(discussion_history)} chars"})
|
||||
|
||||
_append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"})
|
||||
payload, all_text = user_message, []
|
||||
_cumulative_tool_bytes = 0
|
||||
|
||||
# Strip stale file refreshes and truncate old tool outputs ONCE before
|
||||
# entering the tool loop (not per-round — history entries don't change).
|
||||
@@ -634,37 +702,30 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
||||
if cached_tokens:
|
||||
usage["cache_read_input_tokens"] = cached_tokens
|
||||
|
||||
# Fetch cache stats in the background thread to avoid blocking GUI
|
||||
cache_stats = None
|
||||
try:
|
||||
cache_stats = get_gemini_cache_stats()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
events.emit("response_received", payload={"provider": "gemini", "model": _model, "usage": usage, "round": r_idx, "cache_stats": cache_stats})
|
||||
events.emit("response_received", payload={"provider": "gemini", "model": _model, "usage": usage, "round": r_idx})
|
||||
|
||||
reason = resp.candidates[0].finish_reason.name if resp.candidates and hasattr(resp.candidates[0], "finish_reason") else "STOP"
|
||||
|
||||
_append_comms("IN", "response", {"round": r_idx, "stop_reason": reason, "text": txt, "tool_calls": [{"name": c.name, "args": dict(c.args)} for c in calls], "usage": usage})
|
||||
|
||||
# Guard: if Gemini reports input tokens approaching the limit, drop oldest history pairs
|
||||
# Guard: proactively trim history when input tokens exceed 40% of limit
|
||||
total_in = usage.get("input_tokens", 0)
|
||||
if total_in > _GEMINI_MAX_INPUT_TOKENS and _gemini_chat and _get_gemini_history_list(_gemini_chat):
|
||||
if total_in > _GEMINI_MAX_INPUT_TOKENS * 0.4 and _gemini_chat and _get_gemini_history_list(_gemini_chat):
|
||||
hist = _get_gemini_history_list(_gemini_chat)
|
||||
dropped = 0
|
||||
# Drop oldest pairs (user+model) but keep at least the last 2 entries
|
||||
while len(hist) > 4 and total_in > _GEMINI_MAX_INPUT_TOKENS * 0.7:
|
||||
while len(hist) > 4 and total_in > _GEMINI_MAX_INPUT_TOKENS * 0.3:
|
||||
# Drop in pairs (user + model) to maintain alternating roles required by Gemini
|
||||
saved = 0
|
||||
for _ in range(2):
|
||||
if not hist: break
|
||||
for p in hist[0].parts:
|
||||
if hasattr(p, "text") and p.text:
|
||||
saved += len(p.text) // 4
|
||||
saved += int(len(p.text) / _CHARS_PER_TOKEN)
|
||||
elif hasattr(p, "function_response") and p.function_response:
|
||||
r = getattr(p.function_response, "response", {})
|
||||
if isinstance(r, dict):
|
||||
saved += len(str(r.get("output", ""))) // 4
|
||||
saved += int(len(str(r.get("output", ""))) / _CHARS_PER_TOKEN)
|
||||
hist.pop(0)
|
||||
dropped += 1
|
||||
total_in -= max(saved, 200)
|
||||
@@ -689,15 +750,23 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
||||
if i == len(calls) - 1:
|
||||
if file_items:
|
||||
file_items, changed = _reread_file_items(file_items)
|
||||
ctx = _build_file_context_text(changed)
|
||||
ctx = _build_file_diff_text(changed)
|
||||
if ctx:
|
||||
out += f"\n\n[SYSTEM: FILES UPDATED]\n\n{ctx}"
|
||||
if r_idx == MAX_TOOL_ROUNDS: out += "\n\n[SYSTEM: MAX ROUNDS. PROVIDE FINAL ANSWER.]"
|
||||
|
||||
out = _truncate_tool_output(out)
|
||||
_cumulative_tool_bytes += len(out)
|
||||
f_resps.append(types.Part.from_function_response(name=name, response={"output": out}))
|
||||
log.append({"tool_use_id": name, "content": out})
|
||||
events.emit("tool_execution", payload={"status": "completed", "tool": name, "result": out, "round": r_idx})
|
||||
|
||||
|
||||
if _cumulative_tool_bytes > _MAX_TOOL_OUTPUT_BYTES:
|
||||
f_resps.append(types.Part.from_text(
|
||||
f"SYSTEM WARNING: Cumulative tool output exceeded {_MAX_TOOL_OUTPUT_BYTES // 1000}KB budget. Provide your final answer now."
|
||||
))
|
||||
_append_comms("OUT", "request", {"message": f"[TOOL OUTPUT BUDGET EXCEEDED: {_cumulative_tool_bytes} bytes]"})
|
||||
|
||||
_append_comms("OUT", "tool_result_send", {"results": log})
|
||||
payload = f_resps
|
||||
|
||||
@@ -955,7 +1024,7 @@ def _repair_anthropic_history(history: list[dict]):
|
||||
})
|
||||
|
||||
|
||||
def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
||||
def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None, discussion_history: str = "") -> str:
|
||||
try:
|
||||
_ensure_anthropic_client()
|
||||
mcp_client.configure(file_items or [], [base_dir])
|
||||
@@ -969,7 +1038,11 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
||||
context_blocks = _build_chunked_context_blocks(context_text)
|
||||
system_blocks = stable_blocks + context_blocks
|
||||
|
||||
user_content = [{"type": "text", "text": user_message}]
|
||||
# Prepend discussion history to the first user message if this is a fresh session
|
||||
if discussion_history and not _anthropic_history:
|
||||
user_content = [{"type": "text", "text": f"[DISCUSSION HISTORY]\n\n{discussion_history}\n\n---\n\n{user_message}"}]
|
||||
else:
|
||||
user_content = [{"type": "text", "text": user_message}]
|
||||
|
||||
# COMPRESS HISTORY: Truncate massive tool outputs from previous turns
|
||||
for msg in _anthropic_history:
|
||||
@@ -1000,6 +1073,7 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
||||
})
|
||||
|
||||
all_text_parts = []
|
||||
_cumulative_tool_bytes = 0
|
||||
|
||||
# We allow MAX_TOOL_ROUNDS, plus 1 final loop to get the text synthesis
|
||||
for round_idx in range(MAX_TOOL_ROUNDS + 2):
|
||||
@@ -1086,10 +1160,12 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
||||
_append_comms("OUT", "tool_call", {"name": b_name, "id": b_id, "args": b_input})
|
||||
output = mcp_client.dispatch(b_name, b_input)
|
||||
_append_comms("IN", "tool_result", {"name": b_name, "id": b_id, "output": output})
|
||||
truncated = _truncate_tool_output(output)
|
||||
_cumulative_tool_bytes += len(truncated)
|
||||
tool_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": b_id,
|
||||
"content": output,
|
||||
"content": truncated,
|
||||
})
|
||||
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
||||
elif b_name == TOOL_NAME:
|
||||
@@ -1105,17 +1181,26 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
||||
"id": b_id,
|
||||
"output": output,
|
||||
})
|
||||
truncated = _truncate_tool_output(output)
|
||||
_cumulative_tool_bytes += len(truncated)
|
||||
tool_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": b_id,
|
||||
"content": output,
|
||||
"content": truncated,
|
||||
})
|
||||
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
||||
|
||||
if _cumulative_tool_bytes > _MAX_TOOL_OUTPUT_BYTES:
|
||||
tool_results.append({
|
||||
"type": "text",
|
||||
"text": f"SYSTEM WARNING: Cumulative tool output exceeded {_MAX_TOOL_OUTPUT_BYTES // 1000}KB budget. Provide your final answer now."
|
||||
})
|
||||
_append_comms("OUT", "request", {"message": f"[TOOL OUTPUT BUDGET EXCEEDED: {_cumulative_tool_bytes} bytes]"})
|
||||
|
||||
# Refresh file context after tool calls — only inject CHANGED files
|
||||
if file_items:
|
||||
file_items, changed = _reread_file_items(file_items)
|
||||
refreshed_ctx = _build_file_context_text(changed)
|
||||
refreshed_ctx = _build_file_diff_text(changed)
|
||||
if refreshed_ctx:
|
||||
tool_results.append({
|
||||
"type": "text",
|
||||
@@ -1160,21 +1245,26 @@ def send(
|
||||
user_message: str,
|
||||
base_dir: str = ".",
|
||||
file_items: list[dict] | None = None,
|
||||
discussion_history: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Send a message to the active provider.
|
||||
|
||||
md_content : aggregated markdown string from aggregate.run()
|
||||
user_message: the user question / instruction
|
||||
base_dir : project base directory (for PowerShell tool calls)
|
||||
file_items : list of file dicts from aggregate.build_file_items() for
|
||||
dynamic context refresh after tool calls
|
||||
md_content : aggregated markdown string (for Gemini: stable content only,
|
||||
for Anthropic: full content including history)
|
||||
user_message : the user question / instruction
|
||||
base_dir : project base directory (for PowerShell tool calls)
|
||||
file_items : list of file dicts from aggregate.build_file_items() for
|
||||
dynamic context refresh after tool calls
|
||||
discussion_history : discussion history text (used by Gemini to inject as
|
||||
conversation message instead of caching it)
|
||||
"""
|
||||
if _provider == "gemini":
|
||||
return _send_gemini(md_content, user_message, base_dir, file_items)
|
||||
elif _provider == "anthropic":
|
||||
return _send_anthropic(md_content, user_message, base_dir, file_items)
|
||||
raise ValueError(f"unknown provider: {_provider}")
|
||||
with _send_lock:
|
||||
if _provider == "gemini":
|
||||
return _send_gemini(md_content, user_message, base_dir, file_items, discussion_history)
|
||||
elif _provider == "anthropic":
|
||||
return _send_anthropic(md_content, user_message, base_dir, file_items, discussion_history)
|
||||
raise ValueError(f"unknown provider: {_provider}")
|
||||
|
||||
def get_history_bleed_stats() -> dict:
|
||||
"""
|
||||
@@ -1182,7 +1272,9 @@ def get_history_bleed_stats() -> dict:
|
||||
"""
|
||||
if _provider == "anthropic":
|
||||
# For Anthropic, we have a robust estimator
|
||||
current_tokens = _estimate_prompt_tokens([], _anthropic_history)
|
||||
with _anthropic_history_lock:
|
||||
history_snapshot = list(_anthropic_history)
|
||||
current_tokens = _estimate_prompt_tokens([], history_snapshot)
|
||||
limit_tokens = _ANTHROPIC_MAX_PROMPT_TOKENS
|
||||
percentage = (current_tokens / limit_tokens) * 100 if limit_tokens > 0 else 0
|
||||
return {
|
||||
|
||||
+3
@@ -34,3 +34,6 @@ Fine-tune the UI based on live usage and verify against product guidelines.
|
||||
- [x] Task: Ensure all read-only text is selectable/copyable. ebd8158
|
||||
- [x] Task: Implement "Prior Session Log" viewer with tinted UI mode. ebd8158
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 4: Iterative Refinement and Final Audit' (Protocol in workflow.md) 22f8943
|
||||
|
||||
## Phase: Review Fixes
|
||||
- [x] Task: Apply review suggestions (Align diagnostics test) 0c5ac55
|
||||
+4
-2
@@ -9,8 +9,10 @@ This file tracks all major tracks for the project. Each track has its own detail
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Review GUI design. Make sure placment of tunings, features, etc that the gui provides frontend visualization and manipulation for make sense and are in the right place (not in a weird panel or doesn't make sense holistically for its use. Make plan for adjustments and then make major changes to meet resolved goals.**
|
||||
*Link: [./tracks/gui_layout_refinement_20260223/](./tracks/gui_layout_refinement_20260223/)*
|
||||
- [ ] **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/)*
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Track live_ux_test_20260223 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "live_ux_test_20260223",
|
||||
"type": "feature",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-23T19:14:00Z",
|
||||
"updated_at": "2026-02-23T19:14:00Z",
|
||||
"description": "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."
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Implementation Plan: Human-Like UX Interaction Test
|
||||
|
||||
## Phase 1: Infrastructure & Automation Core
|
||||
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.
|
||||
- [ ] Task: Implement `TestUserAgent` class to manage dynamic response generation and action delays.
|
||||
- [ ] Task: Write Tests (Verify basic hook connectivity and simulated delays)
|
||||
- [ ] Task: Implement basic 'ping-pong' interaction via hooks.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Infrastructure & Automation Core' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Workflow Simulation
|
||||
Build the core interaction loop for project creation and AI discussion.
|
||||
|
||||
- [ ] Task: Implement 'New Project' scaffolding script (creating a tiny console program).
|
||||
- [ ] Task: Implement 5-turn discussion loop logic with sub-agent responses.
|
||||
- [ ] Task: Write Tests (Verify state changes in Discussion Hub during simulated chat)
|
||||
- [ ] Task: Implement 'Thinking' and 'Live' indicator verification logic.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 2: Workflow Simulation' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: History & Session Verification
|
||||
Simulate complex session management and historical audit features.
|
||||
|
||||
- [ ] Task: Implement discussion switching logic (creating/switching between named discussions).
|
||||
- [ ] Task: Implement 'Load Prior Log' simulation and 'Tinted Mode' detection.
|
||||
- [ ] Task: Write Tests (Verify log loading and tab navigation consistency)
|
||||
- [ ] Task: Implement truncation limit verification (forcing a long history and checking bleed).
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 3: History & Session Verification' (Protocol in workflow.md)
|
||||
|
||||
## Phase 4: Final Integration & Regression
|
||||
Consolidate the simulation into end-user artifacts and CI tests.
|
||||
|
||||
- [ ] Task: Create `live_walkthrough.py` with full visual feedback and manual sign-off.
|
||||
- [ ] Task: Create `tests/test_live_workflow.py` for automated regression testing.
|
||||
- [ ] Task: Perform a full visual walkthrough and verify 'human-readable' pace.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 4: Final Integration & Regression' (Protocol in workflow.md)
|
||||
@@ -0,0 +1,37 @@
|
||||
# Specification: Human-Like UX Interaction Test
|
||||
|
||||
## Overview
|
||||
This track implements a robust, "human-like" interaction test suite for Manual Slop. The suite will simulate a real user's workflow—from project creation to complex AI discussions and history management—using the application's API hooks. It aims to verify the "Integrated Workspace" functionality, tool execution, and history persistence without requiring manual human input, while remaining slow enough for visual audit.
|
||||
|
||||
## Scope
|
||||
- **Standalone Interactive Test**: A Python script (`live_walkthrough.py`) that drives the GUI through a full session, ending with an optional manual sign-off.
|
||||
- **Automated Regression Test**: A pytest integration (`tests/test_live_workflow.py`) that executes the same logic in a headless or automated fashion for CI.
|
||||
- **Target Model**: Google Gemini Flash 2.5.
|
||||
|
||||
## Functional Requirements
|
||||
1. **User Simulation**:
|
||||
- **Dynamic Messaging**: The test agent will generate responses based on the AI's output to simulate a multi-turn conversation.
|
||||
- **Tactile Delays**: Short, random delays (minimum 0.5s) between actions to simulate reading and "typing" time.
|
||||
- **Visual Feedback**: Automatic scrolling of the discussion history and comms logs to keep the "live" action in view.
|
||||
2. **Workflow Scenarios**:
|
||||
- **Project Scaffolding**: Create a new project and initialize a tiny console-based Python program.
|
||||
- **Discussion Loop**: Engage in a ~5-turn conversation with the AI to refine the code.
|
||||
- **Context Management**: Verify that tool calls (filesystem, shell) are reflected correctly in the Comms and Tool Log tabs.
|
||||
- **History Depth**: Verify truncation limits and switching between named discussions.
|
||||
3. **Session Management**:
|
||||
- **Tab Interaction**: Programmatically switch between "Comms Log" and "Tool Log" tabs during operations.
|
||||
- **Historical Audit**: Use the "Load Session Log" feature to load a prior log file and verify "Tinted Mode" visibility.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Efficiency**: Minimize token usage by using Gemini Flash and keeping the "User" prompts concise.
|
||||
- **Observability**: The standalone test must be clearly visible to a human observer, with state changes occurring at a "human-readable" pace.
|
||||
|
||||
## Acceptance Criteria
|
||||
- `live_walkthrough.py` successfully completes a 5-turn discussion and signs off.
|
||||
- `tests/test_live_workflow.py` passes in CI environment.
|
||||
- Prior session logs are loaded and visualized without crashing.
|
||||
- Thinking and Live indicators trigger correctly during simulated API calls.
|
||||
|
||||
## Out of Scope
|
||||
- Support for Anthropic API in this specific test track.
|
||||
- Stress testing high-concurrency tool calls.
|
||||
+14
@@ -18,3 +18,17 @@ paths = [
|
||||
"C:/projects/forth/bootslop/bootslop.toml",
|
||||
]
|
||||
active = "manual_slop.toml"
|
||||
|
||||
[gui.show_windows]
|
||||
Projects = true
|
||||
Files = true
|
||||
Screenshots = true
|
||||
"Discussion History" = true
|
||||
Provider = true
|
||||
Message = true
|
||||
Response = true
|
||||
"Tool Calls" = true
|
||||
"Comms History" = true
|
||||
"System Prompts" = true
|
||||
Theme = true
|
||||
Diagnostics = true
|
||||
|
||||
@@ -4,6 +4,8 @@ import threading
|
||||
import time
|
||||
import math
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, Tk
|
||||
import aggregate
|
||||
@@ -14,6 +16,9 @@ import session_logger
|
||||
import project_manager
|
||||
import theme_2 as theme
|
||||
import tomllib
|
||||
import numpy as np
|
||||
import api_hooks
|
||||
from performance_monitor import PerformanceMonitor
|
||||
|
||||
from imgui_bundle import imgui, hello_imgui, immapp
|
||||
|
||||
@@ -56,6 +61,15 @@ KIND_COLORS = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_res
|
||||
HEAVY_KEYS = {"message", "text", "script", "output", "content"}
|
||||
|
||||
DISC_ROLES = ["User", "AI", "Vendor API", "System"]
|
||||
AGENT_TOOL_NAMES = ["run_powershell", "read_file", "list_directory", "search_files", "get_file_summary", "web_search", "fetch_url"]
|
||||
|
||||
def truncate_entries(entries: list[dict], max_pairs: int) -> list[dict]:
|
||||
if max_pairs <= 0:
|
||||
return []
|
||||
target_count = max_pairs * 2
|
||||
if len(entries) <= target_count:
|
||||
return entries
|
||||
return entries[-target_count:]
|
||||
|
||||
def _parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict]:
|
||||
known = roles if roles is not None else DISC_ROLES
|
||||
@@ -86,6 +100,9 @@ class App:
|
||||
self.current_provider: str = ai_cfg.get("provider", "gemini")
|
||||
self.current_model: str = ai_cfg.get("model", "gemini-2.0-flash")
|
||||
self.available_models: list[str] = []
|
||||
self.temperature: float = ai_cfg.get("temperature", 0.0)
|
||||
self.max_tokens: int = ai_cfg.get("max_tokens", 8192)
|
||||
self.history_trunc_limit: int = ai_cfg.get("history_trunc_limit", 8000)
|
||||
|
||||
projects_cfg = self.config.get("projects", {})
|
||||
self.project_paths: list[str] = list(projects_cfg.get("paths", []))
|
||||
@@ -116,6 +133,7 @@ class App:
|
||||
self.ui_project_main_context = proj_meta.get("main_context", "")
|
||||
self.ui_project_system_prompt = proj_meta.get("system_prompt", "")
|
||||
self.ui_word_wrap = proj_meta.get("word_wrap", True)
|
||||
self.ui_summary_only = proj_meta.get("summary_only", False)
|
||||
self.ui_auto_add_history = disc_sec.get("auto_add", False)
|
||||
|
||||
self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "")
|
||||
@@ -134,9 +152,10 @@ class App:
|
||||
self.last_file_items: list = []
|
||||
|
||||
self.send_thread: threading.Thread | None = None
|
||||
self._send_thread_lock = threading.Lock()
|
||||
self.models_thread: threading.Thread | None = None
|
||||
|
||||
self.show_windows = {
|
||||
_default_windows = {
|
||||
"Projects": True,
|
||||
"Files": True,
|
||||
"Screenshots": True,
|
||||
@@ -148,7 +167,10 @@ class App:
|
||||
"Comms History": True,
|
||||
"System Prompts": True,
|
||||
"Theme": True,
|
||||
"Diagnostics": False,
|
||||
}
|
||||
saved = self.config.get("gui", {}).get("show_windows", {})
|
||||
self.show_windows = {k: saved.get(k, v) for k, v in _default_windows.items()}
|
||||
self.show_script_output = False
|
||||
self.show_text_viewer = False
|
||||
self.text_viewer_title = ""
|
||||
@@ -176,12 +198,55 @@ class App:
|
||||
self._is_script_blinking = False
|
||||
self._script_blink_start_time = 0.0
|
||||
|
||||
self._scroll_disc_to_bottom = False
|
||||
|
||||
# GUI Task Queue (thread-safe, for event handlers and hook server)
|
||||
self._pending_gui_tasks: list[dict] = []
|
||||
self._pending_gui_tasks_lock = threading.Lock()
|
||||
|
||||
# Session usage tracking
|
||||
self.session_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}
|
||||
|
||||
# Token budget / cache telemetry
|
||||
self._token_budget_pct = 0.0
|
||||
self._token_budget_current = 0
|
||||
self._token_budget_limit = 0
|
||||
self._gemini_cache_text = ""
|
||||
|
||||
# Discussion truncation
|
||||
self.ui_disc_truncate_pairs: int = 2
|
||||
|
||||
# Agent tools config
|
||||
agent_tools_cfg = self.project.get("agent", {}).get("tools", {})
|
||||
self.ui_agent_tools: dict[str, bool] = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES}
|
||||
|
||||
# Prior session log viewing
|
||||
self.is_viewing_prior_session = False
|
||||
self.prior_session_entries: list[dict] = []
|
||||
|
||||
# API Hooks
|
||||
self.test_hooks_enabled = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1")
|
||||
|
||||
# Performance monitoring
|
||||
self.perf_monitor = PerformanceMonitor()
|
||||
self.perf_history = {"frame_time": [0.0]*100, "fps": [0.0]*100, "cpu": [0.0]*100, "input_lag": [0.0]*100}
|
||||
self._perf_last_update = 0.0
|
||||
|
||||
# Auto-save timer (every 60s)
|
||||
self._autosave_interval = 60.0
|
||||
self._last_autosave = time.time()
|
||||
|
||||
session_logger.open_session()
|
||||
ai_client.set_provider(self.current_provider, self.current_model)
|
||||
ai_client.confirm_and_run_callback = self._confirm_and_run
|
||||
ai_client.comms_log_callback = self._on_comms_entry
|
||||
ai_client.tool_log_callback = self._on_tool_log
|
||||
|
||||
# AI client event subscriptions
|
||||
ai_client.events.on("request_start", self._on_api_event)
|
||||
ai_client.events.on("response_received", self._on_api_event)
|
||||
ai_client.events.on("tool_execution", self._on_api_event)
|
||||
|
||||
# ---------------------------------------------------------------- project loading
|
||||
|
||||
def _load_active_project(self):
|
||||
@@ -248,6 +313,10 @@ class App:
|
||||
self.ui_project_main_context = proj.get("project", {}).get("main_context", "")
|
||||
self.ui_auto_add_history = proj.get("discussion", {}).get("auto_add", False)
|
||||
self.ui_word_wrap = proj.get("project", {}).get("word_wrap", True)
|
||||
self.ui_summary_only = proj.get("project", {}).get("summary_only", False)
|
||||
|
||||
agent_tools_cfg = proj.get("agent", {}).get("tools", {})
|
||||
self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES}
|
||||
|
||||
def _save_active_project(self):
|
||||
if self.active_project_path:
|
||||
@@ -332,6 +401,76 @@ class App:
|
||||
def _on_tool_log(self, script: str, result: str):
|
||||
session_logger.log_tool_call(script, result, None)
|
||||
|
||||
def _on_api_event(self, *args, **kwargs):
|
||||
payload = kwargs.get("payload", {})
|
||||
with self._pending_gui_tasks_lock:
|
||||
self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload})
|
||||
|
||||
def _process_pending_gui_tasks(self):
|
||||
if not self._pending_gui_tasks:
|
||||
return
|
||||
with self._pending_gui_tasks_lock:
|
||||
tasks = self._pending_gui_tasks[:]
|
||||
self._pending_gui_tasks.clear()
|
||||
for task in tasks:
|
||||
try:
|
||||
action = task.get("action")
|
||||
if action == "refresh_api_metrics":
|
||||
self._refresh_api_metrics(task.get("payload", {}))
|
||||
except Exception as e:
|
||||
print(f"Error executing GUI task: {e}")
|
||||
|
||||
def _recalculate_session_usage(self):
|
||||
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}
|
||||
for entry in ai_client.get_comms_log():
|
||||
if entry.get("kind") == "response" and "usage" in entry.get("payload", {}):
|
||||
u = entry["payload"]["usage"]
|
||||
for k in usage.keys():
|
||||
usage[k] += u.get(k, 0) or 0
|
||||
self.session_usage = usage
|
||||
|
||||
def _refresh_api_metrics(self, payload: dict):
|
||||
self._recalculate_session_usage()
|
||||
try:
|
||||
stats = ai_client.get_history_bleed_stats()
|
||||
self._token_budget_pct = stats.get("percentage", 0.0) / 100.0
|
||||
self._token_budget_current = stats.get("current", 0)
|
||||
self._token_budget_limit = stats.get("limit", 0)
|
||||
except Exception:
|
||||
pass
|
||||
cache_stats = payload.get("cache_stats")
|
||||
if cache_stats:
|
||||
count = cache_stats.get("cache_count", 0)
|
||||
size_bytes = cache_stats.get("total_size_bytes", 0)
|
||||
self._gemini_cache_text = f"Gemini Caches: {count} ({size_bytes / 1024:.1f} KB)"
|
||||
|
||||
def cb_load_prior_log(self):
|
||||
root = hide_tk_root()
|
||||
path = filedialog.askopenfilename(
|
||||
title="Load Session Log",
|
||||
initialdir="logs",
|
||||
filetypes=[("Log/JSONL", "*.log *.jsonl"), ("All Files", "*.*")]
|
||||
)
|
||||
root.destroy()
|
||||
if not path:
|
||||
return
|
||||
entries = []
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
self.ai_status = f"log load error: {e}"
|
||||
return
|
||||
self.prior_session_entries = entries
|
||||
self.is_viewing_prior_session = True
|
||||
self.ai_status = f"viewing prior session: {Path(path).name} ({len(entries)} entries)"
|
||||
|
||||
def _confirm_and_run(self, script: str, base_dir: str) -> str | None:
|
||||
dialog = ConfirmDialog(script, base_dir)
|
||||
with self._pending_dialog_lock:
|
||||
@@ -368,6 +507,11 @@ class App:
|
||||
proj["project"]["system_prompt"] = self.ui_project_system_prompt
|
||||
proj["project"]["main_context"] = self.ui_project_main_context
|
||||
proj["project"]["word_wrap"] = self.ui_word_wrap
|
||||
proj["project"]["summary_only"] = self.ui_summary_only
|
||||
|
||||
proj.setdefault("agent", {}).setdefault("tools", {})
|
||||
for t_name in AGENT_TOOL_NAMES:
|
||||
proj["agent"]["tools"][t_name] = self.ui_agent_tools.get(t_name, True)
|
||||
|
||||
self._flush_disc_entries_to_project()
|
||||
disc_sec = proj.setdefault("discussion", {})
|
||||
@@ -376,18 +520,35 @@ class App:
|
||||
disc_sec["auto_add"] = self.ui_auto_add_history
|
||||
|
||||
def _flush_to_config(self):
|
||||
self.config["ai"] = {"provider": self.current_provider, "model": self.current_model}
|
||||
self.config["ai"] = {
|
||||
"provider": self.current_provider,
|
||||
"model": self.current_model,
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
"history_trunc_limit": self.history_trunc_limit,
|
||||
}
|
||||
self.config["ai"]["system_prompt"] = self.ui_global_system_prompt
|
||||
self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path}
|
||||
self.config["gui"] = {"show_windows": self.show_windows}
|
||||
theme.save_to_config(self.config)
|
||||
|
||||
def _do_generate(self) -> tuple[str, Path, list]:
|
||||
def _do_generate(self) -> tuple[str, Path, list, str, str]:
|
||||
"""Returns (full_md, output_path, file_items, stable_md, discussion_text)."""
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
save_config(self.config)
|
||||
flat = project_manager.flat_config(self.project, self.active_discussion)
|
||||
return aggregate.run(flat)
|
||||
full_md, path, file_items = aggregate.run(flat)
|
||||
# Build stable markdown (no history) for Gemini caching
|
||||
screenshot_base_dir = Path(flat.get("screenshots", {}).get("base_dir", "."))
|
||||
screenshots = flat.get("screenshots", {}).get("paths", [])
|
||||
summary_only = flat.get("project", {}).get("summary_only", False)
|
||||
stable_md = aggregate.build_markdown_no_history(file_items, screenshot_base_dir, screenshots, summary_only=summary_only)
|
||||
# Build discussion history text separately
|
||||
history = flat.get("discussion", {}).get("history", [])
|
||||
discussion_text = aggregate.build_discussion_text(history)
|
||||
return full_md, path, file_items, stable_md, discussion_text
|
||||
|
||||
def _fetch_models(self, provider: str):
|
||||
self.ai_status = "fetching models..."
|
||||
@@ -434,6 +595,23 @@ class App:
|
||||
# ---------------------------------------------------------------- gui
|
||||
|
||||
def _gui_func(self):
|
||||
self.perf_monitor.start_frame()
|
||||
|
||||
# Process GUI task queue
|
||||
self._process_pending_gui_tasks()
|
||||
|
||||
# Auto-save (every 60s)
|
||||
now = time.time()
|
||||
if now - self._last_autosave >= self._autosave_interval:
|
||||
self._last_autosave = now
|
||||
try:
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
save_config(self.config)
|
||||
except Exception:
|
||||
pass # silent — don't disrupt the GUI loop
|
||||
|
||||
# Sync pending comms
|
||||
with self._pending_comms_lock:
|
||||
for c in self._pending_comms:
|
||||
@@ -441,6 +619,8 @@ class App:
|
||||
self._pending_comms.clear()
|
||||
|
||||
with self._pending_history_adds_lock:
|
||||
if self._pending_history_adds:
|
||||
self._scroll_disc_to_bottom = True
|
||||
for item in self._pending_history_adds:
|
||||
if item["role"] not in self.disc_roles:
|
||||
self.disc_roles.append(item["role"])
|
||||
@@ -453,22 +633,22 @@ class App:
|
||||
_, self.show_windows[w] = imgui.menu_item(w, "", self.show_windows[w])
|
||||
imgui.end_menu()
|
||||
if imgui.begin_menu("Project"):
|
||||
if imgui.menu_item("Save All")[0]:
|
||||
if imgui.menu_item("Save All", "", False)[0]:
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
save_config(self.config)
|
||||
self.ai_status = "config saved"
|
||||
if imgui.menu_item("Reset Session")[0]:
|
||||
if imgui.menu_item("Reset Session", "", False)[0]:
|
||||
ai_client.reset_session()
|
||||
ai_client.clear_comms_log()
|
||||
self._tool_log.clear()
|
||||
self._comms_log.clear()
|
||||
self.ai_status = "session reset"
|
||||
self.ai_response = ""
|
||||
if imgui.menu_item("Generate MD Only")[0]:
|
||||
if imgui.menu_item("Generate MD Only", "", False)[0]:
|
||||
try:
|
||||
md, path, _ = self._do_generate()
|
||||
md, path, *_ = self._do_generate()
|
||||
self.last_md = md
|
||||
self.last_md_path = path
|
||||
self.ai_status = f"md written: {path.name}"
|
||||
@@ -535,7 +715,10 @@ class App:
|
||||
|
||||
if imgui.button("Add Project"):
|
||||
r = hide_tk_root()
|
||||
p = filedialog.askopenfilename(title="Select Project .toml", filetypes=[("TOML", "*.toml"), ("All", "*.*")])
|
||||
p = filedialog.askopenfilename(
|
||||
title="Select Project .toml",
|
||||
filetypes=[("TOML", "*.toml"), ("All", "*.*")],
|
||||
)
|
||||
r.destroy()
|
||||
if p and p not in self.project_paths:
|
||||
self.project_paths.append(p)
|
||||
@@ -560,6 +743,14 @@ class App:
|
||||
self.ai_status = "config saved"
|
||||
|
||||
ch, self.ui_word_wrap = imgui.checkbox("Word-Wrap (Read-only panels)", self.ui_word_wrap)
|
||||
ch, self.ui_summary_only = imgui.checkbox("Summary Only (send file structure, not full content)", self.ui_summary_only)
|
||||
|
||||
if imgui.collapsing_header("Agent Tools"):
|
||||
for t_name in AGENT_TOOL_NAMES:
|
||||
val = self.ui_agent_tools.get(t_name, True)
|
||||
ch, val = imgui.checkbox(f"Enable {t_name}", val)
|
||||
if ch:
|
||||
self.ui_agent_tools[t_name] = val
|
||||
imgui.end()
|
||||
|
||||
# ---- Files
|
||||
@@ -626,7 +817,10 @@ class App:
|
||||
|
||||
if imgui.button("Add Screenshot(s)"):
|
||||
r = hide_tk_root()
|
||||
paths = filedialog.askopenfilenames()
|
||||
paths = filedialog.askopenfilenames(
|
||||
title="Select Screenshots",
|
||||
filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), ("All", "*.*")],
|
||||
)
|
||||
r.destroy()
|
||||
for p in paths:
|
||||
if p not in self.screenshots: self.screenshots.append(p)
|
||||
@@ -636,7 +830,50 @@ class App:
|
||||
if self.show_windows["Discussion History"]:
|
||||
exp, self.show_windows["Discussion History"] = imgui.begin("Discussion History", self.show_windows["Discussion History"])
|
||||
if exp:
|
||||
if imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open):
|
||||
# THINKING indicator
|
||||
is_thinking = self.ai_status in ["sending..."]
|
||||
if is_thinking:
|
||||
val = math.sin(time.time() * 10 * math.pi)
|
||||
alpha = 1.0 if val > 0 else 0.0
|
||||
imgui.text_colored(imgui.ImVec4(1.0, 0.39, 0.39, alpha), "THINKING...")
|
||||
imgui.separator()
|
||||
|
||||
# Prior session viewing mode
|
||||
if self.is_viewing_prior_session:
|
||||
imgui.push_style_color(imgui.Col_.child_bg, vec4(50, 40, 20))
|
||||
imgui.text_colored(vec4(255, 200, 100), "VIEWING PRIOR SESSION")
|
||||
imgui.same_line()
|
||||
if imgui.button("Exit Prior Session"):
|
||||
self.is_viewing_prior_session = False
|
||||
self.prior_session_entries.clear()
|
||||
imgui.separator()
|
||||
imgui.begin_child("prior_scroll", imgui.ImVec2(0, 0), False)
|
||||
for idx, entry in enumerate(self.prior_session_entries):
|
||||
imgui.push_id(f"prior_{idx}")
|
||||
kind = entry.get("kind", entry.get("type", ""))
|
||||
imgui.text_colored(C_LBL, f"#{idx+1}")
|
||||
imgui.same_line()
|
||||
ts = entry.get("ts", entry.get("timestamp", ""))
|
||||
if ts:
|
||||
imgui.text_colored(vec4(160, 160, 160), str(ts))
|
||||
imgui.same_line()
|
||||
imgui.text_colored(C_KEY, str(kind))
|
||||
payload = entry.get("payload", entry)
|
||||
text = payload.get("text", payload.get("message", payload.get("content", "")))
|
||||
if text:
|
||||
preview = str(text).replace("\n", " ")[:200]
|
||||
if self.ui_word_wrap:
|
||||
imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
||||
imgui.text(preview)
|
||||
imgui.pop_text_wrap_pos()
|
||||
else:
|
||||
imgui.text(preview)
|
||||
imgui.separator()
|
||||
imgui.pop_id()
|
||||
imgui.end_child()
|
||||
imgui.pop_style_color()
|
||||
|
||||
if not self.is_viewing_prior_session and imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open):
|
||||
names = self._get_discussion_names()
|
||||
|
||||
if imgui.begin_combo("##disc_sel", self.active_discussion):
|
||||
@@ -683,103 +920,121 @@ class App:
|
||||
if imgui.button("Delete"):
|
||||
self._delete_discussion(self.active_discussion)
|
||||
|
||||
imgui.separator()
|
||||
if imgui.button("+ Entry"):
|
||||
self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||
imgui.same_line()
|
||||
if imgui.button("-All"):
|
||||
for e in self.disc_entries: e["collapsed"] = True
|
||||
imgui.same_line()
|
||||
if imgui.button("+All"):
|
||||
for e in self.disc_entries: e["collapsed"] = False
|
||||
imgui.same_line()
|
||||
if imgui.button("Clear All"):
|
||||
self.disc_entries.clear()
|
||||
imgui.same_line()
|
||||
if imgui.button("Save"):
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
save_config(self.config)
|
||||
self.ai_status = "discussion saved"
|
||||
|
||||
ch, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history)
|
||||
imgui.separator()
|
||||
|
||||
if imgui.collapsing_header("Roles"):
|
||||
imgui.begin_child("roles_scroll", imgui.ImVec2(0, 100), True)
|
||||
for i, r in enumerate(self.disc_roles):
|
||||
if imgui.button(f"x##r{i}"):
|
||||
self.disc_roles.pop(i)
|
||||
break
|
||||
imgui.same_line()
|
||||
imgui.text(r)
|
||||
imgui.end_child()
|
||||
ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input)
|
||||
imgui.same_line()
|
||||
if imgui.button("Add"):
|
||||
r = self.ui_disc_new_role_input.strip()
|
||||
if r and r not in self.disc_roles:
|
||||
self.disc_roles.append(r)
|
||||
self.ui_disc_new_role_input = ""
|
||||
|
||||
imgui.separator()
|
||||
imgui.begin_child("disc_scroll", imgui.ImVec2(0, 0), False)
|
||||
for i, entry in enumerate(self.disc_entries):
|
||||
imgui.push_id(str(i))
|
||||
collapsed = entry.get("collapsed", False)
|
||||
read_mode = entry.get("read_mode", False)
|
||||
|
||||
if imgui.button("+" if collapsed else "-"):
|
||||
entry["collapsed"] = not collapsed
|
||||
imgui.same_line()
|
||||
|
||||
imgui.set_next_item_width(120)
|
||||
if imgui.begin_combo("##role", entry["role"]):
|
||||
for r in self.disc_roles:
|
||||
if imgui.selectable(r, r == entry["role"])[0]:
|
||||
entry["role"] = r
|
||||
imgui.end_combo()
|
||||
|
||||
if not collapsed:
|
||||
imgui.same_line()
|
||||
if imgui.button("[Edit]" if read_mode else "[Read]"):
|
||||
entry["read_mode"] = not read_mode
|
||||
|
||||
ts_str = entry.get("ts", "")
|
||||
if ts_str:
|
||||
imgui.same_line()
|
||||
imgui.text_colored(vec4(120, 120, 100), ts_str)
|
||||
|
||||
if collapsed:
|
||||
imgui.same_line()
|
||||
if imgui.button("Ins"):
|
||||
self.disc_entries.insert(i, {"role": "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||
imgui.same_line()
|
||||
self._render_text_viewer(f"Entry #{i+1}", entry["content"])
|
||||
imgui.same_line()
|
||||
if imgui.button("Del"):
|
||||
self.disc_entries.pop(i)
|
||||
imgui.pop_id()
|
||||
break
|
||||
imgui.same_line()
|
||||
preview = entry["content"].replace("\n", " ")[:60]
|
||||
if len(entry["content"]) > 60: preview += "..."
|
||||
imgui.text_colored(vec4(160, 160, 150), preview)
|
||||
|
||||
if not collapsed:
|
||||
if read_mode:
|
||||
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
|
||||
if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
||||
imgui.text(entry["content"])
|
||||
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
|
||||
imgui.end_child()
|
||||
else:
|
||||
ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150))
|
||||
|
||||
if not self.is_viewing_prior_session:
|
||||
imgui.separator()
|
||||
imgui.pop_id()
|
||||
imgui.end_child()
|
||||
if imgui.button("+ Entry"):
|
||||
self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||
imgui.same_line()
|
||||
if imgui.button("-All"):
|
||||
for e in self.disc_entries: e["collapsed"] = True
|
||||
imgui.same_line()
|
||||
if imgui.button("+All"):
|
||||
for e in self.disc_entries: e["collapsed"] = False
|
||||
imgui.same_line()
|
||||
if imgui.button("Clear All"):
|
||||
self.disc_entries.clear()
|
||||
imgui.same_line()
|
||||
if imgui.button("Save"):
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
save_config(self.config)
|
||||
self.ai_status = "discussion saved"
|
||||
imgui.same_line()
|
||||
if imgui.button("Load Log"):
|
||||
self.cb_load_prior_log()
|
||||
|
||||
ch, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history)
|
||||
|
||||
# Truncation controls
|
||||
imgui.text("Keep Pairs:")
|
||||
imgui.same_line()
|
||||
imgui.set_next_item_width(80)
|
||||
ch, self.ui_disc_truncate_pairs = imgui.input_int("##trunc_pairs", self.ui_disc_truncate_pairs, 1)
|
||||
if self.ui_disc_truncate_pairs < 1: self.ui_disc_truncate_pairs = 1
|
||||
imgui.same_line()
|
||||
if imgui.button("Truncate"):
|
||||
self.disc_entries = truncate_entries(self.disc_entries, self.ui_disc_truncate_pairs)
|
||||
self.ai_status = f"history truncated to {self.ui_disc_truncate_pairs} pairs"
|
||||
imgui.separator()
|
||||
|
||||
if imgui.collapsing_header("Roles"):
|
||||
imgui.begin_child("roles_scroll", imgui.ImVec2(0, 100), True)
|
||||
for i, r in enumerate(self.disc_roles):
|
||||
if imgui.button(f"x##r{i}"):
|
||||
self.disc_roles.pop(i)
|
||||
break
|
||||
imgui.same_line()
|
||||
imgui.text(r)
|
||||
imgui.end_child()
|
||||
ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input)
|
||||
imgui.same_line()
|
||||
if imgui.button("Add"):
|
||||
r = self.ui_disc_new_role_input.strip()
|
||||
if r and r not in self.disc_roles:
|
||||
self.disc_roles.append(r)
|
||||
self.ui_disc_new_role_input = ""
|
||||
|
||||
imgui.separator()
|
||||
imgui.begin_child("disc_scroll", imgui.ImVec2(0, 0), False)
|
||||
for i, entry in enumerate(self.disc_entries):
|
||||
imgui.push_id(str(i))
|
||||
collapsed = entry.get("collapsed", False)
|
||||
read_mode = entry.get("read_mode", False)
|
||||
|
||||
if imgui.button("+" if collapsed else "-"):
|
||||
entry["collapsed"] = not collapsed
|
||||
imgui.same_line()
|
||||
|
||||
imgui.set_next_item_width(120)
|
||||
if imgui.begin_combo("##role", entry["role"]):
|
||||
for r in self.disc_roles:
|
||||
if imgui.selectable(r, r == entry["role"])[0]:
|
||||
entry["role"] = r
|
||||
imgui.end_combo()
|
||||
|
||||
if not collapsed:
|
||||
imgui.same_line()
|
||||
if imgui.button("[Edit]" if read_mode else "[Read]"):
|
||||
entry["read_mode"] = not read_mode
|
||||
|
||||
ts_str = entry.get("ts", "")
|
||||
if ts_str:
|
||||
imgui.same_line()
|
||||
imgui.text_colored(vec4(120, 120, 100), ts_str)
|
||||
|
||||
if collapsed:
|
||||
imgui.same_line()
|
||||
if imgui.button("Ins"):
|
||||
self.disc_entries.insert(i, {"role": "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||
imgui.same_line()
|
||||
self._render_text_viewer(f"Entry #{i+1}", entry["content"])
|
||||
imgui.same_line()
|
||||
if imgui.button("Del"):
|
||||
self.disc_entries.pop(i)
|
||||
imgui.pop_id()
|
||||
break
|
||||
imgui.same_line()
|
||||
preview = entry["content"].replace("\n", " ")[:60]
|
||||
if len(entry["content"]) > 60: preview += "..."
|
||||
imgui.text_colored(vec4(160, 160, 150), preview)
|
||||
|
||||
if not collapsed:
|
||||
if read_mode:
|
||||
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
|
||||
if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
||||
imgui.text(entry["content"])
|
||||
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
|
||||
imgui.end_child()
|
||||
else:
|
||||
ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150))
|
||||
|
||||
imgui.separator()
|
||||
imgui.pop_id()
|
||||
if self._scroll_disc_to_bottom:
|
||||
imgui.set_scroll_here_y(1.0)
|
||||
self._scroll_disc_to_bottom = False
|
||||
imgui.end_child()
|
||||
imgui.end()
|
||||
|
||||
# ---- Provider
|
||||
@@ -809,18 +1064,55 @@ class App:
|
||||
ai_client.reset_session()
|
||||
ai_client.set_provider(self.current_provider, m)
|
||||
imgui.end_list_box()
|
||||
imgui.separator()
|
||||
imgui.text("Parameters")
|
||||
ch, self.temperature = imgui.slider_float("Temperature", self.temperature, 0.0, 2.0, "%.2f")
|
||||
ch, self.max_tokens = imgui.input_int("Max Tokens (Output)", self.max_tokens, 1024)
|
||||
ch, self.history_trunc_limit = imgui.input_int("History Truncation Limit", self.history_trunc_limit, 1024)
|
||||
|
||||
imgui.separator()
|
||||
imgui.text("Telemetry")
|
||||
usage = self.session_usage
|
||||
total = usage["input_tokens"] + usage["output_tokens"]
|
||||
imgui.text_colored(C_RES, f"Tokens: {total:,} (In: {usage['input_tokens']:,} Out: {usage['output_tokens']:,})")
|
||||
if usage["cache_read_input_tokens"]:
|
||||
imgui.text_colored(C_LBL, f" Cache Read: {usage['cache_read_input_tokens']:,} Creation: {usage['cache_creation_input_tokens']:,}")
|
||||
imgui.text("Token Budget:")
|
||||
imgui.progress_bar(self._token_budget_pct, imgui.ImVec2(-1, 0), f"{self._token_budget_current:,} / {self._token_budget_limit:,}")
|
||||
if self._gemini_cache_text:
|
||||
imgui.text_colored(C_SUB, self._gemini_cache_text)
|
||||
imgui.end()
|
||||
|
||||
# ---- Message
|
||||
if self.show_windows["Message"]:
|
||||
exp, self.show_windows["Message"] = imgui.begin("Message", self.show_windows["Message"])
|
||||
if exp:
|
||||
# LIVE indicator
|
||||
is_live = self.ai_status in ["running powershell...", "fetching url...", "searching web...", "powershell done, awaiting AI..."]
|
||||
if is_live:
|
||||
val = math.sin(time.time() * 10 * math.pi)
|
||||
alpha = 1.0 if val > 0 else 0.0
|
||||
imgui.text_colored(imgui.ImVec4(0.39, 1.0, 0.39, alpha), "LIVE")
|
||||
imgui.separator()
|
||||
|
||||
ch, self.ui_ai_input = imgui.input_text_multiline("##ai_in", self.ui_ai_input, imgui.ImVec2(-1, -40))
|
||||
|
||||
# Keyboard shortcuts
|
||||
io = imgui.get_io()
|
||||
ctrl_enter = io.key_ctrl and imgui.is_key_pressed(imgui.Key.enter)
|
||||
ctrl_l = io.key_ctrl and imgui.is_key_pressed(imgui.Key.l)
|
||||
if ctrl_l:
|
||||
self.ui_ai_input = ""
|
||||
|
||||
imgui.separator()
|
||||
if imgui.button("Gen + Send"):
|
||||
if not (self.send_thread and self.send_thread.is_alive()):
|
||||
send_busy = False
|
||||
with self._send_thread_lock:
|
||||
if self.send_thread and self.send_thread.is_alive():
|
||||
send_busy = True
|
||||
if imgui.button("Gen + Send") or ctrl_enter:
|
||||
if not send_busy:
|
||||
try:
|
||||
md, path, file_items = self._do_generate()
|
||||
md, path, file_items, stable_md, disc_text = self._do_generate()
|
||||
self.last_md = md
|
||||
self.last_md_path = path
|
||||
self.last_file_items = file_items
|
||||
@@ -832,13 +1124,17 @@ class App:
|
||||
base_dir = self.ui_files_base_dir
|
||||
csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()])
|
||||
ai_client.set_custom_system_prompt("\n\n".join(csp))
|
||||
|
||||
ai_client.set_model_params(self.temperature, self.max_tokens, self.history_trunc_limit)
|
||||
ai_client.set_agent_tools(self.ui_agent_tools)
|
||||
send_md = stable_md
|
||||
send_disc = disc_text
|
||||
|
||||
def do_send():
|
||||
if self.ui_auto_add_history:
|
||||
with self._pending_history_adds_lock:
|
||||
self._pending_history_adds.append({"role": "User", "content": user_msg, "collapsed": False, "ts": project_manager.now_ts()})
|
||||
try:
|
||||
resp = ai_client.send(self.last_md, user_msg, base_dir, self.last_file_items)
|
||||
resp = ai_client.send(send_md, user_msg, base_dir, self.last_file_items, send_disc)
|
||||
self.ai_response = resp
|
||||
self.ai_status = "done"
|
||||
self._trigger_blink = True
|
||||
@@ -859,13 +1155,14 @@ class App:
|
||||
if self.ui_auto_add_history:
|
||||
with self._pending_history_adds_lock:
|
||||
self._pending_history_adds.append({"role": "System", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()})
|
||||
|
||||
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
||||
self.send_thread.start()
|
||||
|
||||
with self._send_thread_lock:
|
||||
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
||||
self.send_thread.start()
|
||||
imgui.same_line()
|
||||
if imgui.button("MD Only"):
|
||||
try:
|
||||
md, path, _ = self._do_generate()
|
||||
md, path, *_ = self._do_generate()
|
||||
self.last_md = md
|
||||
self.last_md_path = path
|
||||
self.ai_status = f"md written: {path.name}"
|
||||
@@ -1140,6 +1437,67 @@ class App:
|
||||
if ch: theme.set_scale(scale)
|
||||
imgui.end()
|
||||
|
||||
# ---- Diagnostics
|
||||
if self.show_windows["Diagnostics"]:
|
||||
exp, self.show_windows["Diagnostics"] = imgui.begin("Diagnostics", self.show_windows["Diagnostics"])
|
||||
if exp:
|
||||
now = time.time()
|
||||
if now - self._perf_last_update >= 0.5:
|
||||
self._perf_last_update = now
|
||||
metrics = self.perf_monitor.get_metrics()
|
||||
self.perf_history["frame_time"].pop(0)
|
||||
self.perf_history["frame_time"].append(metrics.get("last_frame_time_ms", 0.0))
|
||||
self.perf_history["fps"].pop(0)
|
||||
self.perf_history["fps"].append(metrics.get("fps", 0.0))
|
||||
self.perf_history["cpu"].pop(0)
|
||||
self.perf_history["cpu"].append(metrics.get("cpu_percent", 0.0))
|
||||
self.perf_history["input_lag"].pop(0)
|
||||
self.perf_history["input_lag"].append(metrics.get("input_lag_ms", 0.0))
|
||||
|
||||
metrics = self.perf_monitor.get_metrics()
|
||||
imgui.text("Performance Telemetry")
|
||||
imgui.separator()
|
||||
|
||||
if imgui.begin_table("perf_table", 2, imgui.TableFlags_.borders_inner_h):
|
||||
imgui.table_setup_column("Metric")
|
||||
imgui.table_setup_column("Value")
|
||||
imgui.table_headers_row()
|
||||
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text("FPS")
|
||||
imgui.table_next_column()
|
||||
imgui.text(f"{metrics.get('fps', 0.0):.1f}")
|
||||
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text("Frame Time (ms)")
|
||||
imgui.table_next_column()
|
||||
imgui.text(f"{metrics.get('last_frame_time_ms', 0.0):.2f}")
|
||||
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text("CPU %")
|
||||
imgui.table_next_column()
|
||||
imgui.text(f"{metrics.get('cpu_percent', 0.0):.1f}")
|
||||
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text("Input Lag (ms)")
|
||||
imgui.table_next_column()
|
||||
imgui.text(f"{metrics.get('input_lag_ms', 0.0):.1f}")
|
||||
|
||||
imgui.end_table()
|
||||
|
||||
imgui.separator()
|
||||
imgui.text("Frame Time (ms)")
|
||||
imgui.plot_lines("##ft_plot", np.array(self.perf_history["frame_time"], dtype=np.float32), overlay_text="frame_time", graph_size=imgui.ImVec2(-1, 60))
|
||||
imgui.text("CPU %")
|
||||
imgui.plot_lines("##cpu_plot", np.array(self.perf_history["cpu"], dtype=np.float32), overlay_text="cpu", graph_size=imgui.ImVec2(-1, 60))
|
||||
imgui.end()
|
||||
|
||||
self.perf_monitor.end_frame()
|
||||
|
||||
# ---- Modals / Popups
|
||||
with self._pending_dialog_lock:
|
||||
dlg = self._pending_dialog
|
||||
@@ -1247,22 +1605,35 @@ class App:
|
||||
if font_path and Path(font_path).exists():
|
||||
hello_imgui.load_font(font_path, font_size)
|
||||
|
||||
def _post_init(self):
|
||||
theme.apply_current()
|
||||
|
||||
def run(self):
|
||||
theme.load_from_config(self.config)
|
||||
|
||||
|
||||
self.runner_params = hello_imgui.RunnerParams()
|
||||
self.runner_params.app_window_params.window_title = "manual slop"
|
||||
self.runner_params.app_window_params.window_geometry.size = (1680, 1200)
|
||||
self.runner_params.imgui_window_params.enable_viewports = True
|
||||
self.runner_params.imgui_window_params.default_imgui_window_type = hello_imgui.DefaultImGuiWindowType.provide_full_screen_dock_space
|
||||
self.runner_params.ini_folder_type = hello_imgui.IniFolderType.current_folder
|
||||
self.runner_params.ini_filename = "manualslop_layout.ini"
|
||||
self.runner_params.callbacks.show_gui = self._gui_func
|
||||
self.runner_params.callbacks.load_additional_fonts = self._load_fonts
|
||||
self.runner_params.callbacks.post_init = self._post_init
|
||||
|
||||
self._fetch_models(self.current_provider)
|
||||
|
||||
|
||||
# Start API hooks server (if enabled)
|
||||
self.hook_server = api_hooks.HookServer(self)
|
||||
self.hook_server.start()
|
||||
|
||||
immapp.run(self.runner_params)
|
||||
|
||||
# On exit
|
||||
self.hook_server.stop()
|
||||
self.perf_monitor.stop()
|
||||
ai_client.cleanup() # Destroy active API caches to stop billing
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
|
||||
+18
-74
File diff suppressed because one or more lines are too long
@@ -0,0 +1,124 @@
|
||||
;;; !!! This configuration is handled by HelloImGui and stores several Ini Files, separated by markers like this:
|
||||
;;;<<<INI_NAME>>>;;;
|
||||
|
||||
;;;<<<ImGui_655921752_Default>>>;;;
|
||||
[Window][Debug##Default]
|
||||
Pos=60,60
|
||||
Size=400,400
|
||||
Collapsed=0
|
||||
|
||||
[Window][Projects]
|
||||
Pos=209,396
|
||||
Size=387,337
|
||||
Collapsed=0
|
||||
DockId=0x00000014,0
|
||||
|
||||
[Window][Files]
|
||||
Pos=0,0
|
||||
Size=207,1200
|
||||
Collapsed=0
|
||||
DockId=0x00000011,0
|
||||
|
||||
[Window][Screenshots]
|
||||
Pos=209,0
|
||||
Size=387,171
|
||||
Collapsed=0
|
||||
DockId=0x00000015,0
|
||||
|
||||
[Window][Discussion History]
|
||||
Pos=598,128
|
||||
Size=554,619
|
||||
Collapsed=0
|
||||
DockId=0x0000000E,0
|
||||
|
||||
[Window][Provider]
|
||||
Pos=209,913
|
||||
Size=387,287
|
||||
Collapsed=0
|
||||
DockId=0x0000000A,0
|
||||
|
||||
[Window][Message]
|
||||
Pos=598,749
|
||||
Size=554,451
|
||||
Collapsed=0
|
||||
DockId=0x0000000C,0
|
||||
|
||||
[Window][Response]
|
||||
Pos=209,735
|
||||
Size=387,176
|
||||
Collapsed=0
|
||||
DockId=0x00000010,0
|
||||
|
||||
[Window][Tool Calls]
|
||||
Pos=1154,733
|
||||
Size=526,144
|
||||
Collapsed=0
|
||||
DockId=0x00000008,0
|
||||
|
||||
[Window][Comms History]
|
||||
Pos=1154,879
|
||||
Size=526,321
|
||||
Collapsed=0
|
||||
DockId=0x00000006,0
|
||||
|
||||
[Window][System Prompts]
|
||||
Pos=1154,0
|
||||
Size=286,731
|
||||
Collapsed=0
|
||||
DockId=0x00000017,0
|
||||
|
||||
[Window][Theme]
|
||||
Pos=209,173
|
||||
Size=387,221
|
||||
Collapsed=0
|
||||
DockId=0x00000016,0
|
||||
|
||||
[Window][Text Viewer - Entry #7]
|
||||
Pos=379,324
|
||||
Size=900,700
|
||||
Collapsed=0
|
||||
|
||||
[Window][Diagnostics]
|
||||
Pos=1442,0
|
||||
Size=238,731
|
||||
Collapsed=0
|
||||
DockId=0x00000018,0
|
||||
|
||||
[Docking][Data]
|
||||
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=346,232 Size=1680,1200 Split=X
|
||||
DockNode ID=0x00000011 Parent=0xAFC85805 SizeRef=207,1200 Selected=0x0469CA7A
|
||||
DockNode ID=0x00000012 Parent=0xAFC85805 SizeRef=1559,1200 Split=X
|
||||
DockNode ID=0x00000003 Parent=0x00000012 SizeRef=943,1200 Split=X
|
||||
DockNode ID=0x00000001 Parent=0x00000003 SizeRef=387,1200 Split=Y Selected=0x8CA2375C
|
||||
DockNode ID=0x00000009 Parent=0x00000001 SizeRef=405,911 Split=Y Selected=0x8CA2375C
|
||||
DockNode ID=0x0000000F Parent=0x00000009 SizeRef=405,733 Split=Y Selected=0x8CA2375C
|
||||
DockNode ID=0x00000013 Parent=0x0000000F SizeRef=405,394 Split=Y Selected=0x8CA2375C
|
||||
DockNode ID=0x00000015 Parent=0x00000013 SizeRef=405,171 Selected=0xDF822E02
|
||||
DockNode ID=0x00000016 Parent=0x00000013 SizeRef=405,221 Selected=0x8CA2375C
|
||||
DockNode ID=0x00000014 Parent=0x0000000F SizeRef=405,337 Selected=0xDA22FEDA
|
||||
DockNode ID=0x00000010 Parent=0x00000009 SizeRef=405,176 Selected=0x0D5A5273
|
||||
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=405,287 Selected=0xA07B5F14
|
||||
DockNode ID=0x00000002 Parent=0x00000003 SizeRef=554,1200 Split=Y
|
||||
DockNode ID=0x0000000B Parent=0x00000002 SizeRef=1010,747 Split=Y
|
||||
DockNode ID=0x0000000D Parent=0x0000000B SizeRef=1010,126 CentralNode=1
|
||||
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1010,619 Selected=0x5D11106F
|
||||
DockNode ID=0x0000000C Parent=0x00000002 SizeRef=1010,451 Selected=0x66CFB56E
|
||||
DockNode ID=0x00000004 Parent=0x00000012 SizeRef=526,1200 Split=Y Selected=0xDD6419BC
|
||||
DockNode ID=0x00000005 Parent=0x00000004 SizeRef=261,877 Split=Y Selected=0xDD6419BC
|
||||
DockNode ID=0x00000007 Parent=0x00000005 SizeRef=261,731 Split=X Selected=0xDD6419BC
|
||||
DockNode ID=0x00000017 Parent=0x00000007 SizeRef=286,731 Selected=0xDD6419BC
|
||||
DockNode ID=0x00000018 Parent=0x00000007 SizeRef=238,731 Selected=0xB4CBF21A
|
||||
DockNode ID=0x00000008 Parent=0x00000005 SizeRef=261,144 Selected=0x1D56B311
|
||||
DockNode ID=0x00000006 Parent=0x00000004 SizeRef=261,321 Selected=0x8B4EBFA6
|
||||
|
||||
;;;<<<Layout_655921752_Default>>>;;;
|
||||
;;;<<<HelloImGui_Misc>>>;;;
|
||||
[Layout]
|
||||
Name=Default
|
||||
[StatusBar]
|
||||
Show=false
|
||||
ShowFps=true
|
||||
[Theme]
|
||||
Name=DarculaDarker
|
||||
;;;<<<SplitIds>>>;;;
|
||||
{"gImGuiSplitIDs":{"MainDockSpace":2949142533}}
|
||||
+18
-5
@@ -65,7 +65,10 @@ def configure(file_items: list[dict], extra_base_dirs: list[str] | None = None):
|
||||
for item in file_items:
|
||||
p = item.get("path")
|
||||
if p is not None:
|
||||
rp = Path(p).resolve()
|
||||
try:
|
||||
rp = Path(p).resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
rp = Path(p).resolve()
|
||||
_allowed_paths.add(rp)
|
||||
_base_dirs.add(rp.parent)
|
||||
|
||||
@@ -82,8 +85,13 @@ def _is_allowed(path: Path) -> bool:
|
||||
A path is allowed if:
|
||||
- it is explicitly in _allowed_paths, OR
|
||||
- it is contained within (or equal to) one of the _base_dirs
|
||||
All paths are resolved (follows symlinks) before comparison to prevent
|
||||
symlink-based path traversal.
|
||||
"""
|
||||
rp = path.resolve()
|
||||
try:
|
||||
rp = path.resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
rp = path.resolve()
|
||||
if rp in _allowed_paths:
|
||||
return True
|
||||
for bd in _base_dirs:
|
||||
@@ -104,7 +112,10 @@ def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]:
|
||||
p = Path(raw_path)
|
||||
if not p.is_absolute() and _primary_base_dir:
|
||||
p = _primary_base_dir / p
|
||||
p = p.resolve()
|
||||
try:
|
||||
p = p.resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
p = p.resolve()
|
||||
except Exception as e:
|
||||
return None, f"ERROR: invalid path '{raw_path}': {e}"
|
||||
if not _is_allowed(p):
|
||||
@@ -269,7 +280,8 @@ def web_search(query: str) -> str:
|
||||
url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query)
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||
try:
|
||||
html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore')
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
html = resp.read().decode('utf-8', errors='ignore')
|
||||
parser = _DDGParser()
|
||||
parser.feed(html)
|
||||
if not parser.results:
|
||||
@@ -292,7 +304,8 @@ def fetch_url(url: str) -> str:
|
||||
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||
try:
|
||||
html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore')
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
html = resp.read().decode('utf-8', errors='ignore')
|
||||
parser = _TextExtractor()
|
||||
parser.feed(html)
|
||||
full_text = " ".join(parser.text)
|
||||
|
||||
@@ -26,6 +26,7 @@ scripts/generated/
|
||||
Where <ts> = YYYYMMDD_HHMMSS of when this session was started.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import datetime
|
||||
import json
|
||||
import threading
|
||||
@@ -71,6 +72,8 @@ def open_session():
|
||||
_tool_fh.write(f"# Tool-call log — session {_ts}\n\n")
|
||||
_tool_fh.flush()
|
||||
|
||||
atexit.register(close_session)
|
||||
|
||||
|
||||
def close_session():
|
||||
"""Flush and close both log files. Called on clean exit (optional)."""
|
||||
|
||||
@@ -31,8 +31,8 @@ def app_instance():
|
||||
dpg.destroy_context()
|
||||
|
||||
def test_diagnostics_panel_initialization(app_instance):
|
||||
assert "Operations Hub" in app_instance.window_info
|
||||
assert app_instance.window_info["Operations Hub"] == "win_operations_hub"
|
||||
assert "Diagnostics" in app_instance.window_info
|
||||
assert app_instance.window_info["Diagnostics"] == "win_diagnostics"
|
||||
assert "frame_time" in app_instance.perf_history
|
||||
assert len(app_instance.perf_history["frame_time"]) == 100
|
||||
|
||||
|
||||
+10
-4
@@ -5,7 +5,7 @@ Theming support for manual_slop GUI — imgui-bundle port.
|
||||
Replaces theme.py (DearPyGui-specific) with imgui-bundle equivalents.
|
||||
Palettes are applied via imgui.get_style().set_color_() calls.
|
||||
Font loading uses hello_imgui.load_font().
|
||||
Scale uses imgui.get_io().font_global_scale.
|
||||
Scale uses imgui.get_style().font_scale_main.
|
||||
"""
|
||||
|
||||
from imgui_bundle import imgui, hello_imgui
|
||||
@@ -238,11 +238,11 @@ def apply(palette_name: str):
|
||||
|
||||
|
||||
def set_scale(factor: float):
|
||||
"""Set the global font scale factor."""
|
||||
"""Set the global font/UI scale factor."""
|
||||
global _current_scale
|
||||
_current_scale = factor
|
||||
io = imgui.get_io()
|
||||
io.font_global_scale = factor
|
||||
style = imgui.get_style()
|
||||
style.font_scale_main = factor
|
||||
|
||||
|
||||
def save_to_config(config: dict):
|
||||
@@ -263,6 +263,12 @@ def load_from_config(config: dict):
|
||||
_current_font_size = float(t.get("font_size", 16.0))
|
||||
_current_scale = float(t.get("scale", 1.0))
|
||||
|
||||
# Don't apply here — imgui context may not exist yet.
|
||||
# Call apply_current() after imgui is initialised.
|
||||
|
||||
|
||||
def apply_current():
|
||||
"""Apply the loaded palette and scale. Call after imgui context exists."""
|
||||
apply(_current_palette)
|
||||
set_scale(_current_scale)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user