Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 24 additions & 51 deletions src/wingman/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Main Wingman application."""

import asyncio
import contextlib
import re
import time
import webbrowser
Expand Down Expand Up @@ -117,11 +118,10 @@ def compose(self) -> ComposeResult:
with Vertical(id="sidebar") as sidebar:
sidebar.border_title = "Sessions"
yield Tree("Chats", id="sessions")
with Vertical(id="main"):
with Horizontal(id="panels-container"):
panel = ChatPanel()
self.panels.append(panel)
yield panel
with Vertical(id="main"), Horizontal(id="panels-container"):
panel = ChatPanel()
self.panels.append(panel)
yield panel
yield Static(id="status")

def on_mount(self) -> None:
Expand Down Expand Up @@ -153,7 +153,7 @@ async def _init_dynamic_data(self) -> None:
def _check_background_processes(self) -> None:
"""Periodic check for completed background processes."""
completed = check_completed_processes()
for panel_id, bg_id, exit_code, command in completed:
for _panel_id, bg_id, exit_code, command in completed:
# Shorten command for display
cmd_short = command[:40] + "..." if len(command) > 40 else command
if exit_code == 0:
Expand Down Expand Up @@ -183,10 +183,7 @@ def _update_status(self) -> None:
img_text = f" │ [#7dcfff]{img_count} image{'s' if img_count != 1 else ''}[/]" if img_count else ""

# Context remaining indicator
if panel:
remaining = 1.0 - panel.context.usage_percent
else:
remaining = 1.0
remaining = 1.0 - panel.context.usage_percent if panel else 1.0
if remaining <= (1.0 - AUTO_COMPACT_THRESHOLD):
ctx_color = "#f7768e"
elif remaining <= 0.4:
Expand Down Expand Up @@ -391,16 +388,12 @@ def on_key(self, event) -> None:
self._update_status()
# Remove thinking spinners
for thinking in panel.query("Thinking"):
try:
with contextlib.suppress(Exception):
thinking.remove()
except Exception:
pass
# Remove pending tool approvals
for approval in panel.query("ToolApproval"):
try:
with contextlib.suppress(Exception):
approval.remove()
except Exception:
pass
self.notify("Generation cancelled", severity="warning", timeout=2)
event.stop()
event.prevent_default()
Expand Down Expand Up @@ -557,7 +550,7 @@ def on_submit(self, event: Input.Submitted) -> None:
panel.add_image_message("user", text, images_to_send)
else:
panel.add_message("user", text)

# Save session immediately after user message
save_session(panel.session_id, panel.messages)

Expand Down Expand Up @@ -625,7 +618,6 @@ async def _send_message(
if self.coding_mode:
kwargs["tools"] = create_tools(panel.working_dir, panel.panel_id, panel.session_id)


# Set session context for checkpoint tracking
set_current_session(panel.session_id)
clear_segments(panel.panel_id) # Clear segment tracking for new response
Expand Down Expand Up @@ -677,10 +669,9 @@ async def _send_message(
delta = chunk.choices[0].delta

# Tool call detected - finalize current text segment
if hasattr(delta, "tool_calls") and delta.tool_calls:
if streaming_widget is not None:
streaming_widget.mark_complete()
streaming_widget = None
if hasattr(delta, "tool_calls") and delta.tool_calls and streaming_widget is not None:
streaming_widget.mark_complete()
streaming_widget = None

# Stream text content
if hasattr(delta, "content") and delta.content:
Expand All @@ -705,10 +696,8 @@ async def _send_message(
streaming_widget.mark_complete()
self._update_status()

try:
with contextlib.suppress(Exception):
thinking.remove()
except Exception:
pass

segments = get_segments(panel.panel_id)
if segments:
Expand All @@ -721,15 +710,11 @@ async def _send_message(
await self._check_auto_compact(panel)

except asyncio.TimeoutError:
try:
with contextlib.suppress(Exception):
thinking.remove()
except Exception:
pass
for sw in self.query(StreamingText):
try:
with contextlib.suppress(Exception):
sw.remove()
except Exception:
pass
# Save any partial segments before showing error
segments = get_segments(panel.panel_id)
if segments:
Expand All @@ -743,16 +728,12 @@ async def _send_message(

except Exception as e:
# Clean up thinking spinner
try:
with contextlib.suppress(Exception):
thinking.remove()
except Exception:
pass
# Clean up any streaming widgets
for sw in self.query(StreamingText):
try:
with contextlib.suppress(Exception):
sw.remove()
except Exception:
pass
# Save any partial segments before handling error
segments = get_segments(panel.panel_id)
if segments:
Expand Down Expand Up @@ -813,10 +794,8 @@ async def request_tool_approval(self, tool_name: str, command: str, panel_id: st
return ("cancelled", "")
await asyncio.sleep(0.05)
result = widget.result
try:
with contextlib.suppress(Exception):
widget.remove()
except Exception:
pass # Already removed
# Restore thinking spinner
if thinking:
thinking.display = True
Expand Down Expand Up @@ -858,16 +837,12 @@ def action_stop_generation(self) -> None:
self._update_status()
# Remove thinking spinners
for thinking in panel.query("Thinking"):
try:
with contextlib.suppress(Exception):
thinking.remove()
except Exception:
pass
# Remove pending tool approvals
for approval in panel.query("ToolApproval"):
try:
with contextlib.suppress(Exception):
approval.remove()
except Exception:
pass
self.notify("Generation cancelled", severity="warning", timeout=2)
elif panel:
# Clear the input if not generating
Expand Down Expand Up @@ -1087,10 +1062,7 @@ def _cmd_delete(self, arg: str) -> None:
try:
tree = self.query_one("#sessions", Tree)
if tree.cursor_node and tree.cursor_node != tree.root:
if tree.cursor_node.data:
session_id = str(tree.cursor_node.data)
else:
session_id = str(tree.cursor_node.label)
session_id = str(tree.cursor_node.data) if tree.cursor_node.data else str(tree.cursor_node.label)
except Exception:
pass
if not session_id:
Expand Down Expand Up @@ -1187,7 +1159,7 @@ def _cmd_history(self, arg: str) -> None:
lines = ["[bold #7aa2f7]Checkpoints[/] (use /rollback <id> to restore)\n"]
for cp in checkpoints:
ts = time.strftime("%H:%M:%S", time.localtime(cp.timestamp))
files = ", ".join(Path(f).name for f in cp.files.keys())
files = ", ".join(Path(f).name for f in cp.files)
lines.append(f" [#9ece6a]{cp.id}[/] [{ts}] {cp.description}")
lines.append(f" [dim]{files}[/]")
self._show_info("\n".join(lines))
Expand Down Expand Up @@ -1604,6 +1576,7 @@ def main():
# Load environment variables from .env file
try:
from dotenv import load_dotenv

load_dotenv()
except ImportError:
pass
Expand Down
12 changes: 4 additions & 8 deletions src/wingman/command_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Callable

from .config import COMMANDS, COMMAND_OPTIONS
from .config import COMMAND_OPTIONS, COMMANDS


@dataclass(frozen=True)
Expand Down Expand Up @@ -147,11 +147,7 @@ def get_hint_candidates(
if context.active_index == 0:
search = context.active.text[1:] if context.active.text.startswith("/") else context.active.text
search_lower = search.lower()
matches = [
cmd
for cmd, desc in COMMANDS
if search_lower in cmd.lower() or search_lower in desc.lower()
]
matches = [cmd for cmd, desc in COMMANDS if search_lower in cmd.lower() or search_lower in desc.lower()]
if len(matches) == 1 and matches[0].lstrip("/") == search:
return []
return matches
Expand Down Expand Up @@ -298,7 +294,7 @@ def longest_common_prefix(values: list[str]) -> str:
def apply_completion(context: CompletionContext, replacement: str, add_space: bool) -> CompletionResult:
"""Apply a replacement to the active token using the completion context."""
text = f"/{replacement}" if context.include_slash else replacement
new_value = f"{context.value[:context.replace_start]}{text}{context.value[context.replace_end:]}"
new_value = f"{context.value[: context.replace_start]}{text}{context.value[context.replace_end :]}"
cursor_position = context.replace_start + len(text)
if add_space and cursor_position == len(new_value) and not new_value.endswith(" "):
new_value = f"{new_value} "
Expand Down
9 changes: 4 additions & 5 deletions src/wingman/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Configuration and constants."""

