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
21 changes: 21 additions & 0 deletions code_puppy/agents/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
on_agent_run_end,
on_agent_run_result,
on_agent_run_start,
on_user_prompt_submit,
)
from code_puppy.config import (
get_enable_streaming,
Expand Down Expand Up @@ -270,6 +271,26 @@ async def run_with_mcp(
prompt = _sanitize_prompt(prompt)
group_id = str(uuid.uuid4())

# Fire UserPromptSubmit hook — callbacks may rewrite or augment the prompt
# before it reaches the model. Hooks returning a plain string replace the
# prompt entirely; ``{"inject_context": "..."}`` prepends to it.
try:
hook_results = await on_user_prompt_submit(
prompt, agent_name=agent.name, session_id=group_id
)
for hook_result in hook_results:
if hook_result is None:
continue
if isinstance(hook_result, str) and hook_result.strip():
prompt = hook_result
elif isinstance(hook_result, dict):
injected = hook_result.get("inject_context")
if injected:
prompt = f"{injected}\n\n{prompt}"
except Exception:
# Hook failures never block user prompts.
pass

if agent._code_generation_agent is None:
build_pydantic_agent(agent)
pydantic_agent = agent._code_generation_agent
Expand Down
77 changes: 77 additions & 0 deletions code_puppy/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
"message_history_processor_start",
"message_history_processor_end",
"on_message",
"user_prompt_submit",
"pre_compact",
"session_end",
"notification",
]
CallbackFunc = Callable[..., Any]

Expand Down Expand Up @@ -78,6 +82,10 @@
"message_history_processor_start": [],
"message_history_processor_end": [],
"on_message": [],
"user_prompt_submit": [],
"pre_compact": [],
"session_end": [],
"notification": [],
}

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -788,3 +796,72 @@ async def on_message(message_id: str, message: Any) -> List[Any]:
List of results from registered callbacks.
"""
return await _trigger_callbacks("on_message", message_id, message)


async def on_user_prompt_submit(prompt: str, **kwargs: Any) -> List[Any]:
"""Trigger callbacks when the user submits a prompt to an agent.

Fires before the prompt is sent to the model. Callbacks may return:
- A string: replaces the prompt text entirely
- A dict with ``{"inject_context": "..."}``: the text is prepended to the prompt
- A dict with ``{"blocked": True, "reason": "..."}``: signals the caller to veto
- ``None``: no change

Args:
prompt: The raw user prompt string.
**kwargs: Reserved for future context (session_id, agent_name, etc).

Returns:
List of results from registered callbacks (in registration order).
"""
return await _trigger_callbacks("user_prompt_submit", prompt, **kwargs)


async def on_pre_compact(
agent_name: str,
session_id: Optional[str],
message_history: List[Any],
incoming_messages: List[Any],
) -> List[Any]:
"""Trigger callbacks immediately before message-history compaction.

This is a Claude-Code-compatible mirror of ``message_history_processor_start``
specifically for the ``PreCompact`` hook event. Stays observation-only;
callbacks can log/annotate but cannot currently veto compaction.

Args:
agent_name: Name of the agent whose history is being compacted.
session_id: Optional session identifier.
message_history: Current message history (pre-compaction).
incoming_messages: New messages being added in this pass.

Returns:
List of results from registered callbacks.
"""
return await _trigger_callbacks(
"pre_compact", agent_name, session_id, message_history, incoming_messages
)


async def on_session_end() -> List[Any]:
"""Trigger callbacks when a session ends (distinct from app shutdown).

``shutdown`` fires once when the CLI process exits. ``session_end`` is the
Claude-Code-compatible twin and currently fires from the same site but is
intentionally a separate phase so plugins can opt into either semantic.
"""
return await _trigger_callbacks("session_end")


async def on_notification(notification_type: str, payload: Any = None) -> List[Any]:
"""Trigger callbacks when a user-facing notification is emitted.

Args:
notification_type: A short string tag (e.g. ``"warning"``, ``"info"``,
``"error"``). Free-form; plugins choose their own taxonomy.
payload: The notification body (any JSON-ish object or string).

