Skip to content
Merged
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
4 changes: 2 additions & 2 deletions PolyPilot.Tests/SlashCommandAutocompleteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public void AutocompleteList_HasExpectedMinimumCommands()
var commands = GetAutocompleteCommands();
var expected = new[] { "/help", "/clear", "/compact", "/new", "/sessions",
"/rename", "/version", "/diff", "/status", "/mcp",
"/plugin", "/reflect", "/usage" };
"/plugin", "/fleet", "/usage" };

foreach (var cmd in expected)
{
Expand All @@ -144,7 +144,7 @@ public void ParameterlessCommands_MarkedForAutoSend()
}

// Commands with args should have hasArgs: true
var withArgs = new[] { "/new", "/rename", "/diff", "/reflect", "/mcp", "/plugin", "/prompt", "/status" };
var withArgs = new[] { "/new", "/rename", "/diff", "/fleet", "/mcp", "/plugin", "/prompt", "/status", "/agent" };
foreach (var cmd in withArgs)
{
var pattern = $"cmd: '{cmd}',";
Expand Down
15 changes: 5 additions & 10 deletions PolyPilot/Components/ExpandedSessionView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,6 @@
<button class="mode-btn @(InputMode == "plan" ? "active" : "")" @onclick='() => OnSetInputMode.InvokeAsync("plan")' title="Plan mode β€” create a plan before implementing">Plan</button>
<button class="mode-btn @(InputMode == "autopilot" ? "active" : "")" @onclick='() => OnSetInputMode.InvokeAsync("autopilot")' title="Autopilot mode β€” auto-approve tool calls">Autopilot</button>
</div>
@if (Session.ReflectionCycle is null || !Session.ReflectionCycle.IsActive)
{
<button class="mode-btn" style="margin-left:8px;opacity:0.7" title="Start a reflection cycle" @onclick="InsertReflectCommand">Reflect</button>
}
<span class="status-sep">Β·</span>
<span class="log-label" style="font-size:var(--type-footnote)" data-trigger="log-@EscapeForJs(Session.SessionId ?? "")" title="View session event log" @onclick="ShowLogPopup">Log</span>
<span class="status-sep">Β·</span>
Expand All @@ -282,6 +278,11 @@
<span class="status-sep">Β·</span>
<span class="skills-trigger" data-trigger="agents" @onclick="ShowAgentsPopup" title="View available agents">@availableAgents.Count agents</span>
}
@if (!string.IsNullOrEmpty(Session.ActiveAgentDisplayName ?? Session.ActiveAgentName))
{
<span class="status-sep">Β·</span>
<span class="active-agent-badge" title="Active CLI agent">πŸ€– @(Session.ActiveAgentDisplayName ?? Session.ActiveAgentName)</span>
}
@if (availablePrompts != null)
{
<span class="status-sep">Β·</span>
Expand Down Expand Up @@ -1043,12 +1044,6 @@
");
}

private async Task InsertReflectCommand()
{
var inputId = EscapeForJs("input-" + Session.Name.Replace(" ", "-"));
await JS.InvokeVoidAsync("eval", $"var el = document.getElementById('{inputId}'); if(el){{ el.value = '/reflect '; el.focus(); }}");
}

private static string EscapeForJs(string s) =>
s.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\n", " ").Replace("\r", "")
.Replace("\u2028", "\\u2028").Replace("\u2029", "\\u2029");
Expand Down
8 changes: 8 additions & 0 deletions PolyPilot/Components/ExpandedSessionView.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,14 @@
cursor: pointer;
}

.active-agent-badge {
font-size: var(--type-footnote);
color: var(--text-secondary, #888);
background: var(--hover-bg, rgba(124,92,252,0.08));
border-radius: 4px;
padding: 0 0.3rem;
}

/* === Mobile responsive === */
@media (max-width: 640px) {
.status-extra { display: none; }
Expand Down
165 changes: 53 additions & 112 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -1804,9 +1804,10 @@
"- `/status` β€” Show git status\n" +
"- `/prompt` β€” List saved prompts (`/prompt use|save|edit|show|delete`)\n" +
"- `/usage` β€” Show token usage and quota for this session\n" +
"- `/agent [name]` β€” List or select a CLI agent\n" +
"- `/fleet <prompt>` β€” Start fleet mode (parallel subagent execution)\n" +
"- `/mcp` β€” List MCP servers (`/mcp enable|disable <name>`, `/mcp reload` to restart)\n" +
"- `/plugin` β€” List installed plugins (enable/disable with `/plugin enable|disable <name>`)\n" +
"- `/reflect <goal>` β€” Start a reflection cycle (`/reflect help` for details)\n" +
"- `!<command>` β€” Run a shell command"));
break;

