From 3158e9c6c71e02e686fb53ebc996258193f3bb60 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 16 Jun 2026 17:56:09 -0700 Subject: [PATCH] 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 --- .changeset/fn-6491-tui-agents-start-key.md | 5 + .../dashboard-tui/__tests__/app.test.tsx | 102 +++++++++++++++++- .../cli/src/commands/dashboard-tui/app.tsx | 9 +- 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-6491-tui-agents-start-key.md diff --git a/.changeset/fn-6491-tui-agents-start-key.md b/.changeset/fn-6491-tui-agents-start-key.md new file mode 100644 index 0000000000..7f8651c850 --- /dev/null +++ b/.changeset/fn-6491-tui-agents-start-key.md @@ -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. diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx index eca1bb773f..dab1a89bc7 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx +++ b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx @@ -45,6 +45,7 @@ function makeInteractiveData(opts: { settings?: SettingsValues; models?: ModelItem[]; taskDetail?: TaskDetailData | null; + updateAgentState?: (id: string, state: string) => Promise; 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) => {}, @@ -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", () => { diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index a8441ea749..2de93cba81 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -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;