Returns:
List of results from registered callbacks.
"""
return await _trigger_callbacks("notification", notification_type, payload)
135 changes: 113 additions & 22 deletions code_puppy/command_line/model_picker_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
get_total_pages,
)
from code_puppy.config import get_global_model_name
from code_puppy.item_visibility import (
load_hidden_models,
prune_stale_entries,
)
from code_puppy.list_filtering import query_matches_text
from code_puppy.model_switching import set_model_and_reload_agent

Expand Down Expand Up @@ -211,34 +215,46 @@ def __init__(self, model_names: Optional[list[str]] = None):
self.page_size = MODEL_PICKER_PAGE_SIZE
self.result: Optional[str] = None

if self.current_model in self.visible_model_names:
self.selected_index = self.visible_model_names.index(self.current_model)
# Load visibility state and prune stale entries
self._hidden_models: set[str] = load_hidden_models()
prune_stale_entries(self.model_names)
self._hidden_models = load_hidden_models() # reload after pruning
self.show_all: bool = False # session-level; resets every picker open

# Set initial selection to current model (in display list)
if self.current_model in self.display_model_names:
self.selected_index = self.display_model_names.index(self.current_model)
self.page = get_page_for_index(self.selected_index, self.page_size)

@property
def total_pages(self) -> int:
return get_total_pages(len(self.visible_model_names), self.page_size)
return get_total_pages(len(self.display_model_names), self.page_size)

@property
def page_start(self) -> int:
start, _ = get_page_bounds(
self.page, len(self.visible_model_names), self.page_size
self.page, len(self.display_model_names), self.page_size
)
return start

@property
def page_end(self) -> int:
_, end = get_page_bounds(
self.page, len(self.visible_model_names), self.page_size
self.page, len(self.display_model_names), self.page_size
)
return end

@property
def models_on_page(self) -> list[str]:
return self.visible_model_names[self.page_start : self.page_end]
return self.display_model_names[self.page_start : self.page_end]

@property
def visible_model_names(self) -> list[str]:
"""Models matching the current filter text (includes hidden models).

This represents the raw filter-matched list — visibility is applied
on top by display_model_names.
"""
if not self.filter_text:
return self.model_names
return [
Expand All @@ -247,23 +263,39 @@ def visible_model_names(self) -> list[str]:
if query_matches_text(self.filter_text, model_name)
]

@property
def display_model_names(self) -> list[str]:
"""Models shown in the list, respecting visibility settings.