Expand All @@ -1815,6 +1816,10 @@
session.History.Add(ChatMessage.SystemMessage("Chat history cleared."));
break;

case "agent":
await HandleAgentCommand(session, sessionName, arg);
break;

case "version":
var appVersion = AppInfo.Current.VersionString;
var buildVersion = AppInfo.Current.BuildString;
Expand Down Expand Up @@ -1927,8 +1932,8 @@
HandlePluginCommand(session, arg);
break;

case "reflect":
await HandleReflectCommand(session, sessionName, arg);
case "fleet":
await HandleFleetCommand(session, sessionName, arg);
break;

case "prompt":
Expand Down Expand Up @@ -2153,136 +2158,72 @@
}
}

private async Task HandleReflectCommand(AgentSessionInfo session, string sessionName, string arg)
private async Task HandleAgentCommand(AgentSessionInfo session, string sessionName, string arg)
{
var subParts = arg.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var sub = subParts.Length > 0 ? subParts[0].ToLowerInvariant() : "";

if (sub == "stop")
// /agent β€” list available agents
// /agent <name> β€” select agent
// /agent deselect β€” deselect current agent
if (string.IsNullOrWhiteSpace(arg))
{
CopilotService.StopReflectionCycle(sessionName);
session.History.Add(ChatMessage.SystemMessage("πŸ›‘ Reflection cycle stopped."));
return;
}
// List agents from the API and from local discovery
var apiAgents = await CopilotService.ListAgentsFromApiAsync(sessionName);
var localAgents = CopilotService.DiscoverAvailableAgents(session.WorkingDirectory);

if (sub == "pause")
{
if (session.ReflectionCycle is { IsActive: true, IsPaused: false })
{
session.ReflectionCycle.IsPaused = true;
session.History.Add(ChatMessage.SystemMessage("⏸️ Reflection paused. Use `/reflect resume` to continue."));
}
else
{
session.History.Add(ChatMessage.ErrorMessage("No active reflection cycle to pause."));
}
return;
}
var lines = new List<string> { "**Available agents:**" };

if (sub == "resume")
{
if (session.ReflectionCycle is { IsActive: true, IsPaused: true })
if (apiAgents.Count > 0)
{
session.ReflectionCycle.IsPaused = false;
session.ReflectionCycle.ResetStallDetection();
session.History.Add(ChatMessage.SystemMessage("▢️ Reflection resumed."));
// Re-queue a follow-up to continue the cycle
var followUp = session.ReflectionCycle.BuildFollowUpPrompt("");
if (session.IsProcessing)
{
session.MessageQueue.Add(followUp);
}
else
{
// Dispatch immediately if session is idle
_ = CopilotService.SendPromptAsync(sessionName, followUp, skipHistoryMessage: true);
}
lines.Add("*CLI agents:*");
foreach (var a in apiAgents)
lines.Add($"- `{a.Name}` β€” {a.Description}");
}
else
{
session.History.Add(ChatMessage.ErrorMessage("No paused reflection cycle to resume."));
}
return;
}

if (sub == "status")
{
if (session.ReflectionCycle is { IsActive: true } rc)
if (localAgents.Count > 0)
{
var status = rc.IsPaused ? "⏸️ Paused" : "πŸ”„ Running";
var evalInfo = !string.IsNullOrEmpty(rc.EvaluatorSessionName) ? "independent evaluator" : "self-evaluation";
var feedback = !string.IsNullOrEmpty(rc.EvaluatorFeedback) ? $"\n**Last feedback:** {rc.EvaluatorFeedback}" : "";
session.History.Add(ChatMessage.SystemMessage(
$"{status} β€” **{rc.Goal}**\n" +
$"Iteration {rc.CurrentIteration}/{rc.MaxIterations} Β· {evalInfo}{feedback}"));
lines.Add("*Local agents (from repo):*");
foreach (var a in localAgents)
lines.Add($"- `{a.Name}` β€” {a.Description}");
}

if (apiAgents.Count == 0 && localAgents.Count == 0)
lines.Add("No agents found. Try adding agent markdown files in `.github/agents/` or `.claude/agents/`.");
else
{
session.History.Add(ChatMessage.SystemMessage("No active reflection cycle."));
}
return;
}
lines.Add("\nUse `/agent <name>` to select an agent, or `/agent deselect` to deselect.");

if (string.IsNullOrWhiteSpace(arg) || sub == "help")
{
session.History.Add(ChatMessage.SystemMessage(
"πŸ”„ **Reflection Cycles** β€” Iterative goal-driven refinement\n\n" +
"**Usage:**\n" +
"```\n" +
"/reflect <goal> Start a cycle (default 5 iterations)\n" +
"/reflect <goal> --max N Set max iterations (default 5)\n" +
"/reflect stop Cancel active cycle\n" +
"/reflect pause Pause without cancelling\n" +
"/reflect resume Resume paused cycle\n" +
"/reflect status Show current cycle progress\n" +
"/reflect help Show this help\n" +
"```\n\n" +
"**How it works:**\n" +
"1. You set a goal β†’ the worker starts iterating\n" +
"2. After each response, an **independent evaluator** judges the result\n" +
"3. If the evaluator says FAIL, its feedback is sent back to the worker\n" +
"4. Cycle ends when: βœ… evaluator says PASS | ⚠️ stalled | ⏱️ max iterations\n\n" +
"**Examples:**\n" +
"```\n" +
"/reflect write a haiku about rain --max 4\n" +
"/reflect fix the login bug and add tests --max 8\n" +
"/reflect refactor this function for readability\n" +
"```\n\n" +
"**Tips:**\n" +
"- Send messages during a cycle to steer the worker\n" +
"- Click the πŸ”„ pill in the header to stop\n" +
"- The evaluator is strict early on, lenient on the final iteration"));
return;
session.History.Add(ChatMessage.SystemMessage(string.Join("\n", lines)));
}

// Parse --max N from the goal text (accept em-dash β€” which macOS auto-substitutes for --)
int maxIterations = 5;
var goal = arg;
var maxMatch = System.Text.RegularExpressions.Regex.Match(arg, @"(?:--|β€”|\u2014)max\s+(\d+)");
if (maxMatch.Success)
else if (string.Equals(arg, "deselect", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(maxMatch.Groups[1].Value, out var parsed) && parsed > 0)
maxIterations = parsed;
goal = arg.Remove(maxMatch.Index, maxMatch.Length).Trim();
var success = await CopilotService.DeselectAgentAsync(sessionName);
if (success)
session.History.Add(ChatMessage.SystemMessage("Agent deselected."));
else
session.History.Add(ChatMessage.ErrorMessage("Failed to deselect agent. Is the session connected?"));
}

if (string.IsNullOrWhiteSpace(goal))
else
{
session.History.Add(ChatMessage.ErrorMessage("Please provide a goal for the reflection cycle."));
return;
var success = await CopilotService.SelectAgentAsync(sessionName, arg);
if (success)
session.History.Add(ChatMessage.SystemMessage($"Agent **{arg}** selected."));
else
session.History.Add(ChatMessage.ErrorMessage($"Failed to select agent '{arg}'. Is the agent name correct?"));
}
}

