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
18 changes: 13 additions & 5 deletions src/wingman/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from textual.widgets import Input, Static, Tree

from .checkpoints import get_checkpoint_manager, set_current_session
from .command_completion import get_hint_candidates
from .command_completion import get_hint_candidates_with_desc
from .config import (
APP_CREDIT,
APP_NAME,
Expand Down Expand Up @@ -491,13 +491,21 @@ def on_input_changed(self, event: Input.Changed) -> None:
cycle = getattr(event.input, "_completion_cycle", None)
if cycle and cycle.is_active_for(text, event.input.cursor_position):
return
matches = get_hint_candidates(text, event.input.cursor_position)
formatted = " ".join(f"[#7aa2f7]{cmd}[/]" for cmd in matches)
hint.update(formatted if formatted else "")
matches = get_hint_candidates_with_desc(text, event.input.cursor_position)
# Use the input's hint system for arrow key navigation
if hasattr(event.input, "set_hint_candidates"):
event.input.set_hint_candidates(matches)
else:
hint.update("")
elif panel.pending_images:
if hasattr(event.input, "_clear_hints"):
event.input._clear_hints()
hint.update("[dim]↑ to select images · backspace to remove[/]")
else:
hint.update("")
if hasattr(event.input, "_clear_hints"):
event.input._clear_hints()
else:
hint.update("")

@on(Input.Submitted, ".panel-prompt")
def on_submit(self, event: Input.Submitted) -> None:
Expand Down
35 changes: 35 additions & 0 deletions src/wingman/command_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,41 @@ def get_hint_candidates(
return []


def get_hint_candidates_with_desc(
value: str,
cursor_position: int | None = None,
) -> list[tuple[str, str]]:
"""Get command hint candidates with descriptions for the current input.

Args:
value: Current input value.
cursor_position: Optional cursor position, defaults to end of input.

Returns:
List of (command, description) tuples for display.
"""
if cursor_position is None:
cursor_position = len(value)
context = _parse_context(value, cursor_position)
if context is None:
return []

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.lstrip("/"), desc)
for cmd, desc in COMMANDS
if search_lower in cmd.lower() or search_lower in desc.lower()
]
if len(matches) == 1 and matches[0][0] == search:
return []
return matches

# For non-command hints (options, etc.), return empty descriptions
return []


@dataclass(frozen=True)
class _CompletionContext:
value: str
Expand Down
2 changes: 1 addition & 1 deletion src/wingman/ui/app.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ ChatPanel.active-panel {
.panel-input {
height: auto;
min-height: 3;
max-height: 8;
max-height: 20;
padding: 0 1 1 1;
}

Expand Down
109 changes: 109 additions & 0 deletions src/wingman/ui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class MultilineInput(Input):
"""Input that joins pasted multi-line text, clears on escape, and collapses long pastes."""

LONG_PASTE_THRESHOLD = 200
MAX_VISIBLE_HINTS = 10

BINDINGS = [
Binding("ctrl+a", "select_all", "Select All", show=False),
Expand All @@ -101,18 +102,126 @@ def __init__(self, *args, **kwargs):
self._pasted_content: str | None = None
self._paste_placeholder: str | None = None
self._completion_cycle: _CompletionCycle | None = None
self._hint_candidates: list[tuple[str, str]] = [] # (command, description)
self._hint_index: int = -1 # -1 means no selection

def _on_key(self, event) -> None:
# Handle arrow keys for hint navigation when showing command hints
if self._hint_candidates and event.key in ("up", "down"):
if event.key == "up":
if self._hint_index <= 0:
self._hint_index = len(self._hint_candidates) - 1
else:
self._hint_index -= 1
else: # down
if self._hint_index >= len(self._hint_candidates) - 1:
self._hint_index = 0
else:
self._hint_index += 1
self._update_hint_display()
event.stop()
event.prevent_default()
return

# Handle tab/enter to select highlighted hint
if self._hint_candidates and self._hint_index >= 0 and event.key in ("tab", "enter"):
selected_cmd, _ = self._hint_candidates[self._hint_index]
# Apply the selected command
self.value = f"/{selected_cmd} "
self.cursor_position = len(self.value)
self._clear_hints()
event.stop()
event.prevent_default()
return

if event.key == "tab" and self._handle_tab_completion():
event.stop()
event.prevent_default()
return
if event.key != "tab":
self._completion_cycle = None
# Clear hint selection on other keys (but hints will be repopulated by Changed event)
if event.key not in ("up", "down", "tab", "enter"):
self._hint_index = -1
# Let typing proceed normally - user can type after the placeholder
# Content will be expanded on submit via get_submit_value()
super()._on_key(event)

def set_hint_candidates(self, candidates: list[tuple[str, str]]) -> None:
"""Set the current hint candidates for arrow navigation.

Args:
candidates: List of (command, description) tuples.
"""
# Preserve index if candidates are the same (avoid resetting during navigation)
if candidates == self._hint_candidates:
return
self._hint_candidates = candidates
if not candidates:
self._hint_index = -1
else:
# Reset index only if candidates changed
self._hint_index = -1
self._update_hint_display()

def _clear_hints(self) -> None:
"""Clear hint state."""
self._hint_candidates = []
self._hint_index = -1
panel = self._get_panel()
if panel:
panel.get_hint().update("")

def _update_hint_display(self) -> None:
"""Update the hint display with current candidates and selection."""
panel = self._get_panel()
if not panel:
return
hint = panel.get_hint()
if not self._hint_candidates:
hint.update("")
return

total = len(self._hint_candidates)
max_visible = self.MAX_VISIBLE_HINTS

# Calculate viewport window that follows the selection
if total <= max_visible:
start = 0
end = total
else:
# Keep selection visible by adjusting window
if self._hint_index < 0:
start = 0
else:
# Center the selection in the viewport when possible
start = max(0, self._hint_index - max_visible // 2)
start = min(start, total - max_visible)
end = start + max_visible

parts = []

# Show "..." at top if there are items above
if start > 0:
parts.append(f" [dim]...{start} above[/]")

# Show visible items with command and description
for i in range(start, end):
cmd, desc = self._hint_candidates[i]
# Pad command to align descriptions
padded_cmd = cmd.ljust(12)
if i == self._hint_index:
parts.append(f" [bold #9ece6a]> {padded_cmd}[/] [dim]{desc}[/]")
else:
parts.append(f" [#7aa2f7]{padded_cmd}[/] [dim]{desc}[/]")

# Show "..." at bottom if there are more items
if end < total:
remaining = total - end
parts.append(f" [dim]... +{remaining} more[/]")

hint.update("\n".join(parts))

def _handle_tab_completion(self) -> bool:
if not self.value.lstrip().startswith("/"):
return False
Expand Down