Decision matrix:
- show_all=True → All models (hidden ones dimmed in render)
- show_all=False, filter_text typed → Filter matches (hidden dimmed)
- show_all=False, filter_text empty → Non-hidden models only
"""
base = self.visible_model_names
if self.show_all:
return base
if self.filter_text:
return base
return [m for m in base if m not in self._hidden_models]

def _get_selected_model_name(self) -> Optional[str]:
if 0 <= self.selected_index < len(self.visible_model_names):
return self.visible_model_names[self.selected_index]
if 0 <= self.selected_index < len(self.display_model_names):
return self.display_model_names[self.selected_index]
return None

def _ensure_selection_visible(self) -> None:
self.page = ensure_visible_page(
self.selected_index,
self.page,
len(self.visible_model_names),
len(self.display_model_names),
self.page_size,
)

def _set_filter_text(self, value: str) -> None:
selected_model = self._get_selected_model_name()
self.filter_text = value
visible_models = self.visible_model_names
visible_models = self.display_model_names
if not visible_models:
self.selected_index = 0
self.page = 0
Expand Down Expand Up @@ -298,7 +330,7 @@ def _move_up(self) -> None:
self._ensure_selection_visible()

def _move_down(self) -> None:
if self.selected_index < len(self.visible_model_names) - 1:
if self.selected_index < len(self.display_model_names) - 1:
self.selected_index += 1
self._ensure_selection_visible()

Expand All @@ -322,13 +354,26 @@ def _render(self):
)
lines.append(("", "\n"))

if not self.visible_model_names:
empty_message = (
"No models match the current filter."
if self.filter_text
else "No models available."
)
lines.append(("fg:ansiyellow", f"\n {empty_message}\n"))
if not self.display_model_names:
visible_count = len(self.visible_model_names)
if visible_count > 0:
# Some models match the filter but all are hidden
lines.append(("fg:ansiyellow", "\n All filtered models are hidden.\n"))
lines.append(("fg:ansibrightblack", " Press "))
lines.append(("", "Tab"))
lines.append(("fg:ansibrightblack", " to show all models.\n"))
elif self.filter_text:
# No models match the filter at all
lines.append(
("fg:ansiyellow", "\n No models match the current filter.\n")
)
elif self._hidden_models:
lines.append(("fg:ansiyellow", "\n All models are hidden.\n"))
lines.append(("fg:ansibrightblack", " Press "))
lines.append(("", "Tab"))
lines.append(("fg:ansibrightblack", " to show all models.\n"))
else:
lines.append(("fg:ansiyellow", "\n No models available.\n"))
lines.append(("fg:ansibrightblack", " Type "))
lines.append(("", "Adjust filter\n"))
lines.append(("fg:ansibrightblack", " Backspace "))
Expand All @@ -337,21 +382,35 @@ def _render(self):
lines.append(("fg:ansibrightblack", " Ctrl+U "))
lines.append(("", "Clear filter\n"))
lines.append(("fg:ansiyellow", " Esc "))
lines.append(("", "Exit\n"))
lines.append(("", "Cancel\n"))
return lines

lines.append(("fg:ansibrightblack", f"\n Current: {self.current_model}\n\n"))

for offset, model_name in enumerate(self.models_on_page):
absolute_index = self.page_start + offset
is_selected = absolute_index == self.selected_index
is_current = model_name == self.current_model
is_hidden = model_name in self._hidden_models

prefix = " › " if is_selected else " "
style = "fg:ansiwhite bold" if is_selected else "fg:ansibrightblack"
lines.append((style, f"{prefix}{model_name}"))
if is_hidden:
style = (
"fg:ansibrightblack dim"
if is_selected
else "fg:ansibrightblack dim"
)
arrow_style = (
"fg:ansiwhite bold" if is_selected else "fg:ansibrightblack"
)
else:
style = "fg:ansiwhite bold" if is_selected else "fg:ansibrightblack"
arrow_style = style
lines.append((arrow_style, prefix))
lines.append((style, model_name))
if is_current:
lines.append(("fg:ansigreen", " (active)"))
if is_hidden:
lines.append(("fg:ansibrightblack dim", " [hidden]"))
lines.append(("", "\n"))

lines.append(("", "\n"))
Expand All @@ -366,6 +425,10 @@ def _render(self):
lines.append(("", "Delete filter char\n"))
lines.append(("fg:ansibrightblack", " Ctrl+U "))
lines.append(("", "Clear filter\n"))
lines.append(("fg:ansibrightblack", " Space "))
lines.append(("", "Toggle visibility\n"))
lines.append(("fg:ansibrightblack", " Tab "))
lines.append(("", "Show/hide all\n"))
lines.append(("fg:ansigreen", " Enter "))
lines.append(("", "Select model\n"))
lines.append(("fg:ansiyellow", " Esc "))
Expand Down Expand Up @@ -443,6 +506,34 @@ def _(event):
self.result = None
event.app.exit()

@kb.add("space")
def _(event):
"""Toggle visibility of the currently highlighted model."""
from code_puppy.item_visibility import (
load_hidden_models,
toggle_model_hidden,
)

model = self._get_selected_model_name()
if model is None:
return
# Silently ignore if trying to hide the currently active model
if model == self.current_model:
return
toggle_model_hidden(model)
self._hidden_models = load_hidden_models()
self._set_filter_text(self.filter_text)
refresh()
event.app.invalidate()

@kb.add("tab")
def _(event):
"""Toggle show-all mode (session-level, not persisted)."""
self.show_all = not self.show_all
self._set_filter_text(self.filter_text)
refresh()
event.app.invalidate()

app = Application(
layout=Layout(Window(content=control, wrap_lines=True)),
key_bindings=kb,
Expand Down
Loading
Loading