await CopilotService.StartReflectionCycleAsync(sessionName, goal, maxIterations);
session.History.Add(ChatMessage.SystemMessage($"πŸ”„ Reflection cycle started β€” **{goal}** (max {maxIterations} iterations)"));
if (session.IsProcessing)
private async Task HandleFleetCommand(AgentSessionInfo session, string sessionName, string arg)
{
if (string.IsNullOrWhiteSpace(arg))
{
CopilotService.EnqueueMessage(sessionName, goal);
session.History.Add(ChatMessage.SystemMessage("⏳ Current turn is still running β€” queued reflection goal to run next."));
session.History.Add(ChatMessage.SystemMessage(
"**Usage:** `/fleet <prompt>`\n\nStarts fleet mode, which enables parallel subagent execution for the given prompt."));
return;
}

// Send the goal as the initial prompt to kick off the first iteration
_ = CopilotService.SendPromptAsync(sessionName, goal);
var started = await CopilotService.StartFleetAsync(sessionName, arg);
if (started)
session.History.Add(ChatMessage.SystemMessage($"πŸš€ Fleet started for: *{arg}*"));
else
session.History.Add(ChatMessage.ErrorMessage("Failed to start fleet mode. Ensure the session is connected and idle."));
}

private async Task HandlePromptCommand(string sessionName, AgentSessionInfo session, string arg)
Expand Down
11 changes: 11 additions & 0 deletions PolyPilot/Models/AgentSessionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,17 @@ public string? LastUserPrompt
/// </summary>
public bool IsOrchestratorWorker { get; set; }

/// <summary>
/// The name of the CLI subagent currently active in this session (e.g. "code-review"),
/// or null if no subagent is active. Updated by SubagentSelectedEvent / SubagentDeselectedEvent.
/// </summary>
public string? ActiveAgentName { get; set; }

/// <summary>
/// Display name of the active subagent (e.g. "Code Review"), or null if none.
/// </summary>
public string? ActiveAgentDisplayName { get; set; }

internal static readonly string[] QuestionPhrases =
[
"let me know", "which would you prefer", "would you like", "should i", "do you want",
Expand Down
Loading