From a0a287e9789b498ab7e3c3722a21af1dd2b033fa Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 13:13:57 -0700 Subject: [PATCH 01/21] feat: cli banner --- .../config/components/CliInstallBanner.tsx | 85 +++++++++++++++++++ gui/src/pages/config/index.tsx | 8 +- 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 gui/src/pages/config/components/CliInstallBanner.tsx diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx new file mode 100644 index 00000000000..013f5ef5afd --- /dev/null +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -0,0 +1,85 @@ +import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { useContext, useEffect, useState } from "react"; +import Alert from "../../../components/gui/Alert"; +import { Button } from "../../../components/ui/Button"; +import { IdeMessengerContext } from "../../../context/IdeMessenger"; +import { getPlatform } from "../../../util"; + +export function CliInstallBanner() { + const ideMessenger = useContext(IdeMessengerContext); + const [cliInstalled, setCliInstalled] = useState(null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + const checkCliInstallation = async () => { + try { + const platform = getPlatform(); + // Use 'which' on mac/linux, 'where' on windows + const command = platform === "windows" ? "where cn" : "which cn"; + + const [stdout, stderr] = await ideMessenger.ide.subprocess(command); + + // If stdout has content (path to cn), it's installed + // If empty or stderr has "not found", it's not installed + const isInstalled = + stdout.trim().length > 0 && !stderr.includes("not found"); + setCliInstalled(isInstalled); + } catch (error) { + // If subprocess throws an error, assume CLI is not installed + setCliInstalled(false); + } + }; + + checkCliInstallation(); + }, [ideMessenger]); + + // Don't show if still loading, already installed, or dismissed + // if (cliInstalled === null || cliInstalled === true || dismissed) { + // return null; + // } + + return ( +
+ +
+ +
+
Try the Continue CLI
+
+ Use{" "} + + cn + {" "} + in your terminal for command-line coding assistance with + interactive and headless modes. +
+
+ + npm i -g @continuedev/cli + + +
+
+ +
+
+
+ ); +} diff --git a/gui/src/pages/config/index.tsx b/gui/src/pages/config/index.tsx index 283f48dff47..eae76dbd8c3 100644 --- a/gui/src/pages/config/index.tsx +++ b/gui/src/pages/config/index.tsx @@ -8,6 +8,7 @@ import { TabGroup } from "../../components/ui/TabGroup"; import { useAuth } from "../../context/Auth"; import { useNavigationListener } from "../../hooks/useNavigationListener"; import { bottomTabSections, getAllTabs, topTabSections } from "./configTabs"; +import { CliInstallBanner } from "./components/CliInstallBanner"; import { AccountDropdown } from "./features/account/AccountDropdown"; function ConfigPage() { @@ -89,8 +90,11 @@ function ConfigPage() { {/* Tab Content for larger screens (md and above) */} -
- {allTabs.find((tab) => tab.id === activeTab)?.component} +
+
+ {allTabs.find((tab) => tab.id === activeTab)?.component} +
+
From 3be744b70e2d5b20064a1c868b2a68e108a6d947 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 13:24:47 -0700 Subject: [PATCH 02/21] improv: ui --- .../config/components/CliInstallBanner.tsx | 67 ++++++++++--------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index 013f5ef5afd..82a0e5dddb1 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -1,7 +1,7 @@ import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useContext, useEffect, useState } from "react"; -import Alert from "../../../components/gui/Alert"; -import { Button } from "../../../components/ui/Button"; +import { SecondaryButton } from "../../../components"; +import { Card } from "../../../components/ui"; import { IdeMessengerContext } from "../../../context/IdeMessenger"; import { getPlatform } from "../../../util"; @@ -34,52 +34,55 @@ export function CliInstallBanner() { }, [ideMessenger]); // Don't show if still loading, already installed, or dismissed - // if (cliInstalled === null || cliInstalled === true || dismissed) { - // return null; - // } + if (cliInstalled === null || cliInstalled === true || dismissed) { + // return null; + } return (
- -
- -
-
Try the Continue CLI
-
- Use{" "} - - cn - {" "} - in your terminal for command-line coding assistance with - interactive and headless modes. + + +
+ +
+
+
+ Try the Continue CLI +
+
+ Use{" "} + + cn + {" "} + in your terminal for command-line coding assistance with + interactive and headless modes. +
-
- +
+ npm i -g @continuedev/cli - + Learn more +
-
- +
); } From ad32ef378a1175dfa82b5360e7bce2c92fdaca25 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 13:32:54 -0700 Subject: [PATCH 03/21] improv: ui --- .../config/components/CliInstallBanner.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index 82a0e5dddb1..3d9472ab02b 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -1,6 +1,6 @@ import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useContext, useEffect, useState } from "react"; -import { SecondaryButton } from "../../../components"; +import { CloseButton, SecondaryButton } from "../../../components"; import { Card } from "../../../components/ui"; import { IdeMessengerContext } from "../../../context/IdeMessenger"; import { getPlatform } from "../../../util"; @@ -41,16 +41,12 @@ export function CliInstallBanner() { return (
- -
+ setDismissed(true)}> + + +
-
+
Try the Continue CLI @@ -64,7 +60,7 @@ export function CliInstallBanner() { interactive and headless modes.
-
+
npm i -g @continuedev/cli From bd65b07d3276466a9b1cb22ab3e775da13d3f3ab Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 13:37:34 -0700 Subject: [PATCH 04/21] improv: banner --- gui/src/pages/config/components/CliInstallBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index 3d9472ab02b..f2c2cd603de 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -35,7 +35,7 @@ export function CliInstallBanner() { // Don't show if still loading, already installed, or dismissed if (cliInstalled === null || cliInstalled === true || dismissed) { - // return null; + return null; } return ( From ab1f072eae328f6c4d94144726658ad9418a8300 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 13:55:28 -0700 Subject: [PATCH 05/21] test: cli banner --- .../components/CliInstallBanner.test.tsx | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 gui/src/pages/config/components/CliInstallBanner.test.tsx diff --git a/gui/src/pages/config/components/CliInstallBanner.test.tsx b/gui/src/pages/config/components/CliInstallBanner.test.tsx new file mode 100644 index 00000000000..d226e9b4e3d --- /dev/null +++ b/gui/src/pages/config/components/CliInstallBanner.test.tsx @@ -0,0 +1,280 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { IdeMessengerContext } from "../../../context/IdeMessenger"; +import { MockIdeMessenger } from "../../../context/MockIdeMessenger"; +import * as util from "../../../util"; +import { CliInstallBanner } from "./CliInstallBanner"; + +vi.mock("../../../util", async () => { + const actual = await vi.importActual("../../../util"); + return { + ...actual, + getPlatform: vi.fn(), + }; +}); + +describe("CliInstallBanner", () => { + let mockIdeMessenger: MockIdeMessenger; + + beforeEach(() => { + vi.clearAllMocks(); + mockIdeMessenger = new MockIdeMessenger(); + vi.mocked(util.getPlatform).mockReturnValue("mac"); + }); + + const renderComponent = async (subprocessResponse: [string, string]) => { + // Mock the subprocess call on the IDE + vi.spyOn(mockIdeMessenger.ide, "subprocess").mockResolvedValue( + subprocessResponse, + ); + + return act(async () => + render( + + + , + ), + ); + }; + + describe("CLI detection", () => { + it("does not render when CLI is installed (subprocess returns path)", async () => { + await renderComponent(["/usr/local/bin/cn", ""]); + + await waitFor(() => { + expect( + screen.queryByText("Try the Continue CLI"), + ).not.toBeInTheDocument(); + }); + }); + + it("renders when CLI is not installed (subprocess returns empty)", async () => { + await renderComponent(["", "command not found"]); + + await waitFor(() => { + expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + }); + }); + + it("renders when CLI is not installed (subprocess returns empty stdout)", async () => { + await renderComponent(["", ""]); + + await waitFor(() => { + expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + }); + }); + + it("uses 'which cn' command on mac platform", async () => { + vi.mocked(util.getPlatform).mockReturnValue("mac"); + const subprocessSpy = vi + .spyOn(mockIdeMessenger.ide, "subprocess") + .mockResolvedValue(["", ""]); + + await renderComponent(["", ""]); + + await waitFor(() => { + expect(subprocessSpy).toHaveBeenCalledWith("which cn"); + }); + }); + + it("uses 'which cn' command on linux platform", async () => { + vi.mocked(util.getPlatform).mockReturnValue("linux"); + const subprocessSpy = vi + .spyOn(mockIdeMessenger.ide, "subprocess") + .mockResolvedValue(["", ""]); + + await renderComponent(["", ""]); + + await waitFor(() => { + expect(subprocessSpy).toHaveBeenCalledWith("which cn"); + }); + }); + + it("uses 'where cn' command on windows platform", async () => { + vi.mocked(util.getPlatform).mockReturnValue("windows"); + const subprocessSpy = vi + .spyOn(mockIdeMessenger.ide, "subprocess") + .mockResolvedValue(["", ""]); + + await renderComponent(["", ""]); + + await waitFor(() => { + expect(subprocessSpy).toHaveBeenCalledWith("where cn"); + }); + }); + + it("handles subprocess errors gracefully", async () => { + vi.spyOn(mockIdeMessenger.ide, "subprocess").mockRejectedValue( + new Error("Command failed"), + ); + + await act(async () => + render( + + + , + ), + ); + + await waitFor(() => { + expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + }); + }); + }); + + describe("Banner content", () => { + beforeEach(async () => { + await renderComponent(["", "not found"]); + await waitFor(() => { + expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + }); + }); + + it("displays the title", () => { + expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + }); + + it("displays the description with 'cn' code element", () => { + const description = screen.getByText(/Use/); + expect(description).toBeInTheDocument(); + expect(screen.getByText("cn")).toBeInTheDocument(); + }); + + it("displays the installation command", () => { + expect(screen.getByText("npm i -g @continuedev/cli")).toBeInTheDocument(); + }); + + it("displays the Learn more button", () => { + expect(screen.getByText("Learn more")).toBeInTheDocument(); + }); + + it("displays the close button", () => { + const closeButton = screen.getByRole("button", { name: /dismiss/i }); + expect(closeButton).toBeInTheDocument(); + }); + + it("displays the CommandLine icon", () => { + // The icon should be present in the component + const banner = screen.getByText("Try the Continue CLI").closest("div"); + expect(banner).toBeInTheDocument(); + }); + }); + + describe("User interactions", () => { + beforeEach(async () => { + await renderComponent(["", "not found"]); + await waitFor(() => { + expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + }); + }); + + it("dismisses banner when close button is clicked", async () => { + const closeButton = screen.getByRole("button", { name: /dismiss/i }); + fireEvent.click(closeButton); + + await waitFor(() => { + expect( + screen.queryByText("Try the Continue CLI"), + ).not.toBeInTheDocument(); + }); + }); + + it("opens documentation URL when Learn more button is clicked", async () => { + const postSpy = vi.spyOn(mockIdeMessenger, "post"); + const learnMoreButton = screen.getByText("Learn more"); + + fireEvent.click(learnMoreButton); + + expect(postSpy).toHaveBeenCalledWith( + "openUrl", + "https://docs.continue.dev/guides/cli", + ); + }); + }); + + describe("Banner visibility states", () => { + it("does not render while CLI check is loading", async () => { + vi.spyOn(mockIdeMessenger.ide, "subprocess").mockImplementation( + () => + new Promise((resolve) => setTimeout(() => resolve(["", ""]), 100)), + ); + + await act(async () => + render( + + + , + ), + ); + + // Should not be visible immediately + expect( + screen.queryByText("Try the Continue CLI"), + ).not.toBeInTheDocument(); + }); + + it("remains hidden after dismissal even on re-render", async () => { + const { rerender } = await renderComponent(["", "not found"]); + + await waitFor(() => { + expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + }); + + // Dismiss the banner + const closeButton = screen.getByRole("button", { name: /dismiss/i }); + fireEvent.click(closeButton); + + await waitFor(() => { + expect( + screen.queryByText("Try the Continue CLI"), + ).not.toBeInTheDocument(); + }); + + // Re-render the component + rerender( + + + , + ); + + // Should still be hidden + expect( + screen.queryByText("Try the Continue CLI"), + ).not.toBeInTheDocument(); + }); + }); + + describe("Edge cases", () => { + it("handles whitespace in subprocess output", async () => { + await renderComponent([" \n ", ""]); + + await waitFor(() => { + expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + }); + }); + + it("detects CLI when path has trailing newline", async () => { + await renderComponent(["/usr/local/bin/cn\n", ""]); + + await waitFor(() => { + expect( + screen.queryByText("Try the Continue CLI"), + ).not.toBeInTheDocument(); + }); + }); + + it("renders banner when stderr contains 'not found'", async () => { + await renderComponent(["", "cn: command not found"]); + + await waitFor(() => { + expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + }); + }); + }); +}); From f5999a6cccca4a0105488c72b9878dfe7e11f1d8 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 14:02:44 -0700 Subject: [PATCH 06/21] improv: ui --- .../components/CliInstallBanner.test.tsx | 24 +++++++++++++++++++ .../config/components/CliInstallBanner.tsx | 14 ++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.test.tsx b/gui/src/pages/config/components/CliInstallBanner.test.tsx index d226e9b4e3d..9d1f8e9898a 100644 --- a/gui/src/pages/config/components/CliInstallBanner.test.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.test.tsx @@ -196,6 +196,30 @@ describe("CliInstallBanner", () => { "https://docs.continue.dev/guides/cli", ); }); + + it("copies installation command when copy button is clicked", async () => { + // Find the copy button (ClipboardIcon) + const copyButtons = screen.getAllByRole("button"); + const copyButton = copyButtons.find((btn) => + btn.querySelector('svg[class*="ClipboardIcon"]'), + ); + + expect(copyButton).toBeDefined(); + }); + + it("runs installation command in terminal when run button is clicked", async () => { + const postSpy = vi.spyOn(mockIdeMessenger, "post"); + + // Find the "Run" text or CommandLineIcon + const runButton = screen.getByText(/Run/i).closest("div"); + if (runButton) { + fireEvent.click(runButton); + + expect(postSpy).toHaveBeenCalledWith("runCommand", { + command: "npm i -g @continuedev/cli", + }); + } + }); }); describe("Banner visibility states", () => { diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index f2c2cd603de..ec8d0936287 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -1,6 +1,8 @@ import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useContext, useEffect, useState } from "react"; import { CloseButton, SecondaryButton } from "../../../components"; +import { CopyButton } from "../../../components/StyledMarkdownPreview/StepContainerPreToolbar/CopyButton"; +import { RunInTerminalButton } from "../../../components/StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton"; import { Card } from "../../../components/ui"; import { IdeMessengerContext } from "../../../context/IdeMessenger"; import { getPlatform } from "../../../util"; @@ -61,9 +63,15 @@ export function CliInstallBanner() {
- - npm i -g @continuedev/cli - +
+ + npm i -g @continuedev/cli + +
+ + +
+
ideMessenger.post( From c714c62db1acf516649e7ef784d88c955f848e7d Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 14:07:12 -0700 Subject: [PATCH 07/21] improv: ui --- .../config/components/CliInstallBanner.tsx | 68 +++++++++---------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index ec8d0936287..6abe87da50b 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -46,44 +46,42 @@ export function CliInstallBanner() { setDismissed(true)}> -
- -
-
-
- Try the Continue CLI -
-
- Use{" "} - - cn - {" "} - in your terminal for command-line coding assistance with - interactive and headless modes. -
+
+
+
+ + Try the Continue CLI
-
-
- - npm i -g @continuedev/cli - -
- - -
+
+ Use{" "} + + cn + {" "} + in your terminal for command-line coding assistance with + interactive and headless modes. +
+
+
+
+ + npm i -g @continuedev/cli + +
+ +
- - ideMessenger.post( - "openUrl", - "https://docs.continue.dev/guides/cli", - ) - } - style={{ margin: 0 }} - > - Learn more -
+ + ideMessenger.post( + "openUrl", + "https://docs.continue.dev/guides/cli", + ) + } + style={{ margin: 0 }} + > + Learn more +
From 351070cf4c743da19c404971e126166d2b5cee36 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 14:16:39 -0700 Subject: [PATCH 08/21] improv: ui --- gui/src/pages/config/components/CliInstallBanner.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index 6abe87da50b..c331ebfa6c9 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -61,8 +61,8 @@ export function CliInstallBanner() { interactive and headless modes.
-
-
+
+
npm i -g @continuedev/cli From a873873b72950939d318b8ddcd9e951435a5f704 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 14:19:48 -0700 Subject: [PATCH 09/21] improv: ui --- gui/src/pages/config/components/CliInstallBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index c331ebfa6c9..a220d663dc9 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -62,7 +62,7 @@ export function CliInstallBanner() {
-
+
npm i -g @continuedev/cli From 8a5e320e1cf4e5838ae7e9df1425ebe797fc181c Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 14:23:12 -0700 Subject: [PATCH 10/21] improv: ui --- gui/src/pages/config/components/CliInstallBanner.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index a220d663dc9..d6fb47bb693 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -68,7 +68,9 @@ export function CliInstallBanner() {
- +
Date: Mon, 6 Oct 2025 14:37:57 -0700 Subject: [PATCH 11/21] improv: ui --- .../config/components/CliInstallBanner.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index d6fb47bb693..c13ab87233c 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -50,15 +50,26 @@ export function CliInstallBanner() {
- Try the Continue CLI + Try out the Continue CLI
Use{" "} cn {" "} - in your terminal for command-line coding assistance with - interactive and headless modes. + in your terminal interactively and then deploy Continuous AI + workflows.{" "} + + ideMessenger.post( + "openUrl", + "https://docs.continue.dev/guides/cli", + ) + } + className="cursor-pointer underline hover:brightness-125" + > + Learn more. +
@@ -73,17 +84,6 @@ export function CliInstallBanner() { />
- - ideMessenger.post( - "openUrl", - "https://docs.continue.dev/guides/cli", - ) - } - style={{ margin: 0 }} - > - Learn more -
From 966998465f1614ab1a0d3f346e5da1ace5b1de2e Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 14:41:38 -0700 Subject: [PATCH 12/21] improv: ui --- gui/src/pages/config/components/CliInstallBanner.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index c13ab87233c..075ea0de613 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -1,6 +1,6 @@ import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useContext, useEffect, useState } from "react"; -import { CloseButton, SecondaryButton } from "../../../components"; +import { CloseButton } from "../../../components"; import { CopyButton } from "../../../components/StyledMarkdownPreview/StepContainerPreToolbar/CopyButton"; import { RunInTerminalButton } from "../../../components/StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton"; import { Card } from "../../../components/ui"; @@ -78,7 +78,9 @@ export function CliInstallBanner() { npm i -g @continuedev/cli
- + From 53da290d3e7d1edc62839aac0116596262bdfc36 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 15:45:54 -0700 Subject: [PATCH 13/21] improv: ui --- .../components/CliInstallBanner.test.tsx | 261 +++++++++++++++--- .../config/components/CliInstallBanner.tsx | 52 +++- gui/src/pages/gui/Chat.tsx | 12 + gui/src/util/localStorage.ts | 1 + 4 files changed, 283 insertions(+), 43 deletions(-) diff --git a/gui/src/pages/config/components/CliInstallBanner.test.tsx b/gui/src/pages/config/components/CliInstallBanner.test.tsx index 9d1f8e9898a..5bf24bc2ead 100644 --- a/gui/src/pages/config/components/CliInstallBanner.test.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.test.tsx @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { IdeMessengerContext } from "../../../context/IdeMessenger"; import { MockIdeMessenger } from "../../../context/MockIdeMessenger"; import * as util from "../../../util"; +import * as localStorage from "../../../util/localStorage"; import { CliInstallBanner } from "./CliInstallBanner"; vi.mock("../../../util", async () => { @@ -19,6 +20,15 @@ vi.mock("../../../util", async () => { }; }); +vi.mock("../../../util/localStorage", async () => { + const actual = await vi.importActual("../../../util/localStorage"); + return { + ...actual, + getLocalStorage: vi.fn(), + setLocalStorage: vi.fn(), + }; +}); + describe("CliInstallBanner", () => { let mockIdeMessenger: MockIdeMessenger; @@ -26,6 +36,7 @@ describe("CliInstallBanner", () => { vi.clearAllMocks(); mockIdeMessenger = new MockIdeMessenger(); vi.mocked(util.getPlatform).mockReturnValue("mac"); + vi.mocked(localStorage.getLocalStorage).mockReturnValue(undefined); }); const renderComponent = async (subprocessResponse: [string, string]) => { @@ -49,7 +60,7 @@ describe("CliInstallBanner", () => { await waitFor(() => { expect( - screen.queryByText("Try the Continue CLI"), + screen.queryByText("Try out the Continue CLI"), ).not.toBeInTheDocument(); }); }); @@ -58,7 +69,9 @@ describe("CliInstallBanner", () => { await renderComponent(["", "command not found"]); await waitFor(() => { - expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); }); }); @@ -66,7 +79,9 @@ describe("CliInstallBanner", () => { await renderComponent(["", ""]); await waitFor(() => { - expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); }); }); @@ -76,7 +91,13 @@ describe("CliInstallBanner", () => { .spyOn(mockIdeMessenger.ide, "subprocess") .mockResolvedValue(["", ""]); - await renderComponent(["", ""]); + await act(async () => + render( + + + , + ), + ); await waitFor(() => { expect(subprocessSpy).toHaveBeenCalledWith("which cn"); @@ -89,7 +110,13 @@ describe("CliInstallBanner", () => { .spyOn(mockIdeMessenger.ide, "subprocess") .mockResolvedValue(["", ""]); - await renderComponent(["", ""]); + await act(async () => + render( + + + , + ), + ); await waitFor(() => { expect(subprocessSpy).toHaveBeenCalledWith("which cn"); @@ -102,7 +129,13 @@ describe("CliInstallBanner", () => { .spyOn(mockIdeMessenger.ide, "subprocess") .mockResolvedValue(["", ""]); - await renderComponent(["", ""]); + await act(async () => + render( + + + , + ), + ); await waitFor(() => { expect(subprocessSpy).toHaveBeenCalledWith("where cn"); @@ -123,7 +156,9 @@ describe("CliInstallBanner", () => { ); await waitFor(() => { - expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); }); }); }); @@ -132,12 +167,14 @@ describe("CliInstallBanner", () => { beforeEach(async () => { await renderComponent(["", "not found"]); await waitFor(() => { - expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); }); }); it("displays the title", () => { - expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + expect(screen.getByText("Try out the Continue CLI")).toBeInTheDocument(); }); it("displays the description with 'cn' code element", () => { @@ -150,18 +187,22 @@ describe("CliInstallBanner", () => { expect(screen.getByText("npm i -g @continuedev/cli")).toBeInTheDocument(); }); - it("displays the Learn more button", () => { - expect(screen.getByText("Learn more")).toBeInTheDocument(); + it("displays the Learn more link", () => { + expect(screen.getByText("Learn more.")).toBeInTheDocument(); }); it("displays the close button", () => { - const closeButton = screen.getByRole("button", { name: /dismiss/i }); - expect(closeButton).toBeInTheDocument(); + // Get the styled close button (it doesn't have a text label) + const buttons = screen.getAllByRole("button"); + // There should be multiple buttons (close, copy, run) + expect(buttons.length).toBeGreaterThan(0); }); it("displays the CommandLine icon", () => { // The icon should be present in the component - const banner = screen.getByText("Try the Continue CLI").closest("div"); + const banner = screen + .getByText("Try out the Continue CLI") + .closest("div"); expect(banner).toBeInTheDocument(); }); }); @@ -170,26 +211,30 @@ describe("CliInstallBanner", () => { beforeEach(async () => { await renderComponent(["", "not found"]); await waitFor(() => { - expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); }); }); it("dismisses banner when close button is clicked", async () => { - const closeButton = screen.getByRole("button", { name: /dismiss/i }); + const buttons = screen.getAllByRole("button"); + // First button should be the close button (CloseButton component) + const closeButton = buttons[0]; fireEvent.click(closeButton); await waitFor(() => { expect( - screen.queryByText("Try the Continue CLI"), + screen.queryByText("Try out the Continue CLI"), ).not.toBeInTheDocument(); }); }); - it("opens documentation URL when Learn more button is clicked", async () => { + it("opens documentation URL when Learn more link is clicked", async () => { const postSpy = vi.spyOn(mockIdeMessenger, "post"); - const learnMoreButton = screen.getByText("Learn more"); + const learnMoreLink = screen.getByText("Learn more."); - fireEvent.click(learnMoreButton); + fireEvent.click(learnMoreLink); expect(postSpy).toHaveBeenCalledWith( "openUrl", @@ -197,14 +242,11 @@ describe("CliInstallBanner", () => { ); }); - it("copies installation command when copy button is clicked", async () => { - // Find the copy button (ClipboardIcon) - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((btn) => - btn.querySelector('svg[class*="ClipboardIcon"]'), - ); - - expect(copyButton).toBeDefined(); + it("displays the installation command with interactive controls", async () => { + // The installation command should be visible + expect(screen.getByText("npm i -g @continuedev/cli")).toBeInTheDocument(); + // The "Run" text should be visible for the run button + expect(screen.getByText(/Run/i)).toBeInTheDocument(); }); it("runs installation command in terminal when run button is clicked", async () => { @@ -216,7 +258,7 @@ describe("CliInstallBanner", () => { fireEvent.click(runButton); expect(postSpy).toHaveBeenCalledWith("runCommand", { - command: "npm i -g @continuedev/cli", + command: `npm i -g @continuedev/cli && cn "Explore this repo and provide a concise summary of it's contents"`, }); } }); @@ -239,7 +281,7 @@ describe("CliInstallBanner", () => { // Should not be visible immediately expect( - screen.queryByText("Try the Continue CLI"), + screen.queryByText("Try out the Continue CLI"), ).not.toBeInTheDocument(); }); @@ -247,16 +289,19 @@ describe("CliInstallBanner", () => { const { rerender } = await renderComponent(["", "not found"]); await waitFor(() => { - expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); }); // Dismiss the banner - const closeButton = screen.getByRole("button", { name: /dismiss/i }); + const buttons = screen.getAllByRole("button"); + const closeButton = buttons[0]; fireEvent.click(closeButton); await waitFor(() => { expect( - screen.queryByText("Try the Continue CLI"), + screen.queryByText("Try out the Continue CLI"), ).not.toBeInTheDocument(); }); @@ -269,7 +314,7 @@ describe("CliInstallBanner", () => { // Should still be hidden expect( - screen.queryByText("Try the Continue CLI"), + screen.queryByText("Try out the Continue CLI"), ).not.toBeInTheDocument(); }); }); @@ -279,7 +324,9 @@ describe("CliInstallBanner", () => { await renderComponent([" \n ", ""]); await waitFor(() => { - expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); }); }); @@ -288,7 +335,7 @@ describe("CliInstallBanner", () => { await waitFor(() => { expect( - screen.queryByText("Try the Continue CLI"), + screen.queryByText("Try out the Continue CLI"), ).not.toBeInTheDocument(); }); }); @@ -297,8 +344,148 @@ describe("CliInstallBanner", () => { await renderComponent(["", "cn: command not found"]); await waitFor(() => { - expect(screen.getByText("Try the Continue CLI")).toBeInTheDocument(); + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); + }); + }); + }); + + describe("Message threshold logic", () => { + const renderWithMessageCount = async ( + messageCount?: number, + messageThreshold?: number, + ) => { + vi.spyOn(mockIdeMessenger.ide, "subprocess").mockResolvedValue([ + "", + "not found", + ]); + + return act(async () => + render( + + + , + ), + ); + }; + + it("shows banner when no threshold is set", async () => { + await renderWithMessageCount(0, undefined); + + await waitFor(() => { + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); + }); + }); + + it("does not show banner when message count is below threshold", async () => { + await renderWithMessageCount(2, 3); + + await waitFor(() => { + expect( + screen.queryByText("Try out the Continue CLI"), + ).not.toBeInTheDocument(); + }); + }); + + it("shows banner when message count meets threshold", async () => { + await renderWithMessageCount(3, 3); + + await waitFor(() => { + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); + }); + }); + + it("shows banner when message count exceeds threshold", async () => { + await renderWithMessageCount(5, 3); + + await waitFor(() => { + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); }); }); }); + + describe("Permanent dismissal with localStorage", () => { + const renderWithPermanentDismissal = async () => { + vi.spyOn(mockIdeMessenger.ide, "subprocess").mockResolvedValue([ + "", + "not found", + ]); + + return act(async () => + render( + + + , + ), + ); + }; + + it("does not show banner when previously dismissed permanently", async () => { + vi.mocked(localStorage.getLocalStorage).mockReturnValue(true); + + await renderWithPermanentDismissal(); + + await waitFor(() => { + expect( + screen.queryByText("Try out the Continue CLI"), + ).not.toBeInTheDocument(); + }); + }); + + it("sets localStorage when dismissed with permanentDismissal=true", async () => { + await renderWithPermanentDismissal(); + + await waitFor(() => { + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole("button"); + const closeButton = buttons[0]; + fireEvent.click(closeButton); + + expect(localStorage.setLocalStorage).toHaveBeenCalledWith( + "hasDismissedCliInstallBanner", + true, + ); + }); + + it("does not set localStorage when dismissed with permanentDismissal=false", async () => { + vi.spyOn(mockIdeMessenger.ide, "subprocess").mockResolvedValue([ + "", + "not found", + ]); + + await act(async () => + render( + + + , + ), + ); + + await waitFor(() => { + expect( + screen.getByText("Try out the Continue CLI"), + ).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole("button"); + const closeButton = buttons[0]; + fireEvent.click(closeButton); + + expect(localStorage.setLocalStorage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/pages/config/components/CliInstallBanner.tsx index 075ea0de613..8747d7ecdf5 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/pages/config/components/CliInstallBanner.tsx @@ -6,13 +6,36 @@ import { RunInTerminalButton } from "../../../components/StyledMarkdownPreview/S import { Card } from "../../../components/ui"; import { IdeMessengerContext } from "../../../context/IdeMessenger"; import { getPlatform } from "../../../util"; +import { getLocalStorage, setLocalStorage } from "../../../util/localStorage"; -export function CliInstallBanner() { +interface CliInstallBannerProps { + /** Current message count - banner shows only if >= messageThreshold */ + messageCount?: number; + /** Minimum messages before showing banner (default: always show) */ + messageThreshold?: number; + /** If true, dismissal is permanent via localStorage (default: session only) */ + permanentDismissal?: boolean; +} + +export function CliInstallBanner({ + messageCount, + messageThreshold, + permanentDismissal = false, +}: CliInstallBannerProps = {}) { const ideMessenger = useContext(IdeMessengerContext); const [cliInstalled, setCliInstalled] = useState(null); const [dismissed, setDismissed] = useState(false); useEffect(() => { + // Check if user has permanently dismissed the banner + if (permanentDismissal) { + const hasDismissed = getLocalStorage("hasDismissedCliInstallBanner"); + if (hasDismissed) { + setDismissed(true); + return; + } + } + const checkCliInstallation = async () => { try { const platform = getPlatform(); @@ -32,18 +55,35 @@ export function CliInstallBanner() { } }; - checkCliInstallation(); - }, [ideMessenger]); + void checkCliInstallation(); + }, [ideMessenger, permanentDismissal]); + + const handleDismiss = () => { + setDismissed(true); + if (permanentDismissal) { + setLocalStorage("hasDismissedCliInstallBanner", true); + } + }; - // Don't show if still loading, already installed, or dismissed - if (cliInstalled === null || cliInstalled === true || dismissed) { + // Don't show if: + // - Still loading CLI status + // - CLI is already installed + // - User has dismissed it + // - Message threshold not met (if threshold is set) + if ( + cliInstalled === null || + cliInstalled === true || + dismissed || + (messageThreshold !== undefined && + (messageCount === undefined || messageCount < messageThreshold)) + ) { return null; } return (
- setDismissed(true)}> +
diff --git a/gui/src/pages/gui/Chat.tsx b/gui/src/pages/gui/Chat.tsx index 08ca69498ea..0fabb5faf69 100644 --- a/gui/src/pages/gui/Chat.tsx +++ b/gui/src/pages/gui/Chat.tsx @@ -46,6 +46,7 @@ import { ToolCallDiv } from "./ToolCallDiv"; import { FatalErrorIndicator } from "../../components/config/FatalErrorNotice"; import InlineErrorMessage from "../../components/mainInput/InlineErrorMessage"; import { cancelStream } from "../../redux/thunks/cancelStream"; +import { CliInstallBanner } from "../config/components/CliInstallBanner"; import { EmptyChatBody } from "./EmptyChatBody"; import { ExploreDialogWatcher } from "./ExploreDialogWatcher"; import { useAutoScroll } from "./useAutoScroll"; @@ -380,6 +381,11 @@ export function Chat() { const showScrollbar = showChatScrollbar ?? window.innerHeight > 5000; + // Count user messages for CLI banner threshold + const userMessageCount = useMemo(() => { + return history.filter((item) => item.message.role === "user").length; + }, [history]); + return ( <> {!!showSessionTabs && !isInEdit && } @@ -421,6 +427,12 @@ export function Chat() { inputId={MAIN_EDITOR_INPUT_ID} /> + +
Date: Mon, 6 Oct 2025 15:57:29 -0700 Subject: [PATCH 14/21] improv: ui --- .../components/CliInstallBanner.test.tsx | 16 ++++++++-------- .../config => }/components/CliInstallBanner.tsx | 14 +++++++------- gui/src/pages/config/index.tsx | 2 +- gui/src/pages/gui/Chat.tsx | 2 +- 4 files changed, 17 insertions(+), 17 deletions(-) rename gui/src/{pages/config => }/components/CliInstallBanner.test.tsx (96%) rename gui/src/{pages/config => }/components/CliInstallBanner.tsx (89%) diff --git a/gui/src/pages/config/components/CliInstallBanner.test.tsx b/gui/src/components/CliInstallBanner.test.tsx similarity index 96% rename from gui/src/pages/config/components/CliInstallBanner.test.tsx rename to gui/src/components/CliInstallBanner.test.tsx index 5bf24bc2ead..6f3208c6591 100644 --- a/gui/src/pages/config/components/CliInstallBanner.test.tsx +++ b/gui/src/components/CliInstallBanner.test.tsx @@ -6,22 +6,22 @@ import { waitFor, } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { IdeMessengerContext } from "../../../context/IdeMessenger"; -import { MockIdeMessenger } from "../../../context/MockIdeMessenger"; -import * as util from "../../../util"; -import * as localStorage from "../../../util/localStorage"; +import { IdeMessengerContext } from "../../context/IdeMessenger"; +import { MockIdeMessenger } from "../../context/MockIdeMessenger"; +import * as util from "../../util"; +import * as localStorage from "../../util/localStorage"; import { CliInstallBanner } from "./CliInstallBanner"; -vi.mock("../../../util", async () => { - const actual = await vi.importActual("../../../util"); +vi.mock("../../util", async () => { + const actual = await vi.importActual("../../util"); return { ...actual, getPlatform: vi.fn(), }; }); -vi.mock("../../../util/localStorage", async () => { - const actual = await vi.importActual("../../../util/localStorage"); +vi.mock("../../util/localStorage", async () => { + const actual = await vi.importActual("../../util/localStorage"); return { ...actual, getLocalStorage: vi.fn(), diff --git a/gui/src/pages/config/components/CliInstallBanner.tsx b/gui/src/components/CliInstallBanner.tsx similarity index 89% rename from gui/src/pages/config/components/CliInstallBanner.tsx rename to gui/src/components/CliInstallBanner.tsx index 8747d7ecdf5..c7011f7b734 100644 --- a/gui/src/pages/config/components/CliInstallBanner.tsx +++ b/gui/src/components/CliInstallBanner.tsx @@ -1,12 +1,12 @@ import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useContext, useEffect, useState } from "react"; -import { CloseButton } from "../../../components"; -import { CopyButton } from "../../../components/StyledMarkdownPreview/StepContainerPreToolbar/CopyButton"; -import { RunInTerminalButton } from "../../../components/StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton"; -import { Card } from "../../../components/ui"; -import { IdeMessengerContext } from "../../../context/IdeMessenger"; -import { getPlatform } from "../../../util"; -import { getLocalStorage, setLocalStorage } from "../../../util/localStorage"; +import { CloseButton } from "."; +import { CopyButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/CopyButton"; +import { RunInTerminalButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton"; +import { Card } from "./ui"; +import { IdeMessengerContext } from "../context/IdeMessenger"; +import { getPlatform } from "../util"; +import { getLocalStorage, setLocalStorage } from "../util/localStorage"; interface CliInstallBannerProps { /** Current message count - banner shows only if >= messageThreshold */ diff --git a/gui/src/pages/config/index.tsx b/gui/src/pages/config/index.tsx index eae76dbd8c3..d34d4bcbc65 100644 --- a/gui/src/pages/config/index.tsx +++ b/gui/src/pages/config/index.tsx @@ -8,7 +8,7 @@ import { TabGroup } from "../../components/ui/TabGroup"; import { useAuth } from "../../context/Auth"; import { useNavigationListener } from "../../hooks/useNavigationListener"; import { bottomTabSections, getAllTabs, topTabSections } from "./configTabs"; -import { CliInstallBanner } from "./components/CliInstallBanner"; +import { CliInstallBanner } from "../../components/CliInstallBanner"; import { AccountDropdown } from "./features/account/AccountDropdown"; function ConfigPage() { diff --git a/gui/src/pages/gui/Chat.tsx b/gui/src/pages/gui/Chat.tsx index 0fabb5faf69..1bdc846e338 100644 --- a/gui/src/pages/gui/Chat.tsx +++ b/gui/src/pages/gui/Chat.tsx @@ -46,7 +46,7 @@ import { ToolCallDiv } from "./ToolCallDiv"; import { FatalErrorIndicator } from "../../components/config/FatalErrorNotice"; import InlineErrorMessage from "../../components/mainInput/InlineErrorMessage"; import { cancelStream } from "../../redux/thunks/cancelStream"; -import { CliInstallBanner } from "../config/components/CliInstallBanner"; +import { CliInstallBanner } from "../../components/CliInstallBanner"; import { EmptyChatBody } from "./EmptyChatBody"; import { ExploreDialogWatcher } from "./ExploreDialogWatcher"; import { useAutoScroll } from "./useAutoScroll"; From 039cab3776abad3ee75ce83248267522f97d4f08 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 16:44:07 -0700 Subject: [PATCH 15/21] improv: ui --- gui/src/components/CliInstallBanner.tsx | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/gui/src/components/CliInstallBanner.tsx b/gui/src/components/CliInstallBanner.tsx index c7011f7b734..5eb91af9865 100644 --- a/gui/src/components/CliInstallBanner.tsx +++ b/gui/src/components/CliInstallBanner.tsx @@ -1,12 +1,12 @@ import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useContext, useEffect, useState } from "react"; import { CloseButton } from "."; -import { CopyButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/CopyButton"; -import { RunInTerminalButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton"; -import { Card } from "./ui"; import { IdeMessengerContext } from "../context/IdeMessenger"; import { getPlatform } from "../util"; import { getLocalStorage, setLocalStorage } from "../util/localStorage"; +import { CopyButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/CopyButton"; +import { RunInTerminalButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton"; +import { Card } from "./ui"; interface CliInstallBannerProps { /** Current message count - banner shows only if >= messageThreshold */ @@ -77,7 +77,7 @@ export function CliInstallBanner({ (messageThreshold !== undefined && (messageCount === undefined || messageCount < messageThreshold)) ) { - return null; + // return null; } return ( @@ -113,11 +113,16 @@ export function CliInstallBanner({
-
- - npm i -g @continuedev/cli - -
+
+
+ + npm i -g @continuedev/cli + +
+
From fa6e558c45d227214a5f9ba78afe46d5104e80a1 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 16:47:05 -0700 Subject: [PATCH 16/21] improv: auto-copy --- gui/src/components/CliInstallBanner.tsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/gui/src/components/CliInstallBanner.tsx b/gui/src/components/CliInstallBanner.tsx index 5eb91af9865..cb3cd59ca9d 100644 --- a/gui/src/components/CliInstallBanner.tsx +++ b/gui/src/components/CliInstallBanner.tsx @@ -1,9 +1,10 @@ import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { CloseButton } from "."; import { IdeMessengerContext } from "../context/IdeMessenger"; import { getPlatform } from "../util"; import { getLocalStorage, setLocalStorage } from "../util/localStorage"; +import useCopy from "../hooks/useCopy"; import { CopyButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/CopyButton"; import { RunInTerminalButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton"; import { Card } from "./ui"; @@ -25,6 +26,21 @@ export function CliInstallBanner({ const ideMessenger = useContext(IdeMessengerContext); const [cliInstalled, setCliInstalled] = useState(null); const [dismissed, setDismissed] = useState(false); + const commandTextRef = useRef(null); + const { copyText } = useCopy("npm i -g @continuedev/cli"); + + const handleCommandClick = () => { + // Select the text + if (commandTextRef.current) { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(commandTextRef.current); + selection?.removeAllRanges(); + selection?.addRange(range); + } + // Copy to clipboard + copyText(); + }; useEffect(() => { // Check if user has permanently dismissed the banner @@ -116,8 +132,10 @@ export function CliInstallBanner({
npm i -g @continuedev/cli From 8ec8795d1d974414b7b8f2292c844d7e630bab34 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 16:58:39 -0700 Subject: [PATCH 17/21] improv: ui --- gui/src/components/CliInstallBanner.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/gui/src/components/CliInstallBanner.tsx b/gui/src/components/CliInstallBanner.tsx index cb3cd59ca9d..488b5333c46 100644 --- a/gui/src/components/CliInstallBanner.tsx +++ b/gui/src/components/CliInstallBanner.tsx @@ -28,6 +28,7 @@ export function CliInstallBanner({ const [dismissed, setDismissed] = useState(false); const commandTextRef = useRef(null); const { copyText } = useCopy("npm i -g @continuedev/cli"); + const [showCopiedMessage, setShowCopiedMessage] = useState(false); const handleCommandClick = () => { // Select the text @@ -40,6 +41,10 @@ export function CliInstallBanner({ } // Copy to clipboard copyText(); + + // Show "Copied!" message for 3 seconds + setShowCopiedMessage(true); + setTimeout(() => setShowCopiedMessage(false), 3000); }; useEffect(() => { @@ -130,7 +135,7 @@ export function CliInstallBanner({
-
+
npm i -g @continuedev/cli + {showCopiedMessage && ( + + Copied! + + )}
Date: Mon, 6 Oct 2025 17:06:55 -0700 Subject: [PATCH 18/21] improv: ui --- gui/src/components/CliInstallBanner.tsx | 4 ++-- gui/src/pages/gui/Chat.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gui/src/components/CliInstallBanner.tsx b/gui/src/components/CliInstallBanner.tsx index 488b5333c46..a6ceaa31207 100644 --- a/gui/src/components/CliInstallBanner.tsx +++ b/gui/src/components/CliInstallBanner.tsx @@ -2,9 +2,9 @@ import { CommandLineIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useContext, useEffect, useRef, useState } from "react"; import { CloseButton } from "."; import { IdeMessengerContext } from "../context/IdeMessenger"; +import useCopy from "../hooks/useCopy"; import { getPlatform } from "../util"; import { getLocalStorage, setLocalStorage } from "../util/localStorage"; -import useCopy from "../hooks/useCopy"; import { CopyButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/CopyButton"; import { RunInTerminalButton } from "./StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton"; import { Card } from "./ui"; @@ -98,7 +98,7 @@ export function CliInstallBanner({ (messageThreshold !== undefined && (messageCount === undefined || messageCount < messageThreshold)) ) { - // return null; + return null; } return ( diff --git a/gui/src/pages/gui/Chat.tsx b/gui/src/pages/gui/Chat.tsx index 1bdc846e338..d0c2f45b409 100644 --- a/gui/src/pages/gui/Chat.tsx +++ b/gui/src/pages/gui/Chat.tsx @@ -43,15 +43,15 @@ import { streamResponseThunk } from "../../redux/thunks/streamResponse"; import { isJetBrains, isMetaEquivalentKeyPressed } from "../../util"; import { ToolCallDiv } from "./ToolCallDiv"; +import { useStore } from "react-redux"; +import { CliInstallBanner } from "../../components/CliInstallBanner"; import { FatalErrorIndicator } from "../../components/config/FatalErrorNotice"; import InlineErrorMessage from "../../components/mainInput/InlineErrorMessage"; +import { RootState } from "../../redux/store"; import { cancelStream } from "../../redux/thunks/cancelStream"; -import { CliInstallBanner } from "../../components/CliInstallBanner"; import { EmptyChatBody } from "./EmptyChatBody"; import { ExploreDialogWatcher } from "./ExploreDialogWatcher"; import { useAutoScroll } from "./useAutoScroll"; -import { useStore } from "react-redux"; -import { RootState } from "../../redux/store"; // Helper function to find the index of the latest conversation summary function findLatestSummaryIndex(history: ChatHistoryItem[]): number { @@ -429,7 +429,7 @@ export function Chat() { From c1d09d8d2160658227431a77cc593381c9bc50f3 Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 17:10:18 -0700 Subject: [PATCH 19/21] fix: dismissal --- gui/src/pages/config/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/config/index.tsx b/gui/src/pages/config/index.tsx index d34d4bcbc65..d672b3d099c 100644 --- a/gui/src/pages/config/index.tsx +++ b/gui/src/pages/config/index.tsx @@ -94,7 +94,7 @@ function ConfigPage() {
{allTabs.find((tab) => tab.id === activeTab)?.component}
- +
From 914d587d11f67bff1f6ea7c882f2bbac5f1ee55e Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Mon, 6 Oct 2025 17:27:07 -0700 Subject: [PATCH 20/21] fix: type errors --- gui/src/components/CliInstallBanner.test.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gui/src/components/CliInstallBanner.test.tsx b/gui/src/components/CliInstallBanner.test.tsx index 6f3208c6591..5904df50d9b 100644 --- a/gui/src/components/CliInstallBanner.test.tsx +++ b/gui/src/components/CliInstallBanner.test.tsx @@ -6,22 +6,22 @@ import { waitFor, } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { IdeMessengerContext } from "../../context/IdeMessenger"; -import { MockIdeMessenger } from "../../context/MockIdeMessenger"; -import * as util from "../../util"; -import * as localStorage from "../../util/localStorage"; +import { IdeMessengerContext } from "../context/IdeMessenger"; +import { MockIdeMessenger } from "../context/MockIdeMessenger"; +import * as util from "../util"; +import * as localStorage from "../util/localStorage"; import { CliInstallBanner } from "./CliInstallBanner"; -vi.mock("../../util", async () => { - const actual = await vi.importActual("../../util"); +vi.mock("../util", async () => { + const actual = await vi.importActual("../util"); return { ...actual, getPlatform: vi.fn(), }; }); -vi.mock("../../util/localStorage", async () => { - const actual = await vi.importActual("../../util/localStorage"); +vi.mock("../util/localStorage", async () => { + const actual = await vi.importActual("../util/localStorage"); return { ...actual, getLocalStorage: vi.fn(), From fd3d402e9d193348104cc0d3cd3546df9d92030c Mon Sep 17 00:00:00 2001 From: Tomasz Stefaniak Date: Tue, 7 Oct 2025 20:44:22 -0700 Subject: [PATCH 21/21] fix: use session count instead of message count --- gui/src/components/CliInstallBanner.test.tsx | 26 ++++++++++---------- gui/src/components/CliInstallBanner.tsx | 18 +++++++------- gui/src/pages/gui/Chat.tsx | 12 ++++----- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/gui/src/components/CliInstallBanner.test.tsx b/gui/src/components/CliInstallBanner.test.tsx index 5904df50d9b..134b5aae214 100644 --- a/gui/src/components/CliInstallBanner.test.tsx +++ b/gui/src/components/CliInstallBanner.test.tsx @@ -351,10 +351,10 @@ describe("CliInstallBanner", () => { }); }); - describe("Message threshold logic", () => { - const renderWithMessageCount = async ( - messageCount?: number, - messageThreshold?: number, + describe("Session threshold logic", () => { + const renderWithSessionCount = async ( + sessionCount?: number, + sessionThreshold?: number, ) => { vi.spyOn(mockIdeMessenger.ide, "subprocess").mockResolvedValue([ "", @@ -365,8 +365,8 @@ describe("CliInstallBanner", () => { render( , ), @@ -374,7 +374,7 @@ describe("CliInstallBanner", () => { }; it("shows banner when no threshold is set", async () => { - await renderWithMessageCount(0, undefined); + await renderWithSessionCount(0, undefined); await waitFor(() => { expect( @@ -383,8 +383,8 @@ describe("CliInstallBanner", () => { }); }); - it("does not show banner when message count is below threshold", async () => { - await renderWithMessageCount(2, 3); + it("does not show banner when session count is below threshold", async () => { + await renderWithSessionCount(2, 3); await waitFor(() => { expect( @@ -393,8 +393,8 @@ describe("CliInstallBanner", () => { }); }); - it("shows banner when message count meets threshold", async () => { - await renderWithMessageCount(3, 3); + it("shows banner when session count meets threshold", async () => { + await renderWithSessionCount(3, 3); await waitFor(() => { expect( @@ -403,8 +403,8 @@ describe("CliInstallBanner", () => { }); }); - it("shows banner when message count exceeds threshold", async () => { - await renderWithMessageCount(5, 3); + it("shows banner when session count exceeds threshold", async () => { + await renderWithSessionCount(5, 3); await waitFor(() => { expect( diff --git a/gui/src/components/CliInstallBanner.tsx b/gui/src/components/CliInstallBanner.tsx index a6ceaa31207..36662309afb 100644 --- a/gui/src/components/CliInstallBanner.tsx +++ b/gui/src/components/CliInstallBanner.tsx @@ -10,17 +10,17 @@ import { RunInTerminalButton } from "./StyledMarkdownPreview/StepContainerPreToo import { Card } from "./ui"; interface CliInstallBannerProps { - /** Current message count - banner shows only if >= messageThreshold */ - messageCount?: number; - /** Minimum messages before showing banner (default: always show) */ - messageThreshold?: number; + /** Number of sessions user has had - banner shows only if >= sessionThreshold */ + sessionCount?: number; + /** Minimum sessions before showing banner (default: always show) */ + sessionThreshold?: number; /** If true, dismissal is permanent via localStorage (default: session only) */ permanentDismissal?: boolean; } export function CliInstallBanner({ - messageCount, - messageThreshold, + sessionCount, + sessionThreshold, permanentDismissal = false, }: CliInstallBannerProps = {}) { const ideMessenger = useContext(IdeMessengerContext); @@ -90,13 +90,13 @@ export function CliInstallBanner({ // - Still loading CLI status // - CLI is already installed // - User has dismissed it - // - Message threshold not met (if threshold is set) + // - Session threshold not met (if threshold is set) if ( cliInstalled === null || cliInstalled === true || dismissed || - (messageThreshold !== undefined && - (messageCount === undefined || messageCount < messageThreshold)) + (sessionThreshold !== undefined && + (sessionCount === undefined || sessionCount < sessionThreshold)) ) { return null; } diff --git a/gui/src/pages/gui/Chat.tsx b/gui/src/pages/gui/Chat.tsx index d0c2f45b409..61fc0a85976 100644 --- a/gui/src/pages/gui/Chat.tsx +++ b/gui/src/pages/gui/Chat.tsx @@ -119,6 +119,9 @@ export function Chat() { const isInEdit = useAppSelector((store) => store.session.isInEdit); const lastSessionId = useAppSelector((state) => state.session.lastSessionId); + const allSessionMetadata = useAppSelector( + (state) => state.session.allSessionMetadata, + ); const hasDismissedExploreDialog = useAppSelector( (state) => state.ui.hasDismissedExploreDialog, ); @@ -381,11 +384,6 @@ export function Chat() { const showScrollbar = showChatScrollbar ?? window.innerHeight > 5000; - // Count user messages for CLI banner threshold - const userMessageCount = useMemo(() => { - return history.filter((item) => item.message.role === "user").length; - }, [history]); - return ( <> {!!showSessionTabs && !isInEdit && } @@ -428,8 +426,8 @@ export function Chat() { />