diff --git a/.changeset/fn-7346-tui-settings-arrows.md b/.changeset/fn-7346-tui-settings-arrows.md new file mode 100644 index 0000000000..2911155a76 --- /dev/null +++ b/.changeset/fn-7346-tui-settings-arrows.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix arrow-key editing for Settings in the terminal dashboard. +category: fix +dev: Settings detail-pane arrows now edit enum values instead of switching panes. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3c48ce49fb..b1f20b07f8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -270,6 +270,11 @@ Remote actions support: > ⚠️ Remote URL/QR payloads include tokenized query data. Treat them like credentials and avoid sharing them in screenshots/chat/logs. Prefer short-lived links for ad-hoc phone login. +Settings pane navigation and editing: +- `Tab` switches focus between the settings list and the detail/edit pane. +- In the settings list, `↑`/`↓` or `k`/`j` moves the selected setting. +- In the detail/edit pane, `←`/`→` or `h`/`l` cycles enum values such as **Remote Provider**; `Space` toggles booleans; `+`/`-` adjusts numbers. + Remote action keys in Settings detail pane: - `C` activate selected provider - `V` start tunnel 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 dab1a89bc7..8e9f1fd012 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx +++ b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx @@ -46,6 +46,7 @@ function makeInteractiveData(opts: { models?: ModelItem[]; taskDetail?: TaskDetailData | null; updateAgentState?: (id: string, state: string) => Promise; + updateSettings?: (partial: Partial) => 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 }>; @@ -63,7 +64,7 @@ function makeInteractiveData(opts: { const agents = opts.agents ?? []; const detail = opts.detail ?? null; const taskDetail = opts.taskDetail ?? null; - const settings: SettingsValues = opts.settings ?? { + let settings: SettingsValues = opts.settings ?? { maxConcurrent: 1, maxWorktrees: 2, autoMerge: false, @@ -109,7 +110,9 @@ function makeInteractiveData(opts: { updateAgentState: opts.updateAgentState ?? (async (_id: string, _state: string) => {}), deleteAgent: async (_id: string) => {}, getSettings: async () => settings, - updateSettings: async (_partial: Partial) => {}, + updateSettings: opts.updateSettings ?? (async (partial: Partial) => { + settings = { ...settings, ...partial }; + }), listModels: () => models, remote, git: { @@ -188,11 +191,20 @@ async function waitForFrameUpdateAfterInput() { } async function focusSettingsDetailPane(stdin: { write: (chunk: string) => void }, lastFrame: () => string | undefined) { - stdin.write("\u001b[C"); + stdin.write("\t"); await waitForFrameUpdateAfterInput(); await waitForFrameContains(lastFrame, "[C/V/X/P/L/U/K/R] remote actions"); } +async function selectSettingsRow(stdin: { write: (chunk: string) => void }, label: string, rowOffsetFromTop: number, lastFrame: () => string | undefined) { + await waitForFrameContains(lastFrame, label); + for (let i = 0; i < rowOffsetFromTop; i += 1) { + stdin.write("\u001b[B"); + await waitForFrameUpdateAfterInput(); + } + await waitForFrameContains(lastFrame, label); +} + function findTokenPosition(frame: string, token: string): { row: number; col: number } { const lines = frame.split("\n"); const row = lines.findIndex((line) => line.includes(token)); @@ -556,6 +568,109 @@ describe("Settings view", () => { unmount(); }); + it("keeps list-pane up/down arrows scoped to settings selection", async () => { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + controller.setInteractiveData(makeInteractiveData()); + controller.setMode("interactive"); + controller.setInteractiveView("settings"); + + const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); + await waitForFrameContains(lastFrame, "Max Concurrent"); + + stdin.write("\u001b[B"); + await waitForFrameUpdateAfterInput(); + expect(lastFrame() ?? "").toContain("▶ Max Worktrees"); + + stdin.write("\u001b[A"); + await waitForFrameUpdateAfterInput(); + expect(lastFrame() ?? "").toContain("▶ Max Concurrent"); + + unmount(); + }); + + it("cycles Remote Provider with right arrow in the focused detail pane", async () => { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + const settings: SettingsValues = { + maxConcurrent: 1, + maxWorktrees: 2, + autoMerge: false, + mergeStrategy: "direct", + pollIntervalMs: 60000, + enginePaused: false, + globalPause: false, + remoteActiveProvider: "tailscale", + remoteShortLivedEnabled: true, + remoteShortLivedTtlMs: 600000, + remoteStatus: { provider: "tailscale", state: "running", url: "https://remote.example.com", lastError: null }, + }; + const updateSettings = vi.fn(async (partial: Partial) => { + Object.assign(settings, partial); + }); + const activateProvider = vi.fn(async (_provider: "tailscale" | "cloudflare") => {}); + controller.setInteractiveData(makeInteractiveData({ + settings, + updateSettings, + remote: { activateProvider }, + })); + controller.setMode("interactive"); + controller.setInteractiveView("settings"); + + const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); + await selectSettingsRow(stdin, "Remote Provid", 7, lastFrame); + await focusSettingsDetailPane(stdin, lastFrame); + + stdin.write("\u001b[C"); + + await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledWith({ remoteActiveProvider: "cloudflare" })); + await vi.waitFor(() => expect(activateProvider).toHaveBeenCalledWith("cloudflare")); + await waitForFrameContains(lastFrame, "Provider: cloudflare"); + expect(lastFrame() ?? "").toContain("[←/→] cycle options:"); + + unmount(); + }); + + it("cycles merge strategy with vim-style detail-pane enum keys", async () => { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + const settings: SettingsValues = { + maxConcurrent: 1, + maxWorktrees: 2, + autoMerge: false, + mergeStrategy: "direct", + pollIntervalMs: 60000, + enginePaused: false, + globalPause: false, + remoteActiveProvider: null, + remoteShortLivedEnabled: false, + remoteShortLivedTtlMs: 900000, + }; + const updateSettings = vi.fn(async (partial: Partial) => { + Object.assign(settings, partial); + }); + controller.setInteractiveData(makeInteractiveData({ + updateSettings, + settings, + })); + controller.setMode("interactive"); + controller.setInteractiveView("settings"); + + const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); + await selectSettingsRow(stdin, "Merge Strategy", 3, lastFrame); + await focusSettingsDetailPane(stdin, lastFrame); + + stdin.write("l"); + await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledWith({ mergeStrategy: "squash" })); + await waitForFrameContains(lastFrame, "squash"); + + stdin.write("h"); + await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledWith({ mergeStrategy: "direct" })); + await waitForFrameContains(lastFrame, "direct"); + + unmount(); + }); + it("renders the Remote subsection and supports provider/lifecycle actions", async () => { const controller = newController(); controller.setSystemInfo(makeSystemInfo()); diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index 09db71ffd1..795a499da2 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -2562,23 +2562,6 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState; } useInput((input, key) => { - // Tab cycles list ↔ detail. Left/right also switch — list = left, - // detail = right, matching the visual layout (consistent with AgentsView). - if (key.tab) { - setDetailFocused((f) => !f); - return; - } - if (key.leftArrow) { - setDetailFocused(false); - return; - } - if (key.rightArrow) { - setDetailFocused(true); - return; - } - - const inputUpper = input.toUpperCase(); - if (ttlInputMode) { if (key.escape) { setTtlInputMode(false); @@ -2587,7 +2570,16 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState; return; } - if (inputUpper === "R") { + /* + FNXC:TuiSettingsKeyboard 2026-06-30-22:52: + Tab is the only Settings pane-switch key. Arrow keys must remain available to the focused pane so Windows terminals can use ←/→ to edit enum settings such as Remote Provider instead of getting trapped switching focus. + */ + if (key.tab) { + setDetailFocused((f) => !f); + return; + } + + if (input === "R") { void refreshRemoteStatus(); setStatusMsg(t("tui.settingsRemoteStatusRefreshed", "Remote status refreshed")); return; @@ -2595,62 +2587,6 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState; if (!localSettings) return; - if (data?.remote && inputUpper === "C") { - const provider = localSettings.remoteActiveProvider; - if (!provider) { - setStatusMsg(t("tui.settingsSelectProviderFirst", "Select a remote provider first")); - } else { - void data.remote.activateProvider(provider) - .then(() => refreshRemoteStatus()) - .then(() => setStatusMsg(t("tui.settingsActivatedProvider", "Activated provider: {{provider}}", { provider }))) - .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); - } - return; - } - - if (data?.remote && inputUpper === "V") { - void data.remote.startTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg(t("tui.settingsTunnelStarting", "Remote tunnel starting"))) - .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); - return; - } - - if (data?.remote && inputUpper === "X") { - void data.remote.stopTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg(t("tui.settingsTunnelStopped", "Remote tunnel stopped"))) - .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); - return; - } - - if (data?.remote && inputUpper === "P") { - void data.remote.regeneratePersistentToken() - .then((result) => { - setPersistentMaskedToken(result.maskedToken ?? null); - setStatusMsg(t("tui.settingsPersistentTokenRegenerated", "Persistent token regenerated")); - }) - .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); - return; - } - - if (data?.remote && inputUpper === "L") { - setTtlInputValue(String(localSettings.remoteShortLivedTtlMs)); - setTtlInputMode(true); - setStatusMsg(t("tui.settingsEnterTtl", "Enter TTL milliseconds and press Enter")); - return; - } - - if (data?.remote && inputUpper === "U") { - void handleFetchRemoteUrl("persistent") - .then(() => setStatusMsg(t("tui.settingsRemoteUrlFetched", "Remote URL fetched"))) - .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); - return; - } - - if (data?.remote && inputUpper === "K") { - void handleFetchRemoteQr("persistent") - .then(() => setStatusMsg(t("tui.settingsQrFetched", "QR payload fetched"))) - .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); - return; - } - if (!detailFocused) { if (key.upArrow || input === "k") { setSelectedIndex((i) => Math.max(0, i - 1)); @@ -2665,6 +2601,87 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState; if (!selectedDef) return; + if (selectedDef.type === "enum" && selectedDef.options) { + const current = localSettings[selectedDef.key]; + const idx = typeof current === "string" ? selectedDef.options.indexOf(current) : -1; + if (key.rightArrow || input === "l") { + const next = selectedDef.options[(idx + 1) % selectedDef.options.length]; + const updated = { ...localSettings, [selectedDef.key]: next }; + setLocalSettings(updated); + void saveField({ [selectedDef.key]: next }); + if (selectedDef.key === "remoteActiveProvider" && data?.remote) { + void data.remote.activateProvider(next as "tailscale" | "cloudflare").catch(() => {}); + } + return; + } + if (key.leftArrow || input === "h") { + const prev = selectedDef.options[(idx - 1 + selectedDef.options.length) % selectedDef.options.length]; + const updated = { ...localSettings, [selectedDef.key]: prev }; + setLocalSettings(updated); + void saveField({ [selectedDef.key]: prev }); + if (selectedDef.key === "remoteActiveProvider" && data?.remote) { + void data.remote.activateProvider(prev as "tailscale" | "cloudflare").catch(() => {}); + } + return; + } + } + + if (data?.remote && input === "C") { + const provider = localSettings.remoteActiveProvider; + if (!provider) { + setStatusMsg(t("tui.settingsSelectProviderFirst", "Select a remote provider first")); + } else { + void data.remote.activateProvider(provider) + .then(() => refreshRemoteStatus()) + .then(() => setStatusMsg(t("tui.settingsActivatedProvider", "Activated provider: {{provider}}", { provider }))) + .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); + } + return; + } + + if (data?.remote && input === "V") { + void data.remote.startTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg(t("tui.settingsTunnelStarting", "Remote tunnel starting"))) + .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); + return; + } + + if (data?.remote && input === "X") { + void data.remote.stopTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg(t("tui.settingsTunnelStopped", "Remote tunnel stopped"))) + .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); + return; + } + + if (data?.remote && input === "P") { + void data.remote.regeneratePersistentToken() + .then((result) => { + setPersistentMaskedToken(result.maskedToken ?? null); + setStatusMsg(t("tui.settingsPersistentTokenRegenerated", "Persistent token regenerated")); + }) + .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); + return; + } + + if (data?.remote && input === "L") { + setTtlInputValue(String(localSettings.remoteShortLivedTtlMs)); + setTtlInputMode(true); + setStatusMsg(t("tui.settingsEnterTtl", "Enter TTL milliseconds and press Enter")); + return; + } + + if (data?.remote && input === "U") { + void handleFetchRemoteUrl("persistent") + .then(() => setStatusMsg(t("tui.settingsRemoteUrlFetched", "Remote URL fetched"))) + .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); + return; + } + + if (data?.remote && input === "K") { + void handleFetchRemoteQr("persistent") + .then(() => setStatusMsg(t("tui.settingsQrFetched", "QR payload fetched"))) + .catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)); + return; + } + if (selectedDef.type === "boolean" && input === " ") { const current = localSettings[selectedDef.key] as boolean; const updated = { ...localSettings, [selectedDef.key]: !current }; @@ -2692,30 +2709,6 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState; } } - if (selectedDef.type === "enum" && selectedDef.options) { - const current = localSettings[selectedDef.key] as string; - const idx = selectedDef.options.indexOf(current); - if (key.rightArrow || input === "l") { - const next = selectedDef.options[(idx + 1) % selectedDef.options.length]; - const updated = { ...localSettings, [selectedDef.key]: next }; - setLocalSettings(updated); - void saveField({ [selectedDef.key]: next }); - if (selectedDef.key === "remoteActiveProvider" && data?.remote) { - void data.remote.activateProvider(next as "tailscale" | "cloudflare").catch(() => {}); - } - return; - } - if (key.leftArrow || input === "h") { - const prev = selectedDef.options[(idx - 1 + selectedDef.options.length) % selectedDef.options.length]; - const updated = { ...localSettings, [selectedDef.key]: prev }; - setLocalSettings(updated); - void saveField({ [selectedDef.key]: prev }); - if (selectedDef.key === "remoteActiveProvider" && data?.remote) { - void data.remote.activateProvider(prev as "tailscale" | "cloudflare").catch(() => {}); - } - return; - } - } }); function renderValue(def: SettingDef, settings: SettingsValues): React.ReactNode {