FN-6491: reserve s for agent start in TUI

Keep the Agents view focused when starting the selected agent from the dashboard TUI.

- Treat `s` as the selected-agent start command inside the Agents interactive view.
- Preserve `m` as the universal Main/status shortcut and keep the `s` alias outside Agents.
- Add regression coverage for Agents, non-Agents, status, and empty Agents shortcut behavior.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-6491-tui-agents-start-key.md         |   5 +
 .../commands/dashboard-tui/__tests__/app.test.tsx  | 102 ++++++++++++++++++++-
 packages/cli/src/commands/dashboard-tui/app.tsx    |   9 +-
 3 files changed, 112 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-6491
Fusion-Task-Lineage: 82a9acab-6e4a-400c-80ee-54d051245b57
This commit is contained in:
gsxdsm
2026-06-16 17:56:09 -07:00
parent 5a5c955113
commit 3158e9c6c7
3 changed files with 112 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the dashboard TUI Agents view so pressing `s` starts the selected agent without also switching back to Main.

View File

@@ -45,6 +45,7 @@ function makeInteractiveData(opts: {
settings?: SettingsValues;
models?: ModelItem[];
taskDetail?: TaskDetailData | null;
updateAgentState?: (id: string, state: string) => Promise<void>;
remote?: Partial<{
getSettings: () => Promise<{ activeProvider: "tailscale" | "cloudflare" | null; tailscaleEnabled: boolean; cloudflareEnabled: boolean; shortLivedEnabled: boolean; shortLivedTtlMs: number }>;
getStatus: () => Promise<{ provider: "tailscale" | "cloudflare" | null; state: "stopped" | "starting" | "running" | "error"; url: string | null; lastError: string | null }>;
@@ -105,7 +106,7 @@ function makeInteractiveData(opts: {
}) as TaskItem,
listAgents: async () => agents,
getAgentDetail: async (_id: string) => detail,
updateAgentState: async (_id: string, _state: string) => {},
updateAgentState: opts.updateAgentState ?? (async (_id: string, _state: string) => {}),
deleteAgent: async (_id: string) => {},
getSettings: async () => settings,
updateSettings: async (_partial: Partial<SettingsValues>) => {},
@@ -408,6 +409,105 @@ describe("Agents view", () => {
unmount();
});
it("starts the selected agent with s without leaving the Agents view", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const updateAgentState = vi.fn(async (_id: string, _state: string) => {});
const agents: AgentItem[] = [
{ id: "a1", name: "worker-1", state: "idle", role: "executor" },
];
controller.setInteractiveData(makeInteractiveData({ agents, updateAgentState }));
controller.setMode("interactive");
controller.setInteractiveView("agents");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "worker-1");
stdin.write("s");
await vi.waitFor(() => expect(updateAgentState).toHaveBeenCalledWith("a1", "active"));
await waitForFrameUpdateAfterInput();
const snapshot = controller.getSnapshot();
expect(snapshot.mode).toBe("interactive");
expect(snapshot.interactiveView).toBe("agents");
unmount();
});
it("switches to Main with m from the Agents view", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const agents: AgentItem[] = [
{ id: "a1", name: "worker-1", state: "idle", role: "executor" },
];
controller.setInteractiveData(makeInteractiveData({ agents }));
controller.setMode("interactive");
controller.setInteractiveView("agents");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "worker-1");
stdin.write("m");
await waitForFrameUpdateAfterInput();
expect(controller.getSnapshot().mode).toBe("status");
unmount();
});
it("keeps the s-to-Main alias in non-Agents interactive views", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData({
projects: [{ id: "p1", name: "alpha", path: "/tmp/alpha" }],
tasks: [{ id: "t1", title: "first", description: "", column: "todo" }],
}));
controller.setMode("interactive");
controller.setInteractiveView("board");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "alpha");
stdin.write("s");
await waitForFrameUpdateAfterInput();
expect(controller.getSnapshot().mode).toBe("status");
unmount();
});
it("keeps s as a no-op when already in status mode", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setMode("status");
controller.setInteractiveView("agents");
const { stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("s");
await waitForFrameUpdateAfterInput();
expect(controller.getSnapshot().mode).toBe("status");
unmount();
});
it("treats s as a no-op in an empty Agents view", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const updateAgentState = vi.fn(async (_id: string, _state: string) => {});
controller.setInteractiveData(makeInteractiveData({ agents: [], updateAgentState }));
controller.setMode("interactive");
controller.setInteractiveView("agents");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "Agent Detail");
stdin.write("s");
await waitForFrameUpdateAfterInput();
const snapshot = controller.getSnapshot();
expect(snapshot.mode).toBe("interactive");
expect(snapshot.interactiveView).toBe("agents");
expect(updateAgentState).not.toHaveBeenCalled();
unmount();
});
});
describe("Settings view", () => {

View File

@@ -4335,9 +4335,12 @@ export function DashboardApp({ controller }: DashboardAppProps) {
return;
}
// 'm' / 's' (alias) — switch to Main (status mode). Lowercase only;
// capital S/M are reserved for vim-style "jump to end" semantics.
if (input === "m" || input === "s") {
/*
FNXC:DashboardTui 2026-06-16-17:40:
The global `s` shortcut remains a Main/status alias everywhere except the Agents interactive view, where `s` is reserved for starting the selected agent. Keep `m` as the universal Main switch so Agents users can start an agent without being bounced out of the view.
*/
const agentsStartKeyOwnsInput = state.mode === "interactive" && state.interactiveView === "agents";
if (input === "m" || (input === "s" && !agentsStartKeyOwnsInput)) {
if (state.mode === "interactive") {
controller.setMode("status");
return;