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:
5
.changeset/fn-6491-tui-agents-start-key.md
Normal file
5
.changeset/fn-6491-tui-agents-start-key.md
Normal 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.
|
||||||
@@ -45,6 +45,7 @@ function makeInteractiveData(opts: {
|
|||||||
settings?: SettingsValues;
|
settings?: SettingsValues;
|
||||||
models?: ModelItem[];
|
models?: ModelItem[];
|
||||||
taskDetail?: TaskDetailData | null;
|
taskDetail?: TaskDetailData | null;
|
||||||
|
updateAgentState?: (id: string, state: string) => Promise<void>;
|
||||||
remote?: Partial<{
|
remote?: Partial<{
|
||||||
getSettings: () => Promise<{ activeProvider: "tailscale" | "cloudflare" | null; tailscaleEnabled: boolean; cloudflareEnabled: boolean; shortLivedEnabled: boolean; shortLivedTtlMs: number }>;
|
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 }>;
|
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,
|
}) as TaskItem,
|
||||||
listAgents: async () => agents,
|
listAgents: async () => agents,
|
||||||
getAgentDetail: async (_id: string) => detail,
|
getAgentDetail: async (_id: string) => detail,
|
||||||
updateAgentState: async (_id: string, _state: string) => {},
|
updateAgentState: opts.updateAgentState ?? (async (_id: string, _state: string) => {}),
|
||||||
deleteAgent: async (_id: string) => {},
|
deleteAgent: async (_id: string) => {},
|
||||||
getSettings: async () => settings,
|
getSettings: async () => settings,
|
||||||
updateSettings: async (_partial: Partial<SettingsValues>) => {},
|
updateSettings: async (_partial: Partial<SettingsValues>) => {},
|
||||||
@@ -408,6 +409,105 @@ describe("Agents view", () => {
|
|||||||
|
|
||||||
unmount();
|
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", () => {
|
describe("Settings view", () => {
|
||||||
|
|||||||
@@ -4335,9 +4335,12 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'm' / 's' (alias) — switch to Main (status mode). Lowercase only;
|
/*
|
||||||
// capital S/M are reserved for vim-style "jump to end" semantics.
|
FNXC:DashboardTui 2026-06-16-17:40:
|
||||||
if (input === "m" || input === "s") {
|
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") {
|
if (state.mode === "interactive") {
|
||||||
controller.setMode("status");
|
controller.setMode("status");
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user