import contextlib
from importlib.metadata import version
from pathlib import Path

Expand Down Expand Up @@ -139,10 +140,8 @@ def save_api_key(api_key: str) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config = {}
if CONFIG_FILE.exists():
try:
with contextlib.suppress(Exception):
config = oj.loads(CONFIG_FILE.read_text())
except Exception:
pass
config["api_key"] = api_key
CONFIG_FILE.write_text(oj.dumps(config, indent=2))

Expand Down Expand Up @@ -172,7 +171,7 @@ def load_instructions(working_dir: Path | None = None) -> str:
if content.strip():
global_content = content.strip()
break
except (OSError, IOError):
except OSError:
continue

# Local instructions ({working_dir}/AGENTS.md or WINGMAN.md)
Expand All @@ -185,7 +184,7 @@ def load_instructions(working_dir: Path | None = None) -> str:
if content.strip():
local_content = content.strip()
break
except (OSError, IOError):
except OSError:
continue

# Combine with hierarchy framing
Expand Down
3 changes: 1 addition & 2 deletions src/wingman/headless.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Headless mode for Wingman - runs without TUI for scripting and benchmarks."""

import asyncio
import sys
from pathlib import Path

Expand Down Expand Up @@ -64,7 +63,7 @@ async def run_headless(

# Filter tools if allowed_tools specified
if allowed_tools:
allowed_set = set(t.lower() for t in allowed_tools)
allowed_set = {t.lower() for t in allowed_tools}
tools = [t for t in tools if t.__name__.lower() in allowed_set]

kwargs = {
Expand Down
3 changes: 1 addition & 2 deletions src/wingman/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ def is_image_path(text: str) -> Path | None:
# (check before normalization for URL-encoded extensions like .png)
text_lower = text.lower()
has_image_ext = any(
text_lower.endswith(ext) or f"{ext}%" in text_lower or ext in text_lower
for ext in IMAGE_EXTENSIONS
text_lower.endswith(ext) or f"{ext}%" in text_lower or ext in text_lower for ext in IMAGE_EXTENSIONS
)
if not has_image_ext:
return None
Expand Down
2 changes: 0 additions & 2 deletions src/wingman/sessions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Session storage and persistence."""

from pathlib import Path

from .config import SESSIONS_DIR
from .lib import oj

Expand Down
Loading