From 1847615547d2039f7ebbfb788a0b22106b565d3e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 14 Sep 2026 14:59:15 -0700 Subject: [PATCH 01/11] feat(ui): add the terminal-tools announcement dialog and its one-time gate Announces Plannotator TUI and Herdr Annotate. One cookie, plannotator-announce-tui-herdr-seen, covers every surface, read through storage directly rather than the settings registry (configStore seeds registry defaults into cookies on first access, so a registry flag could not tell 'never seen' from 'seeded default'). The shell matches the other first-run announcements. Two departures: the backdrop dismisses, because the panel collects no decision, and the keydown listener runs on the capture phase and swallows Mod+Enter so a keystroke aimed at the announcement cannot approve a plan or post a review behind it. --- .../TerminalToolsAnnouncementDialog.tsx | 375 ++++++++++++++++++ .../ui/utils/terminalToolsAnnouncement.ts | 74 ++++ 2 files changed, 449 insertions(+) create mode 100644 packages/ui/components/TerminalToolsAnnouncementDialog.tsx create mode 100644 packages/ui/utils/terminalToolsAnnouncement.ts diff --git a/packages/ui/components/TerminalToolsAnnouncementDialog.tsx b/packages/ui/components/TerminalToolsAnnouncementDialog.tsx new file mode 100644 index 0000000000..4842cc5dc8 --- /dev/null +++ b/packages/ui/components/TerminalToolsAnnouncementDialog.tsx @@ -0,0 +1,375 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Check, Copy, Star } from 'lucide-react'; + +/** + * One-time announcement for Plannotator's two terminal clients, Plannotator TUI + * and Herdr Annotate. Same big-format shell as the other first-run + * announcements (LookAndFeelAnnouncementDialog, EditModeAnnouncementDialog), + * with one difference: it asks the user to decide nothing, so Escape, the + * backdrop and the single "Got it" button all do the same thing. + * + * LAST in each app's first-run dialog chain. The Apps gate rendering through + * terminalToolsAnnouncementCanShow so the chain dialogs never stack; see that + * function for why last rather than first. + */ + +const TUI_REPO = 'https://github.com/plannotator/plannotator-tui'; +const HERDR_REPO = 'https://github.com/plannotator/herdr-annotate'; +const PLANNOTATOR_REPO = 'https://github.com/backnotprop/plannotator'; +const FULL_DEMO = 'https://x.com/plannotator/status/2093419561077154287'; +const LITE_DEMO = 'https://x.com/plannotator/status/2092757422322627008'; + +interface TerminalToolsAnnouncementDialogProps { + readonly isOpen: boolean; + /** Marks the announcement seen and closes it. Also wired to Escape and the backdrop. */ + readonly onDismiss: () => void; +} + +function OutboundLink({ + href, + children, + icon, +}: { + readonly href: string; + readonly children: React.ReactNode; + readonly icon?: React.ReactNode; +}) { + return ( + + {icon} + {children} + + ); +} + +function CommandRow({ + command, + label, + copied, + onCopy, +}: { + readonly command: string; + /** Accessible name for the copy button; the command itself is long and noisy. */ + readonly label: string; + readonly copied: boolean; + readonly onCopy: (command: string) => void; +}) { + return ( +
+ + {command} + + +
+ ); +} + +/** + * Mock of a Plannotator TUI review: a selected block with its comment bubble, + * the annotation toolbar, and the status line. Built from theme tokens as real + * elements rather than an ASCII block, so it cannot misalign on a font fallback + * and it follows the active palette in both light and dark. + */ +function TuiPreview() { + return ( + + ); +} + +export function TerminalToolsAnnouncementDialog({ + isOpen, + onDismiss, +}: TerminalToolsAnnouncementDialogProps) { + const dismissRef = useRef(null); + const previousFocusRef = useRef(null); + const onDismissRef = useRef(onDismiss); + const [copied, setCopied] = useState(null); + const copiedTimer = useRef | null>(null); + + useEffect(() => { + onDismissRef.current = onDismiss; + }, [onDismiss]); + + const handleCopy = useCallback((command: string) => { + // Clipboard access can be denied or missing (insecure context, older + // browsers). The command stays selectable either way, so a failure is + // silent rather than an error the reader cannot act on. + void navigator.clipboard?.writeText(command).then( + () => { + setCopied(command); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(null), 2000); + }, + () => {}, + ); + }, []); + + useEffect(() => () => { + if (copiedTimer.current) clearTimeout(copiedTimer.current); + }, []); + + useEffect(() => { + if (!isOpen) return; + + previousFocusRef.current = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + dismissRef.current?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onDismissRef.current(); + return; + } + // Both apps submit their decision on Mod+Enter from a window-level + // handler. This listener is registered on the capture phase, so + // swallowing the chord here is what stops a keystroke aimed at the + // announcement from approving a plan or posting a review behind it. + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (event.key !== 'Tab') return; + + const dialog = document.querySelector('[data-terminal-tools-announcement-dialog]'); + const focusable = Array.from( + dialog?.querySelectorAll('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])') + ?? [], + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown, true); + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + previousFocusRef.current?.focus(); + previousFocusRef.current = null; + }; + }, [isOpen]); + + if (!isOpen) return null; + + return createPortal( +
{ + if (event.target === event.currentTarget) onDismissRef.current(); + }} + > +
+
+ + New + +

+ Plannotator now runs in the terminal +

+

+ Two new tools for people who would rather review in a terminal than in a browser. + Both turn your notes into feedback for your coding agent. +

+
+ +
+ + +
+
+

+ Plannotator TUI +

+

+ Markdown review in the terminal +

+

+ Select text, comment, mark it looks good or delete, then hand the review to your + agent as numbered feedback. Open a folder to get a file tree with per-file counts, + or run plannotator-tui last to + annotate one of your agent’s recent replies. One static binary, no runtime. +

+
+ + +
+

+ Prebuilt macOS, Linux and Windows binaries are on the{' '} + + releases page + + . +

+
+ See it in action + + Feedback +
+
+ +
+

+ Herdr Annotate +

+

+ Annotate inside Herdr +

+

+ Comment on any terminal text, review Markdown documents and your agent’s + replies, and send the feedback back to the agent as its next message. Document + review runs Plannotator TUI. Needs Herdr 0.8.0 or later, and you bind the keys in + Herdr’s config after installing. +

+
+ + +
+

+ Full covers macOS and Linux. Lite is terminal notes only: select text, press your + annotate key, comment in a popover. +

+
+ Full demo + Lite demo + + Feedback +
+
+
+
+ +
+

+ We’re enhancing these as much as we enhance this UI. Star them if they’re + useful, and open an issue when they’re not. +

+
+ + +
+
+
+
, + document.body, + ); +} diff --git a/packages/ui/utils/terminalToolsAnnouncement.ts b/packages/ui/utils/terminalToolsAnnouncement.ts new file mode 100644 index 0000000000..15c3a4da88 --- /dev/null +++ b/packages/ui/utils/terminalToolsAnnouncement.ts @@ -0,0 +1,74 @@ +/** + * One-time gate for the terminal-tools announcement (Plannotator TUI and + * Herdr Annotate). Cookie-backed like the other announcement gates, so a + * dismissal survives Plannotator's random localhost ports, and shared by the + * plan editor, the annotate surfaces and the code review editor: dismissing it + * anywhere retires it everywhere. + */ + +import { storage } from './storage'; + +const STORAGE_KEY = 'plannotator-announce-tui-herdr-seen'; +// Bump to re-announce after a meaningful revision. +const CURRENT_VERSION = '1'; + +export function needsTerminalToolsAnnouncement(): boolean { + return storage.getItem(STORAGE_KEY) !== CURRENT_VERSION; +} + +export function markTerminalToolsAnnouncementSeen(): void { + storage.setItem(STORAGE_KEY, CURRENT_VERSION); +} + +export interface TerminalToolsAnnouncementGateState { + /** + * Latched at mount from needsTerminalToolsAnnouncement(). Latched rather than + * read per render so a dismissal cannot unmount the dialog before its own + * click handler finishes. + */ + readonly announcementPending: boolean; + /** The app has not finished loading its initial payload. */ + readonly isLoading: boolean; + /** + * The session has no author to address: archive browsing, a read-only shared + * plan, the share portal. Telling a viewer about a CLI they did not open is + * noise, and the cookie is deliberately NOT consumed, so the next authoring + * session still shows it. + */ + readonly readOnlySession: boolean; + /** + * Plannotator's compact touch shell. The panel is desktop-shaped (install + * commands to copy, four outbound links) and a phone is not where anyone + * installs a terminal tool. Also deferred rather than consumed. + */ + readonly compact: boolean; + /** + * Any other first-run dialog is on screen. The chain dialogs never stack. + */ + readonly otherFirstRunDialogVisible: boolean; +} + +/** + * Chain gate for the announcement. It is LAST in each app's first-run dialog + * chain, after every dialog that asks the user to decide something (code + * review: guide intro, look-and-feel, review setup, edit mode, token hover + * cards; plan and annotate: look-and-feel, goal setup, permission mode). + * + * Last rather than first because none of those dialogs consume this cookie: + * a session that is busy asking questions defers the announcement to the next + * load instead of burning it. That also puts it in front of the right reader. + * Someone opening Plannotator for the first time is still learning this app; + * the people who should hear that it now runs in a terminal are the ones who + * already answered every setup question, and they see it on their next load. + */ +export function terminalToolsAnnouncementCanShow( + state: TerminalToolsAnnouncementGateState, +): boolean { + return ( + state.announcementPending && + !state.isLoading && + !state.readOnlySession && + !state.compact && + !state.otherFirstRunDialogVisible + ); +} From 5464a58de9365881bad45c84454e451cd38983b0 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 14 Sep 2026 14:59:15 -0700 Subject: [PATCH 02/11] feat(editor,review): show the terminal-tools announcement last in the first-run chain Both apps gate it through terminalToolsAnnouncementCanShow, behind every dialog that asks the user to decide something, and the destination spotlight and auto-viewed toast now defer behind it too. Last rather than first because none of those dialogs consumes this cookie: a session busy asking setup questions defers the announcement instead of burning it. Archive, read-only shared plans (the share portal), the compact touch shell and the initial load suppress it without consuming the cookie. --- packages/editor/App.tsx | 40 +++++++++++++++++++++ packages/review-editor/App.tsx | 66 ++++++++++++++++++++++++++++------ 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 29004bc873..aec30f8936 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -54,6 +54,12 @@ import { type AIProviderOption } from '@plannotator/ui/utils/aiProvider'; import { useAIProviderConfig } from '@plannotator/ui/hooks/useAIProviderConfig'; import { useAIProviderActivation } from '@plannotator/ui/hooks/useAIProviderActivation'; import { markLookAndFeelChoiceResolved, needsLookAndFeelAnnouncement } from '@plannotator/ui/utils/lookAndFeelAnnouncement'; +import { TerminalToolsAnnouncementDialog } from '@plannotator/ui/components/TerminalToolsAnnouncementDialog'; +import { + markTerminalToolsAnnouncementSeen, + needsTerminalToolsAnnouncement, + terminalToolsAnnouncementCanShow, +} from '@plannotator/ui/utils/terminalToolsAnnouncement'; import { buildDefaultPrompt, useAIChat } from '@plannotator/ui/hooks/useAIChat'; import { getUIPreferences, type UIPreferences, type PlanWidth } from '@plannotator/ui/utils/uiPreferences'; import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode'; @@ -672,6 +678,13 @@ const App: React.FC = () => { }, }); const [showLookAndFeelAnnouncement, setShowLookAndFeelAnnouncement] = useState(needsLookAndFeelAnnouncement); + // One-time terminal-tools announcement (Plannotator TUI + Herdr Annotate), + // shared with the code review editor through one cookie. Latched at mount: + // the dismiss writes the cookie, and re-reading it per render would unmount + // the dialog under its own click handler. + const [terminalToolsIntroPending, setTerminalToolsIntroPending] = useState( + needsTerminalToolsAnnouncement, + ); const isMobile = useIsMobile(); const isBelowAgentTerminalBreakpoint = useIsMobile(AGENT_TERMINAL_LG_BREAKPOINT); const isCompactTouchLayout = useCompactTouchLayout(); @@ -1021,6 +1034,11 @@ const App: React.FC = () => { setIsPanelOpen(prev => rightSidebarTab === 'annotations' ? !prev : true); }, [agentTerminalPlacement, exitWideMode, isAgentTerminalVisible, isCompactTouchLayout, openCompactPlanSurface, replaceRightAgentTerminalWithPanel, rightSidebarTab, wideModeType]); + const dismissTerminalToolsAnnouncement = useCallback(() => { + markTerminalToolsAnnouncementSeen(); + setTerminalToolsIntroPending(false); + }, []); + const dismissLookAndFeelAnnouncement = useCallback(() => { // Persist even when the user accepts the displayed default without first // clicking its already-selected card, then record the explicit decision. @@ -5574,6 +5592,18 @@ const App: React.FC = () => { !isSharedSession && !goalSetupMode && !showPermissionModeSetup; + // LAST in this app's first-run sequence: it asks for no decision, so it waits + // behind the look-and-feel chooser and the two setup flows. Archive browsing + // and a read-only shared plan (which is also what the share portal serves) + // have no one to address, so it is deferred there rather than consumed. + const shouldShowTerminalToolsAnnouncement = terminalToolsAnnouncementCanShow({ + announcementPending: terminalToolsIntroPending, + isLoading, + readOnlySession: isSharedSession || archive.archiveMode, + compact: isCompactTouchLayout, + otherFirstRunDialogVisible: + shouldShowLookAndFeelAnnouncement || goalSetupMode || showPermissionModeSetup, + }); const compactNavigatorTabs: SidebarTab[] = [ ...(hasTocEntries ? ['toc' as const] : []), ...(!isHtmlSurface && activeDiffVersionInfo !== null && activeDiffVersionInfo.totalVersions > 1 @@ -6744,6 +6774,16 @@ const App: React.FC = () => { onDismiss={dismissLookAndFeelAnnouncement} /> + {/* One-time Plannotator TUI + Herdr Annotate announcement, shared with + the code review editor. Renders only once the look-and-feel chooser + and the setup flows are done, so the first-run dialogs never stack. */} + {shouldShowTerminalToolsAnnouncement && ( + + )} + {/* Image Annotator for pasted images */} { useEffect(() => { if (shouldConsumeTokenHoverAnnouncement()) markTokenHoverAnnouncementSeen(); }, []); + // One-time terminal-tools announcement (Plannotator TUI + Herdr Annotate). + // LAST in the dialog chain, after every dialog that asks the reviewer to + // decide something. Latched at mount like its siblings: the dismiss writes + // the cookie, and re-reading it per render would unmount the dialog under + // its own click handler. + const [terminalToolsIntroPending, setTerminalToolsIntroPending] = useState( + needsTerminalToolsAnnouncement, + ); + const dismissTerminalToolsIntro = useCallback(() => { + markTerminalToolsAnnouncementSeen(); + setTerminalToolsIntroPending(false); + }, []); const aiChat = useAIChat({ patch: diffData?.rawPatch ?? '', diffType, @@ -1521,6 +1539,23 @@ const ReviewApp: React.FC = () => { reviewSetupVisible: showReviewSetup, editModeVisible: editModeIntroVisible, }); + // LAST in the first-run dialog chain: it asks for no decision, so it waits + // behind every dialog that does. terminalToolsAnnouncementCanShow explains + // why last rather than first. + const terminalToolsIntroVisible = terminalToolsAnnouncementCanShow({ + announcementPending: terminalToolsIntroPending, + isLoading, + // The code review editor has no archive or shared-session mode; the + // portable guide viewer never mounts this App. + readOnlySession: false, + compact: isCompactTouchLayout, + otherFirstRunDialogVisible: + guideIntroVisible + || showLookAndFeel + || showReviewSetup + || editModeIntroVisible + || tokenHoverIntroVisible, + }); const hoveredTokenSymbol = tokenHover.hover?.request.symbol; const startTokenHover = tokenHover.onTokenHoverEnter; // Stitching lives here, not in the diff views: rebuilding a fragmented @@ -2502,7 +2537,7 @@ const ReviewApp: React.FC = () => { // (not lost) behind the guide takeover or a first-run dialog — the file // still marks and the next auto-view retries the toast. if (!needsAutoViewedNotice()) return; - if (guideOpen || guideIntroVisible || showLookAndFeel || showReviewSetup || editModeIntroVisible || tokenHoverIntroVisible) return; + if (guideOpen || guideIntroVisible || showLookAndFeel || showReviewSetup || editModeIntroVisible || tokenHoverIntroVisible || terminalToolsIntroVisible) return; markAutoViewedNoticeSeen(); toast('Files are marked viewed as you scroll', { description: "Scroll past a file or move on to the next and it's checked off. Turn this off in Settings → Git, or from the gear above the file list.", @@ -2522,7 +2557,7 @@ const ReviewApp: React.FC = () => { }, }, }); - }, [guideOpen, guideIntroVisible, showLookAndFeel, showReviewSetup, editModeIntroVisible, tokenHoverIntroVisible]); + }, [guideOpen, guideIntroVisible, showLookAndFeel, showReviewSetup, editModeIntroVisible, tokenHoverIntroVisible, terminalToolsIntroVisible]); const { handleReadingFileChange: handleAutoViewReadingFile, handleFileScrolledPast } = useAutoViewed({ enabled: autoViewedEnabled, // Rule 4 — only the review target. The guide takeover CSS-hides the dock @@ -4192,7 +4227,7 @@ const ReviewApp: React.FC = () => { if (event.defaultPrevented || isNativeHistoryOwner(event)) return false; if (submitted || isSendingFeedback || isApproving || isExiting || isPlatformActioning || isLoadingDiff) return false; if (guideOpen || openSettingsMenu || showDestinationMenu || platformCommentDialog || showExportModal || showWorktreeDialog || showNoAnnotationsDialog || showExitWarning) return false; - if (showLookAndFeel || showGuideIntro || showReviewSetup || editModeIntroVisible || tokenHoverIntroVisible || tourDialogJobId) return false; + if (showLookAndFeel || showGuideIntro || showReviewSetup || editModeIntroVisible || tokenHoverIntroVisible || terminalToolsIntroVisible || tourDialogJobId) return false; return !hasActiveHistoryOverlay(document); }, [ guideOpen, @@ -4203,6 +4238,7 @@ const ReviewApp: React.FC = () => { isSendingFeedback, editModeIntroVisible, tokenHoverIntroVisible, + terminalToolsIntroVisible, openSettingsMenu, platformCommentDialog, showDestinationMenu, @@ -5581,20 +5617,28 @@ const ReviewApp: React.FC = () => { /> )} - {/* One-time token hover card announcement. LAST in the dialog chain + {/* One-time token hover card announcement. Fifth in the dialog chain (guide intro → look-and-feel → review setup → edit mode → token - hover) — tokenHoverAnnouncementCanShow gates on every earlier - dialog, so the chain dialogs never stack. */} + hover → terminal tools) — tokenHoverAnnouncementCanShow gates on + every earlier dialog, so the chain dialogs never stack. */} {tokenHoverIntroVisible && ( )} + {/* One-time Plannotator TUI + Herdr Annotate announcement. LAST in the + dialog chain (guide intro → look-and-feel → review setup → edit + mode → token hover → terminal tools) — terminalToolsAnnouncementCanShow + gates on every earlier dialog, so the chain dialogs never stack. */} + {terminalToolsIntroVisible && ( + + )} + {/* One-time PR feedback-destination spotlight. Strictly AFTER the - first-run dialog chain (guide intro → look-and-feel → review - setup → edit mode → token hover): it only mounts once none of the five is - showing, so it never stacks with them. PR mode only — the switcher - it points at doesn't render otherwise. */} - {showDestSpotlight && !isCompactTouchLayout && !!prMetadata && !isLoading && !showLookAndFeel && !guideIntroVisible && !showReviewSetup && !editModeIntroVisible && !tokenHoverIntroVisible && ( + first-run dialog chain (guide intro → look-and-feel → review setup + → edit mode → token hover → terminal tools): it only mounts once + none of the six is showing, so it never stacks with them. PR mode + only — the switcher it points at doesn't render otherwise. */} + {showDestSpotlight && !isCompactTouchLayout && !!prMetadata && !isLoading && !showLookAndFeel && !guideIntroVisible && !showReviewSetup && !editModeIntroVisible && !tokenHoverIntroVisible && !terminalToolsIntroVisible && ( Date: Mon, 14 Sep 2026 14:59:20 -0700 Subject: [PATCH 03/11] test: cover the terminal-tools announcement gate, dialog and chain ordering Existing App tests seed the new key so the announcement does not open over the surface they exercise. --- .github/workflows/test.yml | 2 + packages/editor/App.archiveReadOnly.test.tsx | 1 + .../editor/App.compactToc.mobile.test.tsx | 1 + packages/editor/App.decisionControl.test.tsx | 2 + packages/editor/App.editModeShortcut.test.tsx | 1 + packages/editor/App.htmlChrome.test.tsx | 2 + .../editor/App.permissionModeSetup.test.tsx | 1 + .../App.terminalToolsAnnouncement.test.tsx | 193 +++++++++++++++++ .../App.decisionControl.test.tsx | 41 +++- .../TerminalToolsAnnouncementDialog.test.tsx | 199 ++++++++++++++++++ .../utils/terminalToolsAnnouncement.test.ts | 75 +++++++ 11 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 packages/editor/App.terminalToolsAnnouncement.test.tsx create mode 100644 packages/ui/components/TerminalToolsAnnouncementDialog.test.tsx create mode 100644 packages/ui/utils/terminalToolsAnnouncement.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24f400e4a3..1c72617614 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -167,6 +167,8 @@ jobs: packages/editor/App.htmlChrome.test.tsx packages/editor/App.decisionControl.test.tsx packages/review-editor/App.decisionControl.test.tsx + packages/ui/components/TerminalToolsAnnouncementDialog.test.tsx + packages/editor/App.terminalToolsAnnouncement.test.tsx packages/review-editor/components/ReviewSidebar.generalComment.test.tsx packages/ui/components/HtmlSurfaceControls.test.tsx packages/ui/components/AnnotationPanel.unanchored.test.tsx diff --git a/packages/editor/App.archiveReadOnly.test.tsx b/packages/editor/App.archiveReadOnly.test.tsx index c0d2a735ab..9efe497e6d 100644 --- a/packages/editor/App.archiveReadOnly.test.tsx +++ b/packages/editor/App.archiveReadOnly.test.tsx @@ -7,6 +7,7 @@ const hasDom = typeof document !== "undefined"; if (hasDom) { document.cookie = "plannotator-look-feel-announcement-seen=2; path=/"; + document.cookie = "plannotator-announce-tui-herdr-seen=1; path=/"; document.cookie = "plannotator-vim-mode-announcement-seen=2; path=/"; document.cookie = "plannotator-plan-ai-announcement-seen=1; path=/"; } diff --git a/packages/editor/App.compactToc.mobile.test.tsx b/packages/editor/App.compactToc.mobile.test.tsx index d624acfb39..bfb203e32c 100644 --- a/packages/editor/App.compactToc.mobile.test.tsx +++ b/packages/editor/App.compactToc.mobile.test.tsx @@ -39,6 +39,7 @@ const memoryBackend: StorageBackend = { function seedAnnouncementsSeen(): void { memory.set("plannotator-look-feel-announcement-seen", "2"); + memory.set("plannotator-announce-tui-herdr-seen", "1"); memory.set("plannotator-vim-mode-announcement-seen", "2"); memory.set("plannotator-plan-ai-announcement-seen", "1"); } diff --git a/packages/editor/App.decisionControl.test.tsx b/packages/editor/App.decisionControl.test.tsx index 1d4b62e974..535321c666 100644 --- a/packages/editor/App.decisionControl.test.tsx +++ b/packages/editor/App.decisionControl.test.tsx @@ -36,6 +36,7 @@ const hasDom = typeof document !== "undefined"; if (hasDom) { document.cookie = "plannotator-look-feel-announcement-seen=2; path=/"; + document.cookie = "plannotator-announce-tui-herdr-seen=1; path=/"; document.cookie = "plannotator-vim-mode-announcement-seen=2; path=/"; document.cookie = "plannotator-plan-ai-announcement-seen=1; path=/"; } @@ -55,6 +56,7 @@ const memoryBackend: StorageBackend = { function seedAnnouncementsSeen(): void { memory.set("plannotator-look-feel-announcement-seen", "2"); + memory.set("plannotator-announce-tui-herdr-seen", "1"); memory.set("plannotator-vim-mode-announcement-seen", "2"); memory.set("plannotator-plan-ai-announcement-seen", "1"); } diff --git a/packages/editor/App.editModeShortcut.test.tsx b/packages/editor/App.editModeShortcut.test.tsx index 5e49d40532..56676fe3f7 100644 --- a/packages/editor/App.editModeShortcut.test.tsx +++ b/packages/editor/App.editModeShortcut.test.tsx @@ -35,6 +35,7 @@ const memoryBackend: StorageBackend = { function seedAnnouncementsSeen(): void { memory.set("plannotator-look-feel-announcement-seen", "2"); + memory.set("plannotator-announce-tui-herdr-seen", "1"); memory.set("plannotator-vim-mode-announcement-seen", "2"); memory.set("plannotator-plan-ai-announcement-seen", "1"); } diff --git a/packages/editor/App.htmlChrome.test.tsx b/packages/editor/App.htmlChrome.test.tsx index 108777c288..82310ee7fd 100644 --- a/packages/editor/App.htmlChrome.test.tsx +++ b/packages/editor/App.htmlChrome.test.tsx @@ -21,6 +21,7 @@ const hasDom = typeof document !== "undefined"; if (hasDom) { document.cookie = "plannotator-look-feel-announcement-seen=2; path=/"; + document.cookie = "plannotator-announce-tui-herdr-seen=1; path=/"; document.cookie = "plannotator-vim-mode-announcement-seen=2; path=/"; document.cookie = "plannotator-plan-ai-announcement-seen=1; path=/"; } @@ -60,6 +61,7 @@ const memoryBackend: StorageBackend = { function seedAnnouncementsSeen(): void { memory.set("plannotator-look-feel-announcement-seen", "2"); + memory.set("plannotator-announce-tui-herdr-seen", "1"); memory.set("plannotator-vim-mode-announcement-seen", "2"); memory.set("plannotator-plan-ai-announcement-seen", "1"); } diff --git a/packages/editor/App.permissionModeSetup.test.tsx b/packages/editor/App.permissionModeSetup.test.tsx index bf921ec029..1784d1c9d3 100644 --- a/packages/editor/App.permissionModeSetup.test.tsx +++ b/packages/editor/App.permissionModeSetup.test.tsx @@ -36,6 +36,7 @@ const memoryBackend: StorageBackend = { function seedAnnouncementsSeen(): void { memory.set("plannotator-look-feel-announcement-seen", "2"); + memory.set("plannotator-announce-tui-herdr-seen", "1"); memory.set("plannotator-vim-mode-announcement-seen", "2"); memory.set("plannotator-plan-ai-announcement-seen", "1"); } diff --git a/packages/editor/App.terminalToolsAnnouncement.test.tsx b/packages/editor/App.terminalToolsAnnouncement.test.tsx new file mode 100644 index 0000000000..710e1d4b2b --- /dev/null +++ b/packages/editor/App.terminalToolsAnnouncement.test.tsx @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +const hasDom = typeof document !== "undefined"; + +const storageModule = hasDom ? await import("@plannotator/ui/utils/storage") : null; +const appModule = hasDom ? await import("./App") : null; +const App = appModule?.default as typeof import("./App")["default"]; + +const originalFetch = globalThis.fetch; +const originalEventSource = globalThis.EventSource; +const originalMatchMedia = hasDom ? window.matchMedia : undefined; + +const ANNOUNCEMENT_KEY = "plannotator-announce-tui-herdr-seen"; +const LOOK_AND_FEEL_KEY = "plannotator-plan-look-choice-resolved"; +const DIALOG = "[data-terminal-tools-announcement-dialog]"; + +const memory = new Map(); +const memoryBackend = { + getItem: (key: string) => memory.get(key) ?? null, + setItem: (key: string, value: string) => void memory.set(key, value), + removeItem: (key: string) => void memory.delete(key), +}; + +class SilentEventSource { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 2; + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSED = 2; + readonly readyState = SilentEventSource.OPEN; + readonly url: string; + readonly withCredentials = false; + onerror: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onopen: ((event: Event) => void) | null = null; + constructor(url: string | URL) { this.url = String(url); } + addEventListener(): void {} + close(): void {} + dispatchEvent(): boolean { return true; } + removeEventListener(): void {} +} + +interface PlanResponse { + readonly plan: string; + readonly origin: "codex"; + readonly mode?: "archive" | "annotate"; + readonly filePath?: string; + readonly archivePlans?: readonly { + readonly filename: string; + readonly status: "approved"; + readonly timestamp: string; + readonly title: string; + }[]; + readonly sharingEnabled: false; + readonly serverConfig: Record; +} + +let root: Root | null = null; +let host: HTMLElement | null = null; + +function stubFetch(planResponse: PlanResponse): typeof fetch { + return async (input, init) => { + const rawUrl = input instanceof Request ? input.url : String(input); + if (rawUrl.startsWith("https://api.github.com/")) return new Response(null, { status: 404 }); + const url = new URL(rawUrl, "http://localhost"); + if (url.pathname === "/api/plan") return Response.json(planResponse); + if (url.pathname === "/api/archive/plans") { + return Response.json({ plans: planResponse.archivePlans ?? [] }); + } + if (url.pathname === "/api/archive/plan") { + return Response.json({ markdown: planResponse.plan, filepath: "saved.md" }); + } + if (url.pathname === "/api/ai/capabilities") return Response.json({ available: false, providers: [] }); + if (url.pathname === "/api/draft") return Response.json({ error: "Not found" }, { status: 404 }); + return Response.json({}); + }; +} + +function useCompactTouchMedia(): void { + window.matchMedia = ((query: string): MediaQueryList => ({ + matches: query.includes("max-width") || query.includes("pointer: coarse"), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + })) as typeof window.matchMedia; +} + +async function mountApp(planResponse: PlanResponse): Promise { + globalThis.fetch = stubFetch(planResponse); + // SAFETY: the App only uses EventSource's constructor, handlers, and close. + globalThis.EventSource = SilentEventSource as unknown as typeof EventSource; + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { root?.render(); }); + for (let attempt = 0; attempt < 20 && !document.body.textContent?.includes("Session document"); attempt += 1) { + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + } +} + +async function unmountApp(): Promise { + if (root) await act(async () => root?.unmount()); + root = null; + host?.remove(); + host = null; + if (hasDom) document.body.replaceChildren(); +} + +const PLAN: PlanResponse = { + plan: "# Session document\n\nA paragraph to review.", + origin: "codex", + sharingEnabled: false, + serverConfig: {}, +}; + +describe.if(hasDom)("terminal tools announcement in the plan editor", () => { + beforeEach(() => { + memory.clear(); + storageModule?.setStorageBackend(memoryBackend); + }); + + afterEach(async () => { + await unmountApp(); + globalThis.fetch = originalFetch; + globalThis.EventSource = originalEventSource; + if (originalMatchMedia) window.matchMedia = originalMatchMedia; + storageModule?.resetStorageBackend(); + memory.clear(); + }); + + test("announces once, then never again in this browser", async () => { + memory.set(LOOK_AND_FEEL_KEY, "true"); + await mountApp(PLAN); + + const dialog = document.querySelector(DIALOG); + expect(dialog).not.toBeNull(); + expect(memory.has(ANNOUNCEMENT_KEY)).toBe(false); + + const gotIt = Array.from(document.querySelectorAll(`${DIALOG} button`)) + .find((button) => button.textContent?.trim() === "Got it"); + if (!gotIt) throw new Error("Dismiss action did not render"); + await act(async () => gotIt.click()); + + expect(document.querySelector(DIALOG)).toBeNull(); + expect(memory.get(ANNOUNCEMENT_KEY)).toBe("1"); + + await unmountApp(); + await mountApp(PLAN); + expect(document.querySelector(DIALOG)).toBeNull(); + }); + + test("defers behind the look-and-feel chooser without spending the announcement", async () => { + // No look-and-feel marker: that chooser owns this session. + await mountApp(PLAN); + + expect(document.querySelector('[aria-labelledby="plan-look-choice-title"]')).not.toBeNull(); + expect(document.querySelector(DIALOG)).toBeNull(); + expect(memory.has(ANNOUNCEMENT_KEY)).toBe(false); + }); + + test("stays out of a read-only archive session and keeps the announcement for later", async () => { + memory.set(LOOK_AND_FEEL_KEY, "true"); + await mountApp({ + ...PLAN, + mode: "archive", + archivePlans: [{ + filename: "saved.md", + status: "approved", + timestamp: "2026-07-31T00:00:00.000Z", + title: "Session document", + }], + }); + + expect(document.querySelector(DIALOG)).toBeNull(); + expect(memory.has(ANNOUNCEMENT_KEY)).toBe(false); + }); + + test("stays out of the compact touch shell and keeps the announcement for later", async () => { + memory.set(LOOK_AND_FEEL_KEY, "true"); + useCompactTouchMedia(); + await mountApp(PLAN); + + expect(document.querySelector(DIALOG)).toBeNull(); + expect(memory.has(ANNOUNCEMENT_KEY)).toBe(false); + }); +}); diff --git a/packages/review-editor/App.decisionControl.test.tsx b/packages/review-editor/App.decisionControl.test.tsx index 09e255ce3d..1abbe9b426 100644 --- a/packages/review-editor/App.decisionControl.test.tsx +++ b/packages/review-editor/App.decisionControl.test.tsx @@ -63,9 +63,11 @@ const memoryBackend: StorageBackend = { }; /** Suppress the one-time dialog chain (guide intro → look-and-feel → review - * setup → edit mode) so the header is interactable on first render. */ + * setup → edit mode → token hover → terminal tools) so the header is + * interactable on first render. */ function seedFirstRunSeen(): void { memory.set("plannotator-plan-look-choice-resolved", "true"); + memory.set("plannotator-announce-tui-herdr-seen", "1"); memory.set("plannotator-guide-intro-seen", "2"); memory.set("plannotator-guide-hint-acked", "true"); memory.set("plannotator-review-setup-seen", "true"); @@ -265,9 +267,22 @@ async function settle(): Promise { }); } -async function mount(waitFor: () => unknown): Promise { - setStorageBackend(memoryBackend); +/** seedFirstRunSeen minus the terminal-tools announcement, so one test can + * watch that dialog take its turn at the end of the chain. */ +function seedFirstRunSeenExceptTerminalTools(): void { seedFirstRunSeen(); + // Not in seedFirstRunSeen: this harness leaves the token hover announcement + // pending, and it sits directly ahead of the terminal-tools one. + memory.set("plannotator-token-hover-announcement-seen", "1"); + memory.delete("plannotator-announce-tui-herdr-seen"); +} + +async function mount( + waitFor: () => unknown, + seed: () => void = seedFirstRunSeen, +): Promise { + setStorageBackend(memoryBackend); + seed(); globalThis.fetch = makeFetch(); // SAFETY: the App only uses EventSource's constructor, handlers, and close. globalThis.EventSource = StubEventSource as unknown as typeof EventSource; @@ -623,6 +638,26 @@ describe.if(hasDom)("review decision control (agent mode)", () => { // Guards the exact regression this project exists to fix on the surface // that has no Mod+Enter (E16-review): compact at zero must offer a visible // positive decision row, and it must post the bare approval body. + test("the terminal-tools announcement takes its turn once the rest of the chain is done", async () => { + const DIALOG = "[data-terminal-tools-announcement-dialog]"; + await mount(() => document.querySelector(DIALOG), seedFirstRunSeenExceptTerminalTools); + + // Every other test in this file seeds the key and sees no dialog, so this + // pair is what proves the review chain hands the last turn over rather + // than swallowing it. + expect(document.querySelector(DIALOG)).not.toBeNull(); + + const gotIt = Array.from(document.querySelectorAll(`${DIALOG} button`)) + .find((button) => button.textContent?.trim() === "Got it"); + if (!gotIt) throw new Error("Dismiss action did not render"); + await act(async () => gotIt.click()); + await settle(); + + expect(document.querySelector(DIALOG)).toBeNull(); + expect(memory.get("plannotator-announce-tui-herdr-seen")).toBe("1"); + expect(submissions).toHaveLength(0); + }); + test("compact touch offers a positive decision row at zero and it posts", async () => { // SAFETY: implements the MediaQueryList surface the shell hooks consume; // coarse-pointer matches put the app in its compact touch layout. diff --git a/packages/ui/components/TerminalToolsAnnouncementDialog.test.tsx b/packages/ui/components/TerminalToolsAnnouncementDialog.test.tsx new file mode 100644 index 0000000000..cabf596077 --- /dev/null +++ b/packages/ui/components/TerminalToolsAnnouncementDialog.test.tsx @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import React, { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { TerminalToolsAnnouncementDialog } from './TerminalToolsAnnouncementDialog'; + +const hasDom = typeof document !== 'undefined'; +let root: Root | null = null; +let host: HTMLElement | null = null; + +const DIALOG = '[data-terminal-tools-announcement-dialog]'; + +async function mountDialog(onDismiss: () => void = () => {}) { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root?.render(); + }); +} + +/** Holds the open state so a dismiss actually unmounts, like the Apps do. */ +function Harness({ onDismiss }: { readonly onDismiss: () => void }) { + const [open, setOpen] = useState(true); + return ( + { + onDismiss(); + setOpen(false); + }} + /> + ); +} + +async function mountHarness(onDismiss: () => void) { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root?.render(); + }); +} + +function gotItButton(): HTMLButtonElement { + const match = Array.from(document.querySelectorAll(`${DIALOG} button`)) + .find((button) => button.textContent?.trim() === 'Got it'); + if (!match) throw new Error('Dismiss action did not render'); + return match; +} + +describe('TerminalToolsAnnouncementDialog', () => { + afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + host?.remove(); + host = null; + if (hasDom) document.body.replaceChildren(); + }); + + test.skipIf(!hasDom)('renders as a labelled modal with exactly one completion action', async () => { + await mountDialog(); + + const dialog = document.querySelector(DIALOG); + expect(dialog?.getAttribute('role')).toBe('dialog'); + expect(dialog?.getAttribute('aria-modal')).toBe('true'); + const labelledBy = dialog?.getAttribute('aria-labelledby'); + const describedBy = dialog?.getAttribute('aria-describedby'); + expect(document.getElementById(labelledBy ?? '')).not.toBeNull(); + expect(document.getElementById(describedBy ?? '')).not.toBeNull(); + + // One primary way out, so the announcement can never read as a choice. + const completions = Array.from(document.querySelectorAll(`${DIALOG} button`)) + .filter((button) => button.textContent?.trim() === 'Got it'); + expect(completions).toHaveLength(1); + expect(document.activeElement).toBe(completions[0]); + }); + + test.skipIf(!hasDom)('sends every outbound link to a new tab with an isolated opener', async () => { + await mountDialog(); + + const links = Array.from(document.querySelectorAll(`${DIALOG} a[href]`)); + expect(links.length).toBeGreaterThan(0); + for (const link of links) { + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + expect(link.getAttribute('href')).toMatch(/^https:\/\//); + } + }); + + test.skipIf(!hasDom)('the button, Escape and the backdrop all dismiss', async () => { + const onDismiss = mock(() => {}); + await mountHarness(onDismiss); + await act(async () => gotItButton().click()); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(document.querySelector(DIALOG)).toBeNull(); + + if (root) await act(async () => root?.unmount()); + host?.remove(); + host = null; + document.body.replaceChildren(); + + const onEscape = mock(() => {}); + await mountHarness(onEscape); + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + expect(onEscape).toHaveBeenCalledTimes(1); + expect(document.querySelector(DIALOG)).toBeNull(); + + if (root) await act(async () => root?.unmount()); + host?.remove(); + host = null; + document.body.replaceChildren(); + + const onBackdrop = mock(() => {}); + await mountHarness(onBackdrop); + const backdrop = document.querySelector(DIALOG)?.parentElement; + if (!backdrop) throw new Error('Backdrop did not render'); + await act(async () => { + backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + expect(onBackdrop).toHaveBeenCalledTimes(1); + expect(document.querySelector(DIALOG)).toBeNull(); + }); + + test.skipIf(!hasDom)('a press inside the panel is not a backdrop dismissal', async () => { + const onDismiss = mock(() => {}); + await mountHarness(onDismiss); + + const panel = document.querySelector(DIALOG); + if (!panel) throw new Error('Dialog did not render'); + await act(async () => { + panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(document.querySelector(DIALOG)).not.toBeNull(); + }); + + test.skipIf(!hasDom)('swallows Mod+Enter so the app behind it cannot submit a decision', async () => { + const appHandler = mock(() => {}); + document.addEventListener('keydown', appHandler); + try { + await mountDialog(); + await act(async () => { + gotItButton().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }), + ); + gotItButton().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }), + ); + }); + expect(appHandler).not.toHaveBeenCalled(); + } finally { + document.removeEventListener('keydown', appHandler); + } + }); + + test.skipIf(!hasDom)('Tab wraps inside the dialog instead of escaping into the page', async () => { + const outside = document.createElement('button'); + outside.textContent = 'behind the dialog'; + document.body.appendChild(outside); + try { + await mountDialog(); + const focusable = Array.from( + document.querySelectorAll(`${DIALOG} button, ${DIALOG} [href]`), + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + last.focus(); + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + }); + expect(document.activeElement).toBe(first); + + await act(async () => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }), + ); + }); + expect(document.activeElement).toBe(last); + } finally { + outside.remove(); + } + }); + + test.skipIf(!hasDom)('renders nothing at all while closed', async () => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root?.render( {}} />); + }); + + expect(document.querySelector(DIALOG)).toBeNull(); + expect(host.childNodes).toHaveLength(0); + }); +}); diff --git a/packages/ui/utils/terminalToolsAnnouncement.test.ts b/packages/ui/utils/terminalToolsAnnouncement.test.ts new file mode 100644 index 0000000000..8aa5ea2f6b --- /dev/null +++ b/packages/ui/utils/terminalToolsAnnouncement.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + markTerminalToolsAnnouncementSeen, + needsTerminalToolsAnnouncement, + terminalToolsAnnouncementCanShow, + type TerminalToolsAnnouncementGateState, +} from './terminalToolsAnnouncement'; +import { resetStorageBackend, setStorageBackend, type StorageBackend } from './storage'; + +const memory = new Map(); +const memoryBackend: StorageBackend = { + getItem: (key) => memory.get(key) ?? null, + setItem: (key, value) => void memory.set(key, value), + removeItem: (key) => void memory.delete(key), +}; + +function showable(overrides: Partial = {}) { + return terminalToolsAnnouncementCanShow({ + announcementPending: true, + isLoading: false, + readOnlySession: false, + compact: false, + otherFirstRunDialogVisible: false, + ...overrides, + }); +} + +afterEach(() => { + memory.clear(); + resetStorageBackend(); +}); + +describe('terminal tools announcement gate', () => { + test('marking it seen is what retires it, and nothing else writes', () => { + setStorageBackend(memoryBackend); + + expect(needsTerminalToolsAnnouncement()).toBe(true); + // Reading must not seed the key: a registry-backed flag would, which is why + // this gate reads storage directly (see lookAndFeelAnnouncement.ts). + expect(memory.size).toBe(0); + + markTerminalToolsAnnouncementSeen(); + expect(needsTerminalToolsAnnouncement()).toBe(false); + }); + + test('a value from a different announcement version does not count as seen', () => { + setStorageBackend(memoryBackend); + memory.set('plannotator-announce-tui-herdr-seen', 'true'); + + // The house pattern is an exact version match, so bumping the version in + // the module re-announces rather than silently staying dismissed. + expect(needsTerminalToolsAnnouncement()).toBe(true); + }); + + test('every suppressing condition independently withholds the dialog', () => { + expect(showable()).toBe(true); + expect(showable({ announcementPending: false })).toBe(false); + expect(showable({ isLoading: true })).toBe(false); + expect(showable({ readOnlySession: true })).toBe(false); + expect(showable({ compact: true })).toBe(false); + expect(showable({ otherFirstRunDialogVisible: true })).toBe(false); + }); + + test('suppression is deferral: the gate never touches the cookie', () => { + setStorageBackend(memoryBackend); + + showable({ readOnlySession: true }); + showable({ compact: true }); + showable({ otherFirstRunDialogVisible: true }); + + // A session that could not show the announcement must still show it next + // time, so none of those paths may consume the one-shot key. + expect(needsTerminalToolsAnnouncement()).toBe(true); + }); +}); From 19d3fdc27adf277683f3ab154add7dc58be1414f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 14 Sep 2026 14:59:20 -0700 Subject: [PATCH 04/11] docs: document the terminal-tools announcement and its manual checklist --- AGENTS.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- tests/UI-TESTING.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b3653d1f94..b07ba724d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,7 +305,50 @@ The default code-review diff is **`since-base`** — a composite of `merge-base( **Staging display invariant:** `useGitAdd`'s `stagedFiles` is the EFFECTIVE staged set (sections-sidecar snapshot + session stage/unstage overrides) and is the only source any surface may render staging state from. The sidecar entry's `staged` flag is a snapshot — ORing it back in makes files unstaged mid-session render as staged (and inverts the next toggle). -`since-base` is only offered when the base ref actually resolves — on a repo whose trunk isn't discoverable (`trunk`, no `origin/HEAD`) `getGitContext` omits it and the default falls through to `uncommitted`, so committed branch work is never silently hidden. The since-base patch/sections/fingerprint/file-content paths all degrade to `HEAD` together when merge-base fails for a resolvable-but-unrelated base. First-run shows `ReviewSetupDialog` (replaces the removed `DiffTypeSetupDialog`), which initializes an unseen reviewer's panel to Tree once while preserving the resolved diff default, and is reopenable from the review header menu. The one-time dialog chain is guide intro → look-and-feel → review setup → Edit Mode → token hover cards; none of the dialogs stack. The token hover announcement is last and additionally skips a session where hover cards cannot run at all (no live workspace), WITHOUT consuming its cookie, and never shows to a reviewer whose trigger is already non-default (which after the boolean-to-trigger migration is exactly the early adopter who turned cards off). Analysis layers no longer add a startup dialog: Semantic Changes retains its enabled default, while Call Flow remains disabled until the user explicitly enables it in Settings, which is also consent for its managed runtime installation. +`since-base` is only offered when the base ref actually resolves — on a repo whose trunk isn't discoverable (`trunk`, no `origin/HEAD`) `getGitContext` omits it and the default falls through to `uncommitted`, so committed branch work is never silently hidden. The since-base patch/sections/fingerprint/file-content paths all degrade to `HEAD` together when merge-base fails for a resolvable-but-unrelated base. First-run shows `ReviewSetupDialog` (replaces the removed `DiffTypeSetupDialog`), which initializes an unseen reviewer's panel to Tree once while preserving the resolved diff default, and is reopenable from the review header menu. The one-time dialog chain is guide intro → look-and-feel → review setup → Edit Mode → token hover cards → the terminal-tools announcement; none of the dialogs stack. The token hover announcement is last and additionally skips a session where hover cards cannot run at all (no live workspace), WITHOUT consuming its cookie, and never shows to a reviewer whose trigger is already non-default (which after the boolean-to-trigger migration is exactly the early adopter who turned cards off). Analysis layers no longer add a startup dialog: Semantic Changes retains its enabled default, while Call Flow remains disabled until the user explicitly enables it in Settings, which is also consent for its managed runtime installation. + +### First-run terminal-tools announcement + +A one-time panel announcing Plannotator's two terminal clients, **Plannotator +TUI** (`plannotator/plannotator-tui`) and **Herdr Annotate** +(`plannotator/herdr-annotate`). One cookie covers every surface — +`plannotator-announce-tui-herdr-seen`, value `'1'`, read and written by +`needsTerminalToolsAnnouncement()` / `markTerminalToolsAnnouncementSeen()` in +`packages/ui/utils/terminalToolsAnnouncement.ts` — so dismissing it in plan +review retires it in annotate and code review too, and vice versa. It is a +plain storage key rather than a settings-registry entry for the same reason +`lookAndFeelAnnouncement.ts` is: `configStore.ensureLoaded` seeds every +registry default into a cookie on first access, so a registry-backed flag could +never tell "never seen" from "seeded default". + +The component is `packages/ui/components/TerminalToolsAnnouncementDialog.tsx` +(shared shell: portal, `z-[100]`, `max-w-5xl`, hand-rolled Escape + Tab wrap + +focus restore, `data-terminal-tools-announcement-dialog`). Two deliberate +departures from its siblings: the backdrop dismisses (the panel collects no +decision, so there is nothing to lose by closing it impatiently), and its +keydown listener is registered on the CAPTURE phase and swallows `Mod+Enter`, +so a keystroke aimed at the announcement cannot approve a plan or post a review +behind it. + +**Ordering: LAST in each app's chain, never first.** Code review gates it +through `terminalToolsAnnouncementCanShow` on guide intro, look-and-feel, review +setup, Edit Mode and token hover cards; the plan editor gates it on the +look-and-feel chooser, goal setup and permission-mode setup. The destination +spotlight and the auto-viewed toast were extended to defer behind it too, so +nothing stacks. Last rather than first because none of those dialogs consumes +this cookie: a session busy asking setup questions defers the announcement to +the next load instead of burning it, and that also puts it in front of the right +reader — someone opening Plannotator for the first time is still learning this +app, while the people who should hear that it now runs in a terminal are the +ones who already answered every setup question and see it on their next load. + +Suppressed, never consumed, on: archive browsing and read-only shared plans +(which is also what the share portal serves, via `isSharedSession`), the +compact touch shell, and while the initial payload is still loading. The +guides.show portable viewer never mounts either App, so it is unaffected by +construction. `@plannotator/ui` exposes no first-run-suppression seam on +`configurePlannotatorUI`; a host that wants the announcement off installs its +own `storageBackend` (the documented escape hatch) and pre-seeds the key. ### GitButler review invariants diff --git a/tests/UI-TESTING.md b/tests/UI-TESTING.md index 2e9ac6f317..df21e56b50 100644 --- a/tests/UI-TESTING.md +++ b/tests/UI-TESTING.md @@ -11,6 +11,7 @@ steps to ensure your changes work correctly. 4. [Debugging Common Issues](#debugging-common-issues) 5. [Decision Control Manual Checklist](#decision-control-manual-checklist) 6. [WebMCP Manual Checklist](#webmcp-manual-checklist) +7. [Terminal-Tools Announcement Manual Checklist](#terminal-tools-announcement-manual-checklist) --- @@ -444,6 +445,35 @@ Then the remaining surfaces: - `plannotator annotate ` and `plannotator annotate http://localhost:`: from inside the iframe, `document.modelContext.getTools()` and `registerTool()` reject with `NotAllowedError`; the parent page still lists Plannotator's tools. - Approve or send feedback from the page: the write tools disappear from `getTools()` and `read_document` carries `session_decided`. +## Terminal-Tools Announcement Manual Checklist + +Not CI. The gate is one cookie, `plannotator-announce-tui-herdr-seen`; clear it in DevTools +(Application, Cookies) between runs, and keep the other first-run keys seeded so the chain does +not hand the turn to an earlier dialog. + +1. **It shows once.** Clear the cookie, open `plannotator review` (or a plan or annotate + session), and dismiss everything else the session asks for. The announcement fills most of the + window over a dimmed backdrop. Click **Got it**: it closes, the cookie reads `1`, and a reload + never shows it again. +2. **Every exit marks it seen.** Repeat with `Escape`, then again with a click on the dimmed area + outside the panel. Both close it and both write the cookie. A press that starts inside the + panel and drags out does not close it. +3. **It never stacks.** Clear the cookie AND `plannotator-plan-look-choice-resolved`. The + Grid/Clean chooser opens alone. Dismiss it and reload: the announcement is now the one on + screen. Nothing ever shows two dialogs at once. +4. **It stays out of read-only and compact.** With the cookie cleared, run `plannotator archive`, + open a `#share` link, and open any session in a phone-sized touch viewport. No announcement in + any of them, and the cookie is still unset afterwards, so the next desktop session shows it. +5. **Keyboard.** With the panel open, `Tab` cycles only inside it and wraps at both ends; + `Mod+Enter` does nothing (no plan approved, no review posted). Closing it returns focus to the + app. +6. **Content.** Both copy buttons put the exact install command on the clipboard and flip to a + check for two seconds. Every link opens in a new tab: the two X demos, the three GitHub repos, + the two issue trackers, and the releases page. +7. **Theme and width.** Toggle light/dark: the panel, the terminal preview and the code rows all + follow the active palette. At ~400px wide the cards stack, nothing overflows horizontally, and + the footer wraps. + ## Need Help? If you're stuck: From 75e921831644b927feb8300d4a6ed5fe4fb0b1c1 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 14 Sep 2026 15:02:45 -0700 Subject: [PATCH 05/11] fix(ui): wrap long install commands in the announcement instead of clipping them At the card width the Homebrew line was cut mid-path with no affordance. --- packages/ui/components/TerminalToolsAnnouncementDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/components/TerminalToolsAnnouncementDialog.tsx b/packages/ui/components/TerminalToolsAnnouncementDialog.tsx index 4842cc5dc8..ba49d954c2 100644 --- a/packages/ui/components/TerminalToolsAnnouncementDialog.tsx +++ b/packages/ui/components/TerminalToolsAnnouncementDialog.tsx @@ -62,7 +62,7 @@ function CommandRow({ }) { return (
- + {command} + + {/* The last source is the one whose error means nothing could load. */} + setFailed(true)} /> + + + {failed ? ( + + ) : ( + + )}
); } -/** - * Mock of a Plannotator TUI review: a selected block with its comment bubble, - * the annotation toolbar, and the status line. Built from theme tokens as real - * elements rather than an ASCII block, so it cannot misalign on a font fallback - * and it follows the active palette in both light and dark. - */ -function TuiPreview() { +interface DemoSwitchProps { + readonly active: TerminalToolsDemo['id']; + readonly onChange: (id: TerminalToolsDemo['id']) => void; +} + +function DemoSwitch({ active, onChange }: DemoSwitchProps) { + const tabRefs = useRef>({}); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const index = TERMINAL_TOOLS_DEMOS.findIndex((demo) => demo.id === active); + const step = event.key === 'ArrowRight' ? 1 : -1; + const next = TERMINAL_TOOLS_DEMOS[ + (index + step + TERMINAL_TOOLS_DEMOS.length) % TERMINAL_TOOLS_DEMOS.length + ]; + onChange(next.id); + tabRefs.current[next.id]?.focus(); + }; + return ( ); } @@ -126,35 +275,21 @@ function TuiPreview() { export function TerminalToolsAnnouncementDialog({ isOpen, onDismiss, + reducedMotion, }: TerminalToolsAnnouncementDialogProps) { const dismissRef = useRef(null); const previousFocusRef = useRef(null); const onDismissRef = useRef(onDismiss); - const [copied, setCopied] = useState(null); - const copiedTimer = useRef | null>(null); + const [activeDemoId, setActiveDemoId] = useState('full'); + const [motionPreference] = useState(() => reducedMotion ?? prefersReducedMotion()); + const reduced = reducedMotion ?? motionPreference; + const activeDemo = + TERMINAL_TOOLS_DEMOS.find((demo) => demo.id === activeDemoId) ?? TERMINAL_TOOLS_DEMOS[0]; useEffect(() => { onDismissRef.current = onDismiss; }, [onDismiss]); - const handleCopy = useCallback((command: string) => { - // Clipboard access can be denied or missing (insecure context, older - // browsers). The command stays selectable either way, so a failure is - // silent rather than an error the reader cannot act on. - void navigator.clipboard?.writeText(command).then( - () => { - setCopied(command); - if (copiedTimer.current) clearTimeout(copiedTimer.current); - copiedTimer.current = setTimeout(() => setCopied(null), 2000); - }, - () => {}, - ); - }, []); - - useEffect(() => () => { - if (copiedTimer.current) clearTimeout(copiedTimer.current); - }, []); - useEffect(() => { if (!isOpen) return; @@ -185,7 +320,7 @@ export function TerminalToolsAnnouncementDialog({ const focusable = Array.from( dialog?.querySelectorAll('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])') ?? [], - ); + ).filter((element) => element.getAttribute('tabindex') !== '-1'); if (focusable.length === 0) return; const first = focusable[0]; @@ -211,7 +346,7 @@ export function TerminalToolsAnnouncementDialog({ return createPortal(
{ @@ -224,150 +359,64 @@ export function TerminalToolsAnnouncementDialog({ aria-modal="true" aria-labelledby="terminal-tools-announcement-title" aria-describedby="terminal-tools-announcement-description" - className="flex max-h-[calc(100dvh-2rem)] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl" + // Width follows the viewport height as well as its width: the footage + // keeps its aspect ratio, so on a short window the panel narrows until + // video plus footer fit instead of scrolling the video out of view. + // The 12rem is the footer's height, with the wrap at narrow widths. + style={{ + width: `min(1120px, 100%, calc((100dvh - 2rem - 12rem) * ${DEMO_ASPECT}))`, + }} + className="terminal-tools-announcement-dialog flex max-h-[calc(100dvh-2rem)] min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl" > -
- - New - -

- Plannotator now runs in the terminal -

-

- Two new tools for people who would rather review in a terminal than in a browser. - Both turn your notes into feedback for your coding agent. -

-
- -
- - -
-
-

- Plannotator TUI -

-

- Markdown review in the terminal -

-

- Select text, comment, mark it looks good or delete, then hand the review to your - agent as numbered feedback. Open a folder to get a file tree with per-file counts, - or run plannotator-tui last to - annotate one of your agent’s recent replies. One static binary, no runtime. -

-
- - -
-

- Prebuilt macOS, Linux and Windows binaries are on the{' '} - - releases page - - . -

-
- See it in action - - Feedback -
-
- -
-

- Herdr Annotate -

-

- Annotate inside Herdr -

-

- Comment on any terminal text, review Markdown documents and your agent’s - replies, and send the feedback back to the agent as its next message. Document - review runs Plannotator TUI. Needs Herdr 0.8.0 or later, and you bind the keys in - Herdr’s config after installing. -

-
- - -
-

- Full covers macOS and Linux. Lite is terminal notes only: select text, press your - annotate key, comment in a popover. +

+ {/* Keyed so a switch resets playback and load-failure state with the footage. */} + +
+ +
+
+
+

+ Plannotator TUI and Herdr Annotate +

+

+ Annotate Markdown and terminal text, then send the notes to your agent.

-
- Full demo - Lite demo - - Feedback -
-
+
+
-
-
-

- We’re enhancing these as much as we enhance this UI. Star them if they’re - useful, and open an issue when they’re not. -

-
- +
+ }> + Star plannotator-tui + + }> + Star herdr-annotate + + }> + Watch on X +
-
+ , document.body, diff --git a/packages/ui/theme.css b/packages/ui/theme.css index eb8e5e4db0..c9a854b48a 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -243,6 +243,22 @@ animation: vim-announcement-dialog-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both; } +/* First-run terminal-tools announcement: same entrance as the Vim one, and + * under reduced motion the panel still fades in, it just does not travel. */ +.terminal-tools-announcement-backdrop { + animation: vim-announcement-backdrop-in 160ms ease-out both; +} + +.terminal-tools-announcement-dialog { + animation: vim-announcement-dialog-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .terminal-tools-announcement-dialog { + animation: vim-announcement-backdrop-in 160ms ease-out both; + } +} + .vim-announcement-switch { transition: border-color 140ms ease, From e6d38e5ed058f4c76d7356eae98cf767b6e39983 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 14 Sep 2026 15:27:18 -0700 Subject: [PATCH 08/11] docs: describe the video-first terminal-tools announcement AGENTS.md now documents the hosted media, the reduced-motion and offline behavior, the demo switch and the height-aware panel width; the manual checklist's content and theme steps match the new dialog. --- AGENTS.md | 35 ++++++++++++++++++++++++++++------- tests/UI-TESTING.md | 18 ++++++++++++------ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b07ba724d8..a136f75bb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,13 +322,34 @@ registry default into a cookie on first access, so a registry-backed flag could never tell "never seen" from "seeded default". The component is `packages/ui/components/TerminalToolsAnnouncementDialog.tsx` -(shared shell: portal, `z-[100]`, `max-w-5xl`, hand-rolled Escape + Tab wrap + -focus restore, `data-terminal-tools-announcement-dialog`). Two deliberate -departures from its siblings: the backdrop dismisses (the panel collects no -decision, so there is nothing to lose by closing it impatiently), and its -keydown listener is registered on the CAPTURE phase and swallows `Mod+Enter`, -so a keystroke aimed at the announcement cannot approve a plan or post a review -behind it. +(shared shell: portal, `z-[100]`, hand-rolled Escape + Tab wrap + focus +restore, `data-terminal-tools-announcement-dialog`). It is video first: the +real demo footage fills the top of the panel edge to edge, and the text under +it is one headline and one sentence, then one action row (star the two repos, +"Watch on X", "Got it"). There is no mock terminal, no install commands, no +feature list; the repo pages carry all of that. Two deliberate departures from +its siblings: the backdrop dismisses (the panel collects no decision, so there +is nothing to lose by closing it impatiently), and its keydown listener is +registered on the CAPTURE phase and swallows `Mod+Enter`, so a keystroke aimed +at the announcement cannot approve a plan or post a review behind it. + +**Media is hosted, not bundled.** The two demos (`tui-herdr-full-demo` and +`tui-herdr-lite-demo`, mp4 + webm + poster jpg each, ~23MB together) live in +`apps/marketing/public/assets/` and are served from +`https://plannotator.ai/assets/` once the marketing deploy syncs them, the same +precedent as `GuideIntroDialog`'s hero image (the Edit Mode recording is small +enough to inline; these are not). The `