From 56754f083e214670146b6e4e25feda3432da5d58 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 14 Nov 2025 15:40:48 -0800 Subject: [PATCH 01/12] feat: initial message feedback button --- cli/src/chat.tsx | 69 ++++++++++ cli/src/components/feedback-icon-button.tsx | 60 +++++++++ cli/src/components/feedback-modal.tsx | 132 ++++++++++++++++++++ cli/src/components/message-block.tsx | 30 +++-- cli/src/components/message-with-agents.tsx | 9 ++ cli/src/hooks/use-send-message.ts | 4 + common/src/constants/analytics-events.ts | 2 + 7 files changed, 297 insertions(+), 9 deletions(-) create mode 100644 cli/src/components/feedback-icon-button.tsx create mode 100644 cli/src/components/feedback-modal.tsx diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 29386a16fc..1cddc4a838 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -1,4 +1,5 @@ import { TextAttributes } from '@opentui/core' +import { useKeyboard } from '@opentui/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -7,6 +8,7 @@ import { AgentModeToggle } from './components/agent-mode-toggle' import { Button } from './components/button' import { LoginModal } from './components/login-modal' import { MessageWithAgents } from './components/message-with-agents' +import { FeedbackModal } from './components/feedback-modal' import { MultilineInput, type MultilineInputHandle, @@ -36,6 +38,8 @@ import { useSuggestionMenuHandlers } from './hooks/use-suggestion-menu-handlers' import { useTerminalDimensions } from './hooks/use-terminal-dimensions' import { useTheme } from './hooks/use-theme' import { useValidationBanner } from './hooks/use-validation-banner' +import { logger } from './utils/logger' +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { useChatStore } from './state/chat-store' import { createChatScrollAcceleration } from './utils/chat-scroll-accel' import { formatQueuedPreview } from './utils/helpers' @@ -126,6 +130,7 @@ export const Chat = ({ setLastMessageMode, addSessionCredits, resetChatStore, + sessionCreditsUsed, } = useChatStore( useShallow((store) => ({ inputValue: store.inputValue, @@ -161,6 +166,7 @@ export const Chat = ({ setLastMessageMode: store.setLastMessageMode, addSessionCredits: store.addSessionCredits, resetChatStore: store.reset, + sessionCreditsUsed: store.sessionCreditsUsed, })), ) @@ -624,6 +630,33 @@ export const Chat = ({ /> ) + const [isFeedbackOpen, setIsFeedbackOpen] = useState(false) + const [feedbackMessageId, setFeedbackMessageId] = useState(null) + + const openFeedbackForMessage = useCallback((id: string) => { + setFeedbackMessageId(id) + setIsFeedbackOpen(true) + }, []) + + // Ctrl+F to open feedback for latest completed AI message + useKeyboard( + useCallback( + (key) => { + if (key?.ctrl && key.name === 'f') { + if ('preventDefault' in key && typeof key.preventDefault === 'function') { + key.preventDefault() + } + const latest = [...messages].reverse().find((m) => m.variant === 'ai' && m.isComplete) + if (latest) { + setFeedbackMessageId(latest.id) + setIsFeedbackOpen(true) + } + } + }, + [messages], + ), + ) + const validationBanner = useValidationBanner({ liveValidationErrors: validationErrors, loadedAgentsData, @@ -699,6 +732,7 @@ export const Chat = ({ onToggleCollapsed={handleCollapseToggle} onBuildFast={handleBuildFast} onBuildMax={handleBuildMax} + onFeedback={openFeedbackForMessage} /> ) })} @@ -918,6 +952,41 @@ export const Chat = ({ hasInvalidCredentials={hasInvalidCredentials} /> )} + + {isFeedbackOpen && ( + m.id === feedbackMessageId) ?? null} + onClose={() => setIsFeedbackOpen(false)} + onSubmit={(text) => { + const target = messages.find((m) => m.id === feedbackMessageId) + const recent = messages.slice(Math.max(0, messages.length - 5)).map((m) => ({ + id: m.id, + variant: m.variant, + timestamp: m.timestamp, + hasBlocks: !!m.blocks, + contentPreview: (m.content || '').slice(0, 400), + })) + logger.info( + { + eventId: AnalyticsEvent.FEEDBACK_SUBMITTED, + source: 'cli', + messageId: target?.id, + variant: target?.variant, + completionTime: target?.completionTime, + credits: target?.credits, + agentMode, + sessionCreditsUsed, + feedbackText: text, + runState: target?.metadata?.runState, + recentMessages: recent, + }, + 'User submitted feedback', + ) + setIsFeedbackOpen(false) + }} + /> + )} ) } diff --git a/cli/src/components/feedback-icon-button.tsx b/cli/src/components/feedback-icon-button.tsx new file mode 100644 index 0000000000..60ba16ddaf --- /dev/null +++ b/cli/src/components/feedback-icon-button.tsx @@ -0,0 +1,60 @@ +import React, { useRef } from 'react' + +import { useHoverToggle } from './agent-mode-toggle' +import { Button } from './button' +import { useTheme } from '../hooks/use-theme' +import { BORDER_CHARS } from '../utils/ui-constants' +import { logger } from '../utils/logger' +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' + +interface FeedbackIconButtonProps { + onClick?: () => void + messageId?: string +} + +export const FeedbackIconButton: React.FC = ({ onClick, messageId }) => { + const theme = useTheme() + const hover = useHoverToggle() + const hoveredOnceRef = useRef(false) + + const handleMouseOver = () => { + hover.clearCloseTimer() + hover.scheduleOpen() + if (!hoveredOnceRef.current) { + hoveredOnceRef.current = true + logger.info( + { + eventId: AnalyticsEvent.FEEDBACK_BUTTON_HOVERED, + messageId, + source: 'cli', + }, + 'Feedback button hovered', + ) + } + } + const handleMouseOut = () => hover.scheduleClose() + + const textCollapsed = '[?]' + const textExpanded = '[share feedback]' + + return ( + + ) +} diff --git a/cli/src/components/feedback-modal.tsx b/cli/src/components/feedback-modal.tsx new file mode 100644 index 0000000000..adf0e88bc1 --- /dev/null +++ b/cli/src/components/feedback-modal.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { useRenderer } from '@opentui/react' + +import { MultilineInput, type MultilineInputHandle } from './multiline-input' +import { Button } from './button' +import { useTheme } from '../hooks/use-theme' +import type { ChatMessage } from '../types/chat' + +interface FeedbackModalProps { + open: boolean + message: ChatMessage | null + onClose: () => void + onSubmit: (text: string) => void +} + +export const FeedbackModal: React.FC = ({ open, message, onClose, onSubmit }) => { + const theme = useTheme() + const renderer = useRenderer() + const [value, setValue] = useState('') + const [cursorPosition, setCursorPosition] = useState(0) + const [showDetails, setShowDetails] = useState(false) + const inputRef = useRef(null) + + const terminalWidth = renderer?.width || 80 + const terminalHeight = renderer?.height || 24 + + const modalWidth = Math.max(60, Math.min(terminalWidth - 4, 100)) + const modalHeight = Math.max(12, Math.min(terminalHeight - 4, 24)) + const modalLeft = Math.floor((terminalWidth - modalWidth) / 2) + const modalTop = Math.floor((terminalHeight - modalHeight) / 2) + + const contextPreview = useMemo(() => { + if (!message) return 'No message context' + const runState = message.metadata?.runState + const safe = { + id: message.id, + variant: message.variant, + timestamp: message.timestamp, + completionTime: message.completionTime, + credits: message.credits, + runStatePreview: runState ? JSON.stringify(runState).slice(0, 1000) + (JSON.stringify(runState).length > 1000 ? ' …' : '') : 'n/a', + } + return JSON.stringify(safe, null, 2) + }, [message]) + + const handleSubmit = useCallback(() => { + const text = value.trim() + if (text.length === 0) return + onSubmit(text) + setValue('') + }, [onSubmit, value]) + + if (!open) return null + + return ( + + + Share Feedback + + + + Thanks for helping us improve! What happened? + + + + { text: string; cursorPosition: number; lastEditDueToNav: boolean })) => { + const v = typeof next === 'function' ? next({ text: value, cursorPosition, lastEditDueToNav: false }) : next + setValue(v.text) + setCursorPosition(v.cursorPosition) + }} + onSubmit={handleSubmit} + placeholder={'Tell us more...'} + focused={true} + maxHeight={6} + width={modalWidth - 4} + textAttributes={undefined} + ref={inputRef} + cursorPosition={cursorPosition} + /> + + + + + Auto-attached: Message content • Trace data • Session info + + + + + {showDetails && ( + + + {contextPreview} + + + )} + + + + + + + ) +} diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index a70ce8807f..38f60b67f6 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -3,6 +3,7 @@ import React, { memo, useCallback, type ReactNode } from 'react' import { AgentBranchItem } from './agent-branch-item' import { ElapsedTimer } from './elapsed-timer' +import { FeedbackIconButton } from './feedback-icon-button' import { useTheme } from '../hooks/use-theme' import { useWhyDidYouUpdateById } from '../hooks/use-why-did-you-update' import { isTextBlock, isToolBlock } from '../types/chat' @@ -72,6 +73,7 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => { onBuildMax, setCollapsedAgents, addAutoCollapsedAgent, + onFeedback, } = props useWhyDidYouUpdateById('MessageBlock', messageId, props, { logLevel: 'debug', @@ -149,19 +151,28 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => { )} {isComplete && ( - - {completionTime} - {credits && ` • ${credits} credits`} - + + {completionTime} + {credits && ` • ${credits} credits`} + + onFeedback?.(messageId)} messageId={messageId} /> + )} )} @@ -237,6 +248,7 @@ interface MessageBlockProps { onBuildMax: () => void setCollapsedAgents: (value: (prev: Set) => Set) => void addAutoCollapsedAgent: (value: string) => void + onFeedback?: (messageId: string) => void } interface AgentBodyProps { diff --git a/cli/src/components/message-with-agents.tsx b/cli/src/components/message-with-agents.tsx index de9b2b799f..713f52ee8e 100644 --- a/cli/src/components/message-with-agents.tsx +++ b/cli/src/components/message-with-agents.tsx @@ -36,6 +36,7 @@ interface MessageWithAgentsProps { onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void + onFeedback: (messageId: string) => void } export const MessageWithAgents = memo( @@ -60,6 +61,7 @@ export const MessageWithAgents = memo( onToggleCollapsed, onBuildFast, onBuildMax, + onFeedback, }: MessageWithAgentsProps): ReactNode => { const SIDE_GUTTER = 1 const isAgent = message.variant === 'agent' @@ -86,6 +88,7 @@ export const MessageWithAgents = memo( onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} + onFeedback={onFeedback} /> ) } @@ -209,6 +212,7 @@ export const MessageWithAgents = memo( onBuildMax={onBuildMax} setCollapsedAgents={setCollapsedAgents} addAutoCollapsedAgent={addAutoCollapsedAgent} + onFeedback={onFeedback} /> @@ -252,6 +256,7 @@ export const MessageWithAgents = memo( onBuildMax={onBuildMax} setCollapsedAgents={setCollapsedAgents} addAutoCollapsedAgent={addAutoCollapsedAgent} + onFeedback={onFeedback} /> )} @@ -282,6 +287,7 @@ export const MessageWithAgents = memo( onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} + onFeedback={onFeedback} /> ))} @@ -312,6 +318,7 @@ interface AgentMessageProps { onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void + onFeedback: (messageId: string) => void } const AgentMessage = memo( @@ -335,6 +342,7 @@ const AgentMessage = memo( onToggleCollapsed, onBuildFast, onBuildMax, + onFeedback, }: AgentMessageProps): ReactNode => { const agentInfo = message.agent! const isCollapsed = collapsedAgents.has(message.id) @@ -532,6 +540,7 @@ const AgentMessage = memo( onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} + onFeedback={onFeedback} /> ))} diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 9198942084..574dbf1878 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -1572,6 +1572,10 @@ export const useSendMessage = ({ ...(actualCredits !== undefined && { credits: actualCredits, }), + metadata: { + ...(msg.metadata ?? {}), + runState, + }, } }), ) diff --git a/common/src/constants/analytics-events.ts b/common/src/constants/analytics-events.ts index 23290a9dc2..aa155cd1f1 100644 --- a/common/src/constants/analytics-events.ts +++ b/common/src/constants/analytics-events.ts @@ -24,6 +24,8 @@ export enum AnalyticsEvent { TERMINAL_COMMAND_COMPLETED_SINGLE = 'cli.terminal_command_completed_single', USER_INPUT_COMPLETE = 'cli.user_input_complete', UPDATE_CODEBUFF_FAILED = 'cli.update_codebuff_failed', + FEEDBACK_SUBMITTED = 'cli.feedback_submitted', + FEEDBACK_BUTTON_HOVERED = 'cli.feedback_button_hovered', // Backend AGENT_STEP = 'backend.agent_step', From 1d6bb463037b0df4257842b70f31291a2fb801ba Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 14 Nov 2025 15:59:29 -0800 Subject: [PATCH 02/12] fix: better ui for feedback modal --- cli/src/chat.tsx | 43 +++-- .../message-block.completion.test.tsx | 22 +++ cli/src/components/feedback-icon-button.tsx | 9 +- cli/src/components/feedback-modal.tsx | 163 +++++++++++------- cli/src/components/message-block.tsx | 3 +- 5 files changed, 158 insertions(+), 82 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 1cddc4a838..33a9774ac0 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -450,6 +450,26 @@ export const Chat = ({ sendMessageRef, }) + // Feedback state and handlers + const [isFeedbackOpen, setIsFeedbackOpen] = useState(false) + const [feedbackMessageId, setFeedbackMessageId] = useState(null) + + const openFeedbackForMessage = useCallback((id: string) => { + setFeedbackMessageId(id) + setIsFeedbackOpen(true) + }, []) + + const openFeedbackForLatestMessage = useCallback(() => { + const latest = [...messages] + .reverse() + .find((m) => m.variant === 'ai' && m.isComplete) + if (!latest) { + return false + } + openFeedbackForMessage(latest.id) + return true + }, [messages, openFeedbackForMessage]) + const handleSubmit = useCallback( () => routeUserPrompt({ @@ -630,14 +650,6 @@ export const Chat = ({ /> ) - const [isFeedbackOpen, setIsFeedbackOpen] = useState(false) - const [feedbackMessageId, setFeedbackMessageId] = useState(null) - - const openFeedbackForMessage = useCallback((id: string) => { - setFeedbackMessageId(id) - setIsFeedbackOpen(true) - }, []) - // Ctrl+F to open feedback for latest completed AI message useKeyboard( useCallback( @@ -646,14 +658,10 @@ export const Chat = ({ if ('preventDefault' in key && typeof key.preventDefault === 'function') { key.preventDefault() } - const latest = [...messages].reverse().find((m) => m.variant === 'ai' && m.isComplete) - if (latest) { - setFeedbackMessageId(latest.id) - setIsFeedbackOpen(true) - } + openFeedbackForLatestMessage() } }, - [messages], + [openFeedbackForLatestMessage], ), ) @@ -875,7 +883,7 @@ export const Chat = ({ ? 'Enter a coding task' : 'Enter a coding task or / for commands' } - focused={inputFocused} + focused={inputFocused && !isFeedbackOpen} maxHeight={5} width={inputWidth} onKeyIntercept={handleSuggestionMenuKey} @@ -958,7 +966,7 @@ export const Chat = ({ open={isFeedbackOpen} message={messages.find((m) => m.id === feedbackMessageId) ?? null} onClose={() => setIsFeedbackOpen(false)} - onSubmit={(text) => { + onSubmit={(data) => { const target = messages.find((m) => m.id === feedbackMessageId) const recent = messages.slice(Math.max(0, messages.length - 5)).map((m) => ({ id: m.id, @@ -977,7 +985,8 @@ export const Chat = ({ credits: target?.credits, agentMode, sessionCreditsUsed, - feedbackText: text, + feedbackCategory: data.category, + feedbackText: data.text, runState: target?.metadata?.runState, recentMessages: recent, }, diff --git a/cli/src/components/__tests__/message-block.completion.test.tsx b/cli/src/components/__tests__/message-block.completion.test.tsx index 671ef3a1e9..18d8a10797 100644 --- a/cli/src/components/__tests__/message-block.completion.test.tsx +++ b/cli/src/components/__tests__/message-block.completion.test.tsx @@ -78,4 +78,26 @@ describe('MessageBlock completion time', () => { expect(markup).not.toContain('7s') expect(markup).not.toContain('3 credits') }) + + test('pluralizes credit label correctly', () => { + const singularMarkup = renderToStaticMarkup( + , + ) + expect(singularMarkup).toContain('1 credit') + + const pluralMarkup = renderToStaticMarkup( + , + ) + expect(pluralMarkup).toContain('4 credits') + }) }) diff --git a/cli/src/components/feedback-icon-button.tsx b/cli/src/components/feedback-icon-button.tsx index 60ba16ddaf..59678a76e3 100644 --- a/cli/src/components/feedback-icon-button.tsx +++ b/cli/src/components/feedback-icon-button.tsx @@ -42,17 +42,14 @@ export const FeedbackIconButton: React.FC = ({ onClick, style={{ flexDirection: 'row', alignItems: 'center', - paddingLeft: 1, - paddingRight: 1, - borderStyle: 'single', - borderColor: hover.isOpen ? theme.foreground : theme.border, - customBorderChars: BORDER_CHARS, + paddingLeft: 0, + paddingRight: 0, }} onClick={() => onClick?.()} onMouseOver={handleMouseOver} onMouseOut={handleMouseOut} > - + {hover.isOpen ? textExpanded : textCollapsed} diff --git a/cli/src/components/feedback-modal.tsx b/cli/src/components/feedback-modal.tsx index adf0e88bc1..9acdcebf9c 100644 --- a/cli/src/components/feedback-modal.tsx +++ b/cli/src/components/feedback-modal.tsx @@ -1,24 +1,25 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { useRenderer } from '@opentui/react' import { MultilineInput, type MultilineInputHandle } from './multiline-input' import { Button } from './button' import { useTheme } from '../hooks/use-theme' +import { BORDER_CHARS } from '../utils/ui-constants' import type { ChatMessage } from '../types/chat' interface FeedbackModalProps { open: boolean message: ChatMessage | null onClose: () => void - onSubmit: (text: string) => void + onSubmit: (data: { text: string; category: string | null }) => void } -export const FeedbackModal: React.FC = ({ open, message, onClose, onSubmit }) => { +export const FeedbackModal: React.FC = ({ open, onClose, onSubmit }) => { const theme = useTheme() const renderer = useRenderer() - const [value, setValue] = useState('') - const [cursorPosition, setCursorPosition] = useState(0) - const [showDetails, setShowDetails] = useState(false) + const [feedbackText, setFeedbackText] = useState('') + const [feedbackCursor, setFeedbackCursor] = useState(0) + const [category, setCategory] = useState('other') const inputRef = useRef(null) const terminalWidth = renderer?.width || 80 @@ -29,29 +30,25 @@ export const FeedbackModal: React.FC = ({ open, message, onC const modalLeft = Math.floor((terminalWidth - modalWidth) / 2) const modalTop = Math.floor((terminalHeight - modalHeight) / 2) - const contextPreview = useMemo(() => { - if (!message) return 'No message context' - const runState = message.metadata?.runState - const safe = { - id: message.id, - variant: message.variant, - timestamp: message.timestamp, - completionTime: message.completionTime, - credits: message.credits, - runStatePreview: runState ? JSON.stringify(runState).slice(0, 1000) + (JSON.stringify(runState).length > 1000 ? ' …' : '') : 'n/a', - } - return JSON.stringify(safe, null, 2) - }, [message]) - const handleSubmit = useCallback(() => { - const text = value.trim() + const text = feedbackText.trim() if (text.length === 0) return - onSubmit(text) - setValue('') - }, [onSubmit, value]) + onSubmit({ text, category }) + setFeedbackText('') + setCategory('other') + }, [onSubmit, feedbackText, category]) if (!open) return null + const categoryOptions = [ + { id: 'good_code', label: 'Good code', highlight: theme.success }, + { id: 'bad_code', label: 'Bad code', highlight: theme.error }, + { id: 'bug', label: 'Bug', highlight: theme.warning }, + { id: 'other', label: 'Other', highlight: theme.info }, + ] as const + + const canSubmit = feedbackText.trim().length > 0 + return ( = ({ open, message, onC gap: 1, }} > - - Share Feedback - + + + Share Feedback + + + Thanks for helping us improve! What happened? - + + + Select a category: + + + {categoryOptions.map((option) => { + const isSelected = category === option.id + return ( + + ) + })} + + + + { text: string; cursorPosition: number; lastEditDueToNav: boolean })) => { - const v = typeof next === 'function' ? next({ text: value, cursorPosition, lastEditDueToNav: false }) : next - setValue(v.text) - setCursorPosition(v.cursorPosition) + const v = typeof next === 'function' ? next({ text: feedbackText, cursorPosition: feedbackCursor, lastEditDueToNav: false }) : next + setFeedbackText(v.text) + setFeedbackCursor(v.cursorPosition) }} onSubmit={handleSubmit} placeholder={'Tell us more...'} focused={true} maxHeight={6} - width={modalWidth - 4} + width={modalWidth - 6} textAttributes={undefined} ref={inputRef} - cursorPosition={cursorPosition} + cursorPosition={feedbackCursor} /> - + Auto-attached: Message content • Trace data • Session info - - - - {showDetails && ( - - - {contextPreview} - - - )} - - - - diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 38f60b67f6..d78c98384b 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -1,4 +1,5 @@ import { TextAttributes } from '@opentui/core' +import { pluralize } from '@codebuff/common/util/string' import React, { memo, useCallback, type ReactNode } from 'react' import { AgentBranchItem } from './agent-branch-item' @@ -169,7 +170,7 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => { }} > {completionTime} - {credits && ` • ${credits} credits`} + {typeof credits === 'number' && credits > 0 && ` • ${pluralize(credits, 'credit')}`} onFeedback?.(messageId)} messageId={messageId} /> From b77b5cecbd441e5baec5315f88f083b266f5f0a1 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 14 Nov 2025 16:05:54 -0800 Subject: [PATCH 03/12] Fix keyboard input conflict between feedback modal and chat input - Disable chat input focus when feedback modal is open - Add disabled state to keyboard handlers to prevent chat shortcuts from firing - Ensure modal has exclusive keyboard control when active --- cli/src/chat.tsx | 1 + cli/src/hooks/use-keyboard-handlers.ts | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 33a9774ac0..b2f98749a8 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -549,6 +549,7 @@ export const Chat = ({ }, historyNavUpEnabled, historyNavDownEnabled, + disabled: isFeedbackOpen, }) const { tree: messageTree, topLevelMessages } = useMemo( diff --git a/cli/src/hooks/use-keyboard-handlers.ts b/cli/src/hooks/use-keyboard-handlers.ts index 339b27d4cc..6c12f53ac8 100644 --- a/cli/src/hooks/use-keyboard-handlers.ts +++ b/cli/src/hooks/use-keyboard-handlers.ts @@ -19,6 +19,7 @@ interface KeyboardHandlersConfig { onInterrupt: () => void historyNavUpEnabled: boolean historyNavDownEnabled: boolean + disabled?: boolean } export const useKeyboardHandlers = ({ @@ -37,10 +38,13 @@ export const useKeyboardHandlers = ({ onInterrupt, historyNavUpEnabled, historyNavDownEnabled, + disabled = false, }: KeyboardHandlersConfig) => { useKeyboard( useCallback( (key) => { + if (disabled) return + const isEscape = key.name === 'escape' const isCtrlC = key.ctrl && key.name === 'c' @@ -71,13 +75,14 @@ export const useKeyboardHandlers = ({ } } }, - [isStreaming, isWaitingForResponse, abortControllerRef, onCtrlC, onInterrupt], + [isStreaming, isWaitingForResponse, abortControllerRef, onCtrlC, onInterrupt, disabled], ), ) useKeyboard( useCallback( (key) => { + if (disabled) return if (!focusedAgentId) return const isSpace = @@ -125,13 +130,14 @@ export const useKeyboardHandlers = ({ }) } }, - [focusedAgentId, setCollapsedAgents], + [focusedAgentId, setCollapsedAgents, disabled], ), ) useKeyboard( useCallback( (key) => { + if (disabled) return if (key.name === 'escape' && focusedAgentId) { if ( 'preventDefault' in key && @@ -144,7 +150,7 @@ export const useKeyboardHandlers = ({ inputRef.current?.focus() } }, - [focusedAgentId, setFocusedAgentId, setInputFocused, inputRef], + [focusedAgentId, setFocusedAgentId, setInputFocused, inputRef, disabled], ), ) @@ -152,6 +158,8 @@ export const useKeyboardHandlers = ({ useKeyboard( useCallback( (key) => { + if (disabled) return + const isUpArrow = key.name === 'up' && !key.ctrl && !key.meta && !key.shift const isDownArrow = @@ -174,13 +182,15 @@ export const useKeyboardHandlers = ({ navigateDown() } }, - [historyNavUpEnabled, historyNavDownEnabled, navigateUp, navigateDown], + [historyNavUpEnabled, historyNavDownEnabled, navigateUp, navigateDown, disabled], ), ) useKeyboard( useCallback( (key) => { + if (disabled) return + const isShiftTab = key.shift && key.name === 'tab' && !key.ctrl && !key.meta @@ -195,7 +205,7 @@ export const useKeyboardHandlers = ({ toggleAgentMode() }, - [toggleAgentMode], + [toggleAgentMode, disabled], ), ) } From 82d085499996eb0e5899cb3d1df18c6fca537f46 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 14 Nov 2025 16:46:09 -0800 Subject: [PATCH 04/12] Add blinking cursor and keyboard shortcuts to feedback modal - Extract cursor to reusable InputCursor component with idle blink animation - Implement 500ms blink cycle (visible/invisible) after idle detection - Add Ctrl+C handler: clear input first, close modal if already empty - Add Escape key to immediately close modal - Refactor MultilineInput to use new InputCursor component --- cli/src/components/feedback-modal.tsx | 38 ++++++++++++- cli/src/components/input-cursor.tsx | 78 ++++++++++++++++++++++++++ cli/src/components/multiline-input.tsx | 20 +++++-- 3 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 cli/src/components/input-cursor.tsx diff --git a/cli/src/components/feedback-modal.tsx b/cli/src/components/feedback-modal.tsx index 9acdcebf9c..700a4e321d 100644 --- a/cli/src/components/feedback-modal.tsx +++ b/cli/src/components/feedback-modal.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef, useState } from 'react' -import { useRenderer } from '@opentui/react' +import { useRenderer, useKeyboard } from '@opentui/react' import { MultilineInput, type MultilineInputHandle } from './multiline-input' import { Button } from './button' @@ -38,6 +38,40 @@ export const FeedbackModal: React.FC = ({ open, onClose, onS setCategory('other') }, [onSubmit, feedbackText, category]) + // Handle Ctrl+C: clear input first, then close if already empty + // Handle Escape: close modal directly + useKeyboard( + useCallback( + (key) => { + if (!open) return + + const isCtrlC = key.ctrl && key.name === 'c' + const isEscape = key.name === 'escape' + + if (!isCtrlC && !isEscape) return + + if ('preventDefault' in key && typeof key.preventDefault === 'function') { + key.preventDefault() + } + + if (isEscape) { + // Escape always closes the modal + onClose() + } else if (isCtrlC) { + if (feedbackText.length === 0) { + // Input is already empty, close the modal + onClose() + } else { + // Clear the input + setFeedbackText('') + setFeedbackCursor(0) + } + } + }, + [open, feedbackText, onClose] + ) + ) + if (!open) return null const categoryOptions = [ @@ -170,7 +204,7 @@ export const FeedbackModal: React.FC = ({ open, onClose, onS }} > - {'< SUBMIT'} + {'SUBMIT'} diff --git a/cli/src/components/input-cursor.tsx b/cli/src/components/input-cursor.tsx new file mode 100644 index 0000000000..9781587d26 --- /dev/null +++ b/cli/src/components/input-cursor.tsx @@ -0,0 +1,78 @@ +import { TextAttributes } from '@opentui/core' +import React, { useEffect, useRef, useState } from 'react' +import { useTheme } from '../hooks/use-theme' + +interface InputCursorProps { + visible: boolean + focused: boolean + char?: string + color?: string + dimColor?: string + blinkDelay?: number + blinkInterval?: number + bold?: boolean +} + +export const InputCursor: React.FC = ({ + visible, + focused, + char = '▍', + color, + dimColor, + blinkDelay = 500, + blinkInterval = 500, // Faster blinking + bold = true, +}) => { + const theme = useTheme() + // false = normal/visible, true = invisible + const [isInvisible, setIsInvisible] = useState(false) + const blinkIntervalRef = useRef(null) + + // Handle blinking (toggle visible/invisible) when idle + useEffect(() => { + // Clear any existing interval + if (blinkIntervalRef.current) { + clearInterval(blinkIntervalRef.current) + blinkIntervalRef.current = null + } + + // Reset cursor to visible + setIsInvisible(false) + + if (!focused || !visible) return + + // Set up idle detection + const idleTimer = setTimeout(() => { + // Start blinking interval (toggle between visible and invisible) + blinkIntervalRef.current = setInterval(() => { + setIsInvisible((prev) => !prev) + }, blinkInterval) + }, blinkDelay) + + return () => { + clearTimeout(idleTimer) + if (blinkIntervalRef.current) { + clearInterval(blinkIntervalRef.current) + blinkIntervalRef.current = null + } + } + }, [visible, focused, blinkDelay, blinkInterval]) + + if (!visible || !focused) { + return null + } + + // When invisible, return a space to maintain layout + if (isInvisible) { + return + } + + return ( + + {char} + + ) +} \ No newline at end of file diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index 9a4eebea07..b62f831a24 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -12,6 +12,7 @@ import { import { useOpentuiPaste } from '../hooks/use-opentui-paste' import { useTheme } from '../hooks/use-theme' +import { InputCursor } from './input-cursor' import { clamp } from '../utils/math' import { computeInputLayoutMetrics } from '../utils/text-layout' import { calculateNewCursorPosition } from '../utils/word-wrap-utils' @@ -126,6 +127,13 @@ export const MultilineInput = forwardRef< const theme = useTheme() const scrollBoxRef = useRef(null) const [measuredCols, setMeasuredCols] = useState(null) + const [lastActivity, setLastActivity] = useState(Date.now()) + + // Update last activity on value or cursor changes + useEffect(() => { + setLastActivity(Date.now()) + }, [value, cursorPosition]) + const getEffectiveCols = useCallback(() => { // Prefer measured viewport columns; fallback to a conservative // estimate: outer width minus border(2) minus padding(2) = 4. @@ -787,12 +795,12 @@ export const MultilineInput = forwardRef< {activeChar === ' ' ? '\u00a0' : activeChar} ) : ( - - {CURSOR_CHAR} - + )} {shouldHighlight ? afterCursor.length > 0 From 8852f3466b7cac4f86b8d73d16ad2764f56eca8c Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 14 Nov 2025 19:34:05 -0800 Subject: [PATCH 05/12] feat: converted to feedback input bar --- cli/src/chat.tsx | 171 ++++++++++++---- cli/src/components/feedback-icon-button.tsx | 10 +- cli/src/components/feedback-input-mode.tsx | 208 ++++++++++++++++++++ cli/src/components/feedback-modal.tsx | 8 +- cli/src/components/message-block.tsx | 22 ++- cli/src/components/message-with-agents.tsx | 16 +- cli/src/components/status-indicator.tsx | 2 +- 7 files changed, 382 insertions(+), 55 deletions(-) create mode 100644 cli/src/components/feedback-input-mode.tsx diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index b2f98749a8..5453f30231 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -8,7 +8,7 @@ import { AgentModeToggle } from './components/agent-mode-toggle' import { Button } from './components/button' import { LoginModal } from './components/login-modal' import { MessageWithAgents } from './components/message-with-agents' -import { FeedbackModal } from './components/feedback-modal' +import { FeedbackInputMode } from './components/feedback-input-mode' import { MultilineInput, type MultilineInputHandle, @@ -451,13 +451,29 @@ export const Chat = ({ }) // Feedback state and handlers - const [isFeedbackOpen, setIsFeedbackOpen] = useState(false) const [feedbackMessageId, setFeedbackMessageId] = useState(null) + const [feedbackMode, setFeedbackMode] = useState(false) + const [feedbackText, setFeedbackText] = useState('') + const [feedbackCursor, setFeedbackCursor] = useState(0) + const [feedbackCategory, setFeedbackCategory] = useState('other') + const [savedInputValue, setSavedInputValue] = useState('') + const [savedCursorPosition, setSavedCursorPosition] = useState(0) + const [showFeedbackConfirmation, setShowFeedbackConfirmation] = useState(false) + const [feedbackSentMessage, setFeedbackSentMessage] = useState(null) + const [messagesWithFeedback, setMessagesWithFeedback] = useState>(new Set()) const openFeedbackForMessage = useCallback((id: string) => { + // Save current input state + setSavedInputValue(inputValue) + setSavedCursorPosition(cursorPosition) + + // Enter feedback mode setFeedbackMessageId(id) - setIsFeedbackOpen(true) - }, []) + setFeedbackMode(true) + setFeedbackText('') + setFeedbackCursor(0) + setFeedbackCategory('other') + }, [inputValue, cursorPosition]) const openFeedbackForLatestMessage = useCallback(() => { const latest = [...messages] @@ -470,6 +486,66 @@ export const Chat = ({ return true }, [messages, openFeedbackForMessage]) + const handleFeedbackSubmit = useCallback(() => { + const text = feedbackText.trim() + if (text.length === 0) return + + const target = messages.find((m) => m.id === feedbackMessageId) + const recent = messages.slice(Math.max(0, messages.length - 5)).map((m) => ({ + id: m.id, + variant: m.variant, + timestamp: m.timestamp, + hasBlocks: !!m.blocks, + contentPreview: (m.content || '').slice(0, 400), + })) + + logger.info( + { + eventId: AnalyticsEvent.FEEDBACK_SUBMITTED, + source: 'cli', + messageId: target?.id, + variant: target?.variant, + completionTime: target?.completionTime, + credits: target?.credits, + agentMode, + sessionCreditsUsed, + recentMessages: recent, + feedback: { + text, + category: feedbackCategory, + }, + }, + ) + + // Mark this message as having feedback submitted + if (feedbackMessageId) { + setMessagesWithFeedback(prev => new Set(prev).add(feedbackMessageId)) + } + + // Show success in status indicator and exit feedback mode + setFeedbackMode(false) + setFeedbackText('') + setFeedbackCategory('other') + setFeedbackSentMessage('Feedback sent ✔') + setTimeout(() => { + setFeedbackSentMessage(null) + }, 5000) + }, [feedbackText, feedbackCategory, feedbackMessageId, messages, agentMode, sessionCreditsUsed]) + + const handleFeedbackCancel = useCallback(() => { + // Restore saved input + setInputValue((prev) => ({ + text: savedInputValue, + cursorPosition: savedCursorPosition, + lastEditDueToNav: false + })) + + // Exit feedback mode + setFeedbackMode(false) + setFeedbackText('') + setFeedbackCategory('other') + }, [savedInputValue, savedCursorPosition, setInputValue]) + const handleSubmit = useCallback( () => routeUserPrompt({ @@ -549,7 +625,7 @@ export const Chat = ({ }, historyNavUpEnabled, historyNavDownEnabled, - disabled: isFeedbackOpen, + disabled: feedbackMode, }) const { tree: messageTree, topLevelMessages } = useMemo( @@ -636,7 +712,7 @@ export const Chat = ({ const statusIndicatorNode = ( { + // Don't handle if already in feedback mode + if (feedbackMode) return + if (key?.ctrl && key.name === 'f') { if ('preventDefault' in key && typeof key.preventDefault === 'function') { key.preventDefault() @@ -662,7 +741,7 @@ export const Chat = ({ openFeedbackForLatestMessage() } }, - [openFeedbackForLatestMessage], + [openFeedbackForLatestMessage, feedbackMode], ), ) @@ -742,6 +821,10 @@ export const Chat = ({ onBuildFast={handleBuildFast} onBuildMax={handleBuildMax} onFeedback={openFeedbackForMessage} + feedbackOpenMessageId={feedbackMessageId} + feedbackMode={feedbackMode} + onCloseFeedback={handleFeedbackCancel} + messagesWithFeedback={messagesWithFeedback} /> ) })} @@ -823,6 +906,42 @@ export const Chat = ({ {/* Wrap the input row in a single OpenTUI border so the toggle stays inside the flex layout. The queue preview is injected via the border title rather than custom text nodes, which keeps the border coupled to the content height while preserving the inline preview look. */} + {feedbackMode ? ( + { + setFeedbackText(text) + setFeedbackCursor(cursor) + }} + onCategoryChange={setFeedbackCategory} + onSubmit={handleFeedbackSubmit} + onCancel={handleFeedbackCancel} + width={terminalWidth - 2} + terminalWidth={terminalWidth} + /> + ) : showFeedbackConfirmation ? ( + + + ✓ Feedback sent! Thanks for helping us improve. + + + ) : ( + )} {/* Paused queue indicator - fake bottom border continuation */} {pausedQueueText && ( @@ -962,41 +1082,6 @@ export const Chat = ({ /> )} - {isFeedbackOpen && ( - m.id === feedbackMessageId) ?? null} - onClose={() => setIsFeedbackOpen(false)} - onSubmit={(data) => { - const target = messages.find((m) => m.id === feedbackMessageId) - const recent = messages.slice(Math.max(0, messages.length - 5)).map((m) => ({ - id: m.id, - variant: m.variant, - timestamp: m.timestamp, - hasBlocks: !!m.blocks, - contentPreview: (m.content || '').slice(0, 400), - })) - logger.info( - { - eventId: AnalyticsEvent.FEEDBACK_SUBMITTED, - source: 'cli', - messageId: target?.id, - variant: target?.variant, - completionTime: target?.completionTime, - credits: target?.credits, - agentMode, - sessionCreditsUsed, - feedbackCategory: data.category, - feedbackText: data.text, - runState: target?.metadata?.runState, - recentMessages: recent, - }, - 'User submitted feedback', - ) - setIsFeedbackOpen(false) - }} - /> - )} ) } diff --git a/cli/src/components/feedback-icon-button.tsx b/cli/src/components/feedback-icon-button.tsx index 59678a76e3..f7801e01bd 100644 --- a/cli/src/components/feedback-icon-button.tsx +++ b/cli/src/components/feedback-icon-button.tsx @@ -9,10 +9,12 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' interface FeedbackIconButtonProps { onClick?: () => void + onClose?: () => void + isOpen?: boolean messageId?: string } -export const FeedbackIconButton: React.FC = ({ onClick, messageId }) => { +export const FeedbackIconButton: React.FC = ({ onClick, onClose, isOpen, messageId }) => { const theme = useTheme() const hover = useHoverToggle() const hoveredOnceRef = useRef(false) @@ -34,8 +36,8 @@ export const FeedbackIconButton: React.FC = ({ onClick, } const handleMouseOut = () => hover.scheduleClose() - const textCollapsed = '[?]' - const textExpanded = '[share feedback]' + const textCollapsed = isOpen ? '[x]' : '[?]' + const textExpanded = isOpen ? '[close x]' : '[share feedback]' return ( + ) + })} + + + {/* Separator */} + + + {'─'.repeat(width - 4)} + + + + {/* Feedback input */} + + { text: string; cursorPosition: number; lastEditDueToNav: boolean })) => { + const v = typeof next === 'function' + ? next({ text: feedbackText, cursorPosition: feedbackCursor, lastEditDueToNav: false }) + : next + onFeedbackTextChange(v.text, v.cursorPosition) + }} + onSubmit={onSubmit} + onKeyIntercept={(key) => { + const isEnter = key.name === 'return' || key.name === 'enter' + if (!isEnter) return false + if (key.meta) { + if (canSubmit) onSubmit() + return true + } + const newText = feedbackText.slice(0, feedbackCursor) + '\n' + feedbackText.slice(feedbackCursor) + onFeedbackTextChange(newText, feedbackCursor + 1) + return true + }} + placeholder={'Tell us more (what happened, what you expected)...'} + focused={true} + maxHeight={5} + width={width - 4} + textAttributes={undefined} + ref={inputRef} + cursorPosition={feedbackCursor} + /> + + + {/* Separator */} + + + {'─'.repeat(width - 4)} + + + + {/* Footer with auto-attached info and submit button */} + + + Auto-attached: Message • Trace • Session + + + + + ) +} \ No newline at end of file diff --git a/cli/src/components/feedback-modal.tsx b/cli/src/components/feedback-modal.tsx index 700a4e321d..f5102822b1 100644 --- a/cli/src/components/feedback-modal.tsx +++ b/cli/src/components/feedback-modal.tsx @@ -125,8 +125,8 @@ export const FeedbackModal: React.FC = ({ open, onClose, onS - - Select a category: + + Category: {categoryOptions.map((option) => { @@ -146,7 +146,7 @@ export const FeedbackModal: React.FC = ({ open, onClose, onS borderStyle: 'single', borderColor: isSelected ? option.highlight : theme.border, customBorderChars: BORDER_CHARS, - backgroundColor: isSelected ? theme.surface : undefined, + backgroundColor: 'transparent', }} > @@ -184,7 +184,7 @@ export const FeedbackModal: React.FC = ({ open, onClose, onS /> - + Auto-attached: Message content • Trace data • Session info diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index d78c98384b..ed6f74f769 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -46,6 +46,11 @@ interface MessageBlockProps { onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void + onFeedback?: (messageId: string) => void + feedbackOpenMessageId?: string | null + feedbackMode?: boolean + onCloseFeedback?: () => void + messagesWithFeedback?: Set } export const MessageBlock = memo((props: MessageBlockProps): ReactNode => { @@ -75,6 +80,10 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => { setCollapsedAgents, addAutoCollapsedAgent, onFeedback, + feedbackOpenMessageId, + feedbackMode, + onCloseFeedback, + messagesWithFeedback, } = props useWhyDidYouUpdateById('MessageBlock', messageId, props, { logLevel: 'debug', @@ -172,7 +181,14 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => { {completionTime} {typeof credits === 'number' && credits > 0 && ` • ${pluralize(credits, 'credit')}`} - onFeedback?.(messageId)} messageId={messageId} /> + {!messagesWithFeedback?.has(messageId) && ( + onFeedback?.(messageId)} + onClose={onCloseFeedback} + isOpen={Boolean(feedbackMode && feedbackOpenMessageId === messageId)} + messageId={messageId} + /> + )} )} @@ -250,6 +266,10 @@ interface MessageBlockProps { setCollapsedAgents: (value: (prev: Set) => Set) => void addAutoCollapsedAgent: (value: string) => void onFeedback?: (messageId: string) => void + feedbackOpenMessageId?: string | null + feedbackMode?: boolean + onCloseFeedback?: () => void + messagesWithFeedback?: Set } interface AgentBodyProps { diff --git a/cli/src/components/message-with-agents.tsx b/cli/src/components/message-with-agents.tsx index 713f52ee8e..b66fd0b470 100644 --- a/cli/src/components/message-with-agents.tsx +++ b/cli/src/components/message-with-agents.tsx @@ -62,7 +62,11 @@ export const MessageWithAgents = memo( onBuildFast, onBuildMax, onFeedback, - }: MessageWithAgentsProps): ReactNode => { + feedbackOpenMessageId, + feedbackMode, + onCloseFeedback, + messagesWithFeedback, + }: MessageWithAgentsProps & { feedbackOpenMessageId?: string | null; feedbackMode?: boolean; onCloseFeedback?: () => void; messagesWithFeedback?: Set }): ReactNode => { const SIDE_GUTTER = 1 const isAgent = message.variant === 'agent' @@ -213,6 +217,10 @@ export const MessageWithAgents = memo( setCollapsedAgents={setCollapsedAgents} addAutoCollapsedAgent={addAutoCollapsedAgent} onFeedback={onFeedback} + feedbackOpenMessageId={feedbackOpenMessageId} + feedbackMode={feedbackMode} + onCloseFeedback={onCloseFeedback} + messagesWithFeedback={messagesWithFeedback} /> @@ -256,7 +264,11 @@ export const MessageWithAgents = memo( onBuildMax={onBuildMax} setCollapsedAgents={setCollapsedAgents} addAutoCollapsedAgent={addAutoCollapsedAgent} - onFeedback={onFeedback} + onFeedback={onFeedback} + feedbackOpenMessageId={feedbackOpenMessageId} + feedbackMode={feedbackMode} + onCloseFeedback={onCloseFeedback} + messagesWithFeedback={messagesWithFeedback} /> )} diff --git a/cli/src/components/status-indicator.tsx b/cli/src/components/status-indicator.tsx index 3897cabf0b..0fec18a452 100644 --- a/cli/src/components/status-indicator.tsx +++ b/cli/src/components/status-indicator.tsx @@ -90,7 +90,7 @@ export const StatusIndicator = ({ } if (state.kind === 'clipboard') { - return {state.message} + return {state.message} } if (state.kind === 'connecting') { From 466c29e64a724858178da64323bb47a688eecfc3 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 14 Nov 2025 20:13:50 -0800 Subject: [PATCH 06/12] fix: tweaks to feedback input bar --- cli/src/chat.tsx | 21 +++++++++++-------- .../__tests__/status-indicator.test.tsx | 12 +++++------ .../__tests__/status-indicator.timer.test.tsx | 14 ++++++------- cli/src/components/feedback-input-mode.tsx | 8 ++----- cli/src/components/status-indicator.tsx | 14 ++++++------- cli/src/hooks/use-clipboard.ts | 6 +++--- 6 files changed, 37 insertions(+), 38 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 5453f30231..bba6ad13fd 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -24,6 +24,7 @@ import { useAgentValidation } from './hooks/use-agent-validation' import { useAuthState } from './hooks/use-auth-state' import { useChatInput } from './hooks/use-chat-input' import { useClipboard } from './hooks/use-clipboard' +import { showClipboardMessage } from './utils/clipboard' import { useConnectionStatus } from './hooks/use-connection-status' import { useElapsedTime } from './hooks/use-elapsed-time' import { useExitHandler } from './hooks/use-exit-handler' @@ -223,7 +224,7 @@ export const Chat = ({ const abortControllerRef = useRef(null) const sendMessageRef = useRef() - const { clipboardMessage } = useClipboard() + const { statusMessage } = useClipboard() const isConnected = useConnectionStatus() const mainAgentTimer = useElapsedTime() const timerStartTime = mainAgentTimer.startTime @@ -459,7 +460,7 @@ export const Chat = ({ const [savedInputValue, setSavedInputValue] = useState('') const [savedCursorPosition, setSavedCursorPosition] = useState(0) const [showFeedbackConfirmation, setShowFeedbackConfirmation] = useState(false) - const [feedbackSentMessage, setFeedbackSentMessage] = useState(null) + const [messagesWithFeedback, setMessagesWithFeedback] = useState>(new Set()) const openFeedbackForMessage = useCallback((id: string) => { @@ -522,14 +523,16 @@ export const Chat = ({ setMessagesWithFeedback(prev => new Set(prev).add(feedbackMessageId)) } - // Show success in status indicator and exit feedback mode + // Exit feedback mode first setFeedbackMode(false) setFeedbackText('') setFeedbackCategory('other') - setFeedbackSentMessage('Feedback sent ✔') - setTimeout(() => { - setFeedbackSentMessage(null) - }, 5000) + + // Show success message in status indicator for 5 seconds + showClipboardMessage('Feedback sent ✔', { durationMs: 5000 }) + + // Restore input focus + setInputFocused(true) }, [feedbackText, feedbackCategory, feedbackMessageId, messages, agentMode, sessionCreditsUsed]) const handleFeedbackCancel = useCallback(() => { @@ -700,7 +703,7 @@ export const Chat = ({ const shouldCenterInputVertically = !hasSuggestionMenu && !showAgentStatusLine && !isMultilineInput const statusIndicatorState = getStatusIndicatorState({ - clipboardMessage, + statusMessage, streamStatus, nextCtrlCWillExit, isConnected, @@ -712,7 +715,7 @@ export const Chat = ({ const statusIndicatorNode = ( { describe('getStatusIndicatorState', () => { const baseArgs: StatusIndicatorStateArgs = { - clipboardMessage: null, + statusMessage: null, streamStatus: 'idle', nextCtrlCWillExit: false, isConnected: true, @@ -21,7 +21,7 @@ describe('StatusIndicator state logic', () => { const state = getStatusIndicatorState({ ...baseArgs, nextCtrlCWillExit: true, - clipboardMessage: 'Some message', + statusMessage: 'Some message', streamStatus: 'streaming', isConnected: false, }) @@ -31,7 +31,7 @@ describe('StatusIndicator state logic', () => { test('returns clipboard state when message exists (second priority)', () => { const state = getStatusIndicatorState({ ...baseArgs, - clipboardMessage: 'Copied to clipboard!', + statusMessage: 'Copied to clipboard!', streamStatus: 'streaming', isConnected: false, }) @@ -69,7 +69,7 @@ describe('StatusIndicator state logic', () => { test('handles empty clipboard message as falsy', () => { const state = getStatusIndicatorState({ ...baseArgs, - clipboardMessage: '', + statusMessage: '', streamStatus: 'streaming', }) // Empty string is falsy, should fall through to streaming state @@ -81,7 +81,7 @@ describe('StatusIndicator state logic', () => { const state = getStatusIndicatorState({ ...baseArgs, nextCtrlCWillExit: true, - clipboardMessage: 'Test', + statusMessage: 'Test', }) expect(state.kind).toBe('ctrlC') }) @@ -89,7 +89,7 @@ describe('StatusIndicator state logic', () => { test('clipboard beats connecting', () => { const state = getStatusIndicatorState({ ...baseArgs, - clipboardMessage: 'Test', + statusMessage: 'Test', isConnected: false, }) expect(state.kind).toBe('clipboard') diff --git a/cli/src/components/__tests__/status-indicator.timer.test.tsx b/cli/src/components/__tests__/status-indicator.timer.test.tsx index 56e3ef60f7..609187b85b 100644 --- a/cli/src/components/__tests__/status-indicator.timer.test.tsx +++ b/cli/src/components/__tests__/status-indicator.timer.test.tsx @@ -14,7 +14,7 @@ describe('StatusIndicator state transitions', () => { const now = Date.now() const markup = renderToStaticMarkup( { const now = Date.now() const markup = renderToStaticMarkup( { test('shows nothing when inactive (streamStatus = idle)', () => { const markup = renderToStaticMarkup( { const now = Date.now() const markup = renderToStaticMarkup( { const now = Date.now() const markup = renderToStaticMarkup( { test('shows "connecting..." shimmer when offline and idle', () => { const markup = renderToStaticMarkup( { test('getStatusIndicatorState reports connecting state when offline', () => { const state = getStatusIndicatorState({ - clipboardMessage: null, + statusMessage: null, streamStatus: 'idle', nextCtrlCWillExit: false, isConnected: false, diff --git a/cli/src/components/feedback-input-mode.tsx b/cli/src/components/feedback-input-mode.tsx index 7b01e762c2..64dfff5964 100644 --- a/cli/src/components/feedback-input-mode.tsx +++ b/cli/src/components/feedback-input-mode.tsx @@ -145,10 +145,7 @@ export const FeedbackInputMode: React.FC = ({ onKeyIntercept={(key) => { const isEnter = key.name === 'return' || key.name === 'enter' if (!isEnter) return false - if (key.meta) { - if (canSubmit) onSubmit() - return true - } + // Just add newline on Enter const newText = feedbackText.slice(0, feedbackCursor) + '\n' + feedbackText.slice(feedbackCursor) onFeedbackTextChange(newText, feedbackCursor + 1) return true @@ -198,8 +195,7 @@ export const FeedbackInputMode: React.FC = ({ }} > - SUBMIT - ⌘↵ + SUBMIT diff --git a/cli/src/components/status-indicator.tsx b/cli/src/components/status-indicator.tsx index 0fec18a452..7ee41ba58f 100644 --- a/cli/src/components/status-indicator.tsx +++ b/cli/src/components/status-indicator.tsx @@ -17,7 +17,7 @@ export type StatusIndicatorState = | { kind: 'streaming' } export type StatusIndicatorStateArgs = { - clipboardMessage?: string | null + statusMessage?: string | null streamStatus: StreamStatus nextCtrlCWillExit: boolean isConnected: boolean @@ -28,7 +28,7 @@ export type StatusIndicatorStateArgs = { * * State priority (highest to lowest): * 1. nextCtrlCWillExit - User pressed Ctrl+C once, warn about exit - * 2. clipboardMessage - Temporary feedback for clipboard operations + * 2. statusMessage - Temporary status messages (clipboard, feedback, etc.) * 3. connecting - Not connected to backend * 4. waiting - Waiting for AI response to start * 5. streaming - AI is actively responding @@ -38,7 +38,7 @@ export type StatusIndicatorStateArgs = { * @returns The appropriate state indicator */ export const getStatusIndicatorState = ({ - clipboardMessage, + statusMessage, streamStatus, nextCtrlCWillExit, isConnected, @@ -47,8 +47,8 @@ export const getStatusIndicatorState = ({ return { kind: 'ctrlC' } } - if (clipboardMessage) { - return { kind: 'clipboard', message: clipboardMessage } + if (statusMessage) { + return { kind: 'clipboard', message: statusMessage } } if (!isConnected) { @@ -71,7 +71,7 @@ type StatusIndicatorProps = StatusIndicatorStateArgs & { } export const StatusIndicator = ({ - clipboardMessage, + statusMessage, streamStatus, timerStartTime, nextCtrlCWillExit, @@ -79,7 +79,7 @@ export const StatusIndicator = ({ }: StatusIndicatorProps) => { const theme = useTheme() const state = getStatusIndicatorState({ - clipboardMessage, + statusMessage, streamStatus, nextCtrlCWillExit, isConnected, diff --git a/cli/src/hooks/use-clipboard.ts b/cli/src/hooks/use-clipboard.ts index e59b9f92a8..f901f51abb 100644 --- a/cli/src/hooks/use-clipboard.ts +++ b/cli/src/hooks/use-clipboard.ts @@ -17,7 +17,7 @@ function formatDefaultClipboardMessage(text: string): string | null { export const useClipboard = () => { const renderer = useRenderer() - const [clipboardMessage, setClipboardMessage] = useState(null) + const [statusMessage, setStatusMessage] = useState(null) const pendingCopyTimeoutRef = useRef | null>( null, ) @@ -26,7 +26,7 @@ export const useClipboard = () => { const lastCopiedRef = useRef(null) useEffect(() => { - return subscribeClipboardMessages(setClipboardMessage) + return subscribeClipboardMessages(setStatusMessage) }, []) useEffect(() => { @@ -94,6 +94,6 @@ export const useClipboard = () => { }, []) return { - clipboardMessage, + statusMessage, } } From 344b4ae59ddd345e2f32103575d5b9a6acc8ff19 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 14 Nov 2025 20:45:04 -0800 Subject: [PATCH 07/12] fix: design feedback --- cli/src/components/feedback-icon-button.tsx | 8 +++--- cli/src/components/feedback-input-mode.tsx | 27 ++++++++++++++------- cli/src/components/feedback-modal.tsx | 11 +++++---- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/cli/src/components/feedback-icon-button.tsx b/cli/src/components/feedback-icon-button.tsx index f7801e01bd..af55779686 100644 --- a/cli/src/components/feedback-icon-button.tsx +++ b/cli/src/components/feedback-icon-button.tsx @@ -36,8 +36,8 @@ export const FeedbackIconButton: React.FC = ({ onClick, } const handleMouseOut = () => hover.scheduleClose() - const textCollapsed = isOpen ? '[x]' : '[?]' - const textExpanded = isOpen ? '[close x]' : '[share feedback]' + const textCollapsed = '[?]' + const textExpanded = '[share feedback]' return ( ) diff --git a/cli/src/components/feedback-input-mode.tsx b/cli/src/components/feedback-input-mode.tsx index 64dfff5964..5538fefee1 100644 --- a/cli/src/components/feedback-input-mode.tsx +++ b/cli/src/components/feedback-input-mode.tsx @@ -1,5 +1,6 @@ -import React, { useCallback, useRef } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { useKeyboard } from '@opentui/react' +import { TextAttributes } from '@opentui/core' import { MultilineInput, type MultilineInputHandle } from './multiline-input' import { Button } from './button' @@ -32,6 +33,7 @@ export const FeedbackInputMode: React.FC = ({ const theme = useTheme() const inputRef = useRef(null) const canSubmit = feedbackText.trim().length > 0 + const [closeButtonHovered, setCloseButtonHovered] = useState(false) // Handle keyboard shortcuts useKeyboard( @@ -63,8 +65,8 @@ export const FeedbackInputMode: React.FC = ({ ) const categoryOptions = [ - { id: 'good_code', label: 'Good code', highlight: theme.success }, - { id: 'bad_code', label: 'Bad code', highlight: theme.error }, + { id: 'good_code', label: 'Good result', highlight: theme.success }, + { id: 'bad_code', label: 'Bad result', highlight: theme.error }, { id: 'bug', label: 'Bug', highlight: theme.warning }, { id: 'other', label: 'Other', highlight: theme.info }, ] as const @@ -85,10 +87,17 @@ export const FeedbackInputMode: React.FC = ({ }} > - {/* Helper text */} - - Share feedback — thanks for helping us improve! - + {/* Header: helper text + close X */} + + + Share feedback — thanks for helping us improve! + + setCloseButtonHovered(true)} onMouseOut={() => setCloseButtonHovered(false)}> + + X + + + {/* Category buttons */} @@ -177,7 +186,7 @@ export const FeedbackInputMode: React.FC = ({ gap: 2 }}> - Auto-attached: Message • Trace • Session + Auto-attached: message • trace • session diff --git a/cli/src/components/feedback-modal.tsx b/cli/src/components/feedback-modal.tsx index f5102822b1..3d8b000e7f 100644 --- a/cli/src/components/feedback-modal.tsx +++ b/cli/src/components/feedback-modal.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useRef, useState } from 'react' import { useRenderer, useKeyboard } from '@opentui/react' +import { TextAttributes } from '@opentui/core' import { MultilineInput, type MultilineInputHandle } from './multiline-input' import { Button } from './button' @@ -75,8 +76,8 @@ export const FeedbackModal: React.FC = ({ open, onClose, onS if (!open) return null const categoryOptions = [ - { id: 'good_code', label: 'Good code', highlight: theme.success }, - { id: 'bad_code', label: 'Bad code', highlight: theme.error }, + { id: 'good_code', label: 'Good result', highlight: theme.success }, + { id: 'bad_code', label: 'Bad result', highlight: theme.error }, { id: 'bug', label: 'Bug', highlight: theme.warning }, { id: 'other', label: 'Other', highlight: theme.info }, ] as const @@ -186,7 +187,7 @@ export const FeedbackModal: React.FC = ({ open, onClose, onS - Auto-attached: Message content • Trace data • Session info + Auto-attached: message • trace • session From 4ed8eb352aa0e5b8d89a82859b7a08517fceb891 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 17 Nov 2025 09:33:40 -0800 Subject: [PATCH 08/12] feat: new `/feedback` command --- cli/src/chat.tsx | 67 ++++--- cli/src/commands/router.ts | 24 ++- cli/src/components/feedback-input-mode.tsx | 6 +- cli/src/components/feedback-modal.tsx | 214 --------------------- cli/src/components/multiline-input.tsx | 2 +- cli/src/data/slash-commands.ts | 5 + 6 files changed, 65 insertions(+), 253 deletions(-) delete mode 100644 cli/src/components/feedback-modal.tsx diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index bba6ad13fd..fbc0f8c443 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -463,6 +463,12 @@ export const Chat = ({ const [messagesWithFeedback, setMessagesWithFeedback] = useState>(new Set()) + const resetFeedbackForm = useCallback(() => { + setFeedbackText('') + setFeedbackCursor(0) + setFeedbackCategory('other') + }, []) + const openFeedbackForMessage = useCallback((id: string) => { // Save current input state setSavedInputValue(inputValue) @@ -471,10 +477,8 @@ export const Chat = ({ // Enter feedback mode setFeedbackMessageId(id) setFeedbackMode(true) - setFeedbackText('') - setFeedbackCursor(0) - setFeedbackCategory('other') - }, [inputValue, cursorPosition]) + resetFeedbackForm() + }, [inputValue, cursorPosition, resetFeedbackForm]) const openFeedbackForLatestMessage = useCallback(() => { const latest = [...messages] @@ -491,7 +495,7 @@ export const Chat = ({ const text = feedbackText.trim() if (text.length === 0) return - const target = messages.find((m) => m.id === feedbackMessageId) + const target = feedbackMessageId ? messages.find((m) => m.id === feedbackMessageId) : null const recent = messages.slice(Math.max(0, messages.length - 5)).map((m) => ({ id: m.id, variant: m.variant, @@ -504,16 +508,17 @@ export const Chat = ({ { eventId: AnalyticsEvent.FEEDBACK_SUBMITTED, source: 'cli', - messageId: target?.id, - variant: target?.variant, - completionTime: target?.completionTime, - credits: target?.credits, + messageId: target?.id || null, + variant: target?.variant || null, + completionTime: target?.completionTime || null, + credits: target?.credits || null, agentMode, sessionCreditsUsed, recentMessages: recent, feedback: { text, category: feedbackCategory, + type: feedbackMessageId ? 'message' : 'general', }, }, ) @@ -525,8 +530,7 @@ export const Chat = ({ // Exit feedback mode first setFeedbackMode(false) - setFeedbackText('') - setFeedbackCategory('other') + resetFeedbackForm() // Show success message in status indicator for 5 seconds showClipboardMessage('Feedback sent ✔', { durationMs: 5000 }) @@ -545,13 +549,12 @@ export const Chat = ({ // Exit feedback mode setFeedbackMode(false) - setFeedbackText('') - setFeedbackCategory('other') - }, [savedInputValue, savedCursorPosition, setInputValue]) + resetFeedbackForm() + }, [resetFeedbackForm, savedInputValue, savedCursorPosition, setInputValue]) const handleSubmit = useCallback( - () => - routeUserPrompt({ + async () => { + const result = await routeUserPrompt({ abortControllerRef, agentMode, inputRef, @@ -574,7 +577,17 @@ export const Chat = ({ setMessages, setUser, stopStreaming, - }), + }) + + // Handle /feedback command + if (result && 'openFeedbackMode' in result && result.openFeedbackMode) { + setSavedInputValue('') + setSavedCursorPosition(0) + setFeedbackMessageId(null) // General feedback, not tied to a message + setFeedbackMode(true) + resetFeedbackForm() + } + }, [ agentMode, inputValue, @@ -589,6 +602,7 @@ export const Chat = ({ clearQueue, queuedMessages, pauseQueue, + resetFeedbackForm, ], ) @@ -711,7 +725,7 @@ export const Chat = ({ const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle' const shouldShowStatusLine = - hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom + !feedbackMode && (hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom) const statusIndicatorNode = ( { setFeedbackText(text) setFeedbackCursor(cursor) }} onCategoryChange={setFeedbackCategory} - onSubmit={handleFeedbackSubmit} - onCancel={handleFeedbackCancel} - width={terminalWidth - 2} - terminalWidth={terminalWidth} - /> + onSubmit={handleFeedbackSubmit} + onCancel={handleFeedbackCancel} + width={terminalWidth - 2} + /> ) : showFeedbackConfirmation ? ( [ ...prev, @@ -131,7 +139,7 @@ export async function routeUserPrompt(params: { ), ]) setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) - return + return undefined } if (cmd === 'logout' || cmd === 'signout') { abortControllerRef.current?.abort() @@ -148,12 +156,12 @@ export async function routeUserPrompt(params: { }, 300) }, }) - return + return undefined } if (cmd === 'exit' || cmd === 'quit') { process.kill(process.pid, 'SIGINT') - return + return undefined } if (cmd === 'clear' || cmd === 'new') { @@ -165,7 +173,7 @@ export async function routeUserPrompt(params: { stopStreaming() setCanProcessQueue(false) - return + return undefined } if (cmd === 'init') { @@ -178,7 +186,7 @@ export async function routeUserPrompt(params: { setMessages((prev) => usagePostMessage(prev)) saveToHistory(trimmed) setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) - return + return undefined } saveToHistory(trimmed) @@ -192,7 +200,7 @@ export async function routeUserPrompt(params: { addToQueue(trimmed) setInputFocused(true) inputRef.current?.focus() - return + return undefined } if (trimmed.startsWith('/') && cmd !== 'init') { @@ -201,7 +209,7 @@ export async function routeUserPrompt(params: { getUserMessage(trimmed), getSystemMessage(`Command not found: ${JSON.stringify(trimmed)}`), ]) - return + return undefined } sendMessage({ content: trimmed, agentMode, postUserMessage }) @@ -209,4 +217,6 @@ export async function routeUserPrompt(params: { setTimeout(() => { scrollToLatest() }, 0) + + return undefined } diff --git a/cli/src/components/feedback-input-mode.tsx b/cli/src/components/feedback-input-mode.tsx index 5538fefee1..2b6450e56c 100644 --- a/cli/src/components/feedback-input-mode.tsx +++ b/cli/src/components/feedback-input-mode.tsx @@ -16,7 +16,6 @@ interface FeedbackInputModeProps { onSubmit: () => void onCancel: () => void width: number - terminalWidth: number } export const FeedbackInputMode: React.FC = ({ @@ -28,7 +27,6 @@ export const FeedbackInputMode: React.FC = ({ onSubmit, onCancel, width, - terminalWidth, }) => { const theme = useTheme() const inputRef = useRef(null) @@ -141,7 +139,7 @@ export const FeedbackInputMode: React.FC = ({ {/* Feedback input */} - + { text: string; cursorPosition: number; lastEditDueToNav: boolean })) => { @@ -210,4 +208,4 @@ export const FeedbackInputMode: React.FC = ({ ) -} \ No newline at end of file +} diff --git a/cli/src/components/feedback-modal.tsx b/cli/src/components/feedback-modal.tsx deleted file mode 100644 index 3d8b000e7f..0000000000 --- a/cli/src/components/feedback-modal.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import React, { useCallback, useRef, useState } from 'react' -import { useRenderer, useKeyboard } from '@opentui/react' -import { TextAttributes } from '@opentui/core' - -import { MultilineInput, type MultilineInputHandle } from './multiline-input' -import { Button } from './button' -import { useTheme } from '../hooks/use-theme' -import { BORDER_CHARS } from '../utils/ui-constants' -import type { ChatMessage } from '../types/chat' - -interface FeedbackModalProps { - open: boolean - message: ChatMessage | null - onClose: () => void - onSubmit: (data: { text: string; category: string | null }) => void -} - -export const FeedbackModal: React.FC = ({ open, onClose, onSubmit }) => { - const theme = useTheme() - const renderer = useRenderer() - const [feedbackText, setFeedbackText] = useState('') - const [feedbackCursor, setFeedbackCursor] = useState(0) - const [category, setCategory] = useState('other') - const inputRef = useRef(null) - - const terminalWidth = renderer?.width || 80 - const terminalHeight = renderer?.height || 24 - - const modalWidth = Math.max(60, Math.min(terminalWidth - 4, 100)) - const modalHeight = Math.max(12, Math.min(terminalHeight - 4, 24)) - const modalLeft = Math.floor((terminalWidth - modalWidth) / 2) - const modalTop = Math.floor((terminalHeight - modalHeight) / 2) - - const handleSubmit = useCallback(() => { - const text = feedbackText.trim() - if (text.length === 0) return - onSubmit({ text, category }) - setFeedbackText('') - setCategory('other') - }, [onSubmit, feedbackText, category]) - - // Handle Ctrl+C: clear input first, then close if already empty - // Handle Escape: close modal directly - useKeyboard( - useCallback( - (key) => { - if (!open) return - - const isCtrlC = key.ctrl && key.name === 'c' - const isEscape = key.name === 'escape' - - if (!isCtrlC && !isEscape) return - - if ('preventDefault' in key && typeof key.preventDefault === 'function') { - key.preventDefault() - } - - if (isEscape) { - // Escape always closes the modal - onClose() - } else if (isCtrlC) { - if (feedbackText.length === 0) { - // Input is already empty, close the modal - onClose() - } else { - // Clear the input - setFeedbackText('') - setFeedbackCursor(0) - } - } - }, - [open, feedbackText, onClose] - ) - ) - - if (!open) return null - - const categoryOptions = [ - { id: 'good_code', label: 'Good result', highlight: theme.success }, - { id: 'bad_code', label: 'Bad result', highlight: theme.error }, - { id: 'bug', label: 'Bug', highlight: theme.warning }, - { id: 'other', label: 'Other', highlight: theme.info }, - ] as const - - const canSubmit = feedbackText.trim().length > 0 - - return ( - - - - Share Feedback - - - - - - Thanks for helping us improve! What happened? - - - - - Category: - - - {categoryOptions.map((option) => { - const isSelected = category === option.id - return ( - - ) - })} - - - - - { text: string; cursorPosition: number; lastEditDueToNav: boolean })) => { - const v = typeof next === 'function' ? next({ text: feedbackText, cursorPosition: feedbackCursor, lastEditDueToNav: false }) : next - setFeedbackText(v.text) - setFeedbackCursor(v.cursorPosition) - }} - onSubmit={handleSubmit} - placeholder={'Tell us more...'} - focused={true} - maxHeight={6} - width={modalWidth - 6} - textAttributes={undefined} - ref={inputRef} - cursorPosition={feedbackCursor} - /> - - - - - Auto-attached: message • trace • session - - - - - ) -} diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index b62f831a24..8f428fec74 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -778,7 +778,7 @@ export const MultilineInput = forwardRef< border: false, }, contentOptions: { - justifyContent: 'flex-end', + justifyContent: 'flex-start', }, }} > diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 408bec6882..f86daeb001 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -56,4 +56,9 @@ export const SLASH_COMMANDS: SlashCommand[] = [ description: 'Start a fresh conversation session', aliases: ['reset', 'clear'], }, + { + id: 'feedback', + label: 'feedback', + description: 'Share general feedback about Codebuff', + }, ] From 9977e88abfdc5c98a97511ddfc11aea026a4a8a9 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 17 Nov 2025 12:00:57 -0800 Subject: [PATCH 09/12] Fix feedback input height --- cli/src/components/feedback-input-mode.tsx | 1 + cli/src/components/multiline-input.tsx | 29 +++++++++++++++------ cli/src/utils/__tests__/text-layout.test.ts | 26 ++++++++++++++++++ cli/src/utils/text-layout.ts | 17 +++++++++--- 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/cli/src/components/feedback-input-mode.tsx b/cli/src/components/feedback-input-mode.tsx index 2b6450e56c..f38ae77c67 100644 --- a/cli/src/components/feedback-input-mode.tsx +++ b/cli/src/components/feedback-input-mode.tsx @@ -160,6 +160,7 @@ export const FeedbackInputMode: React.FC = ({ placeholder={'Tell us more (what happened, what you expected)...'} focused={true} maxHeight={5} + minHeight={3} width={width - 4} textAttributes={undefined} ref={inputRef} diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index 8f428fec74..fdef02ec05 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -97,6 +97,7 @@ interface MultilineInputProps { placeholder?: string focused?: boolean maxHeight?: number + minHeight?: number width: number textAttributes?: number cursorPosition: number @@ -117,6 +118,7 @@ export const MultilineInput = forwardRef< placeholder = '', focused = true, maxHeight = 5, + minHeight = 1, width, textAttributes, onKeyIntercept, @@ -220,7 +222,10 @@ export const MultilineInput = forwardRef< useEffect(() => { const node = scrollBoxRef.current if (!node) return - const vpWidth = Math.max(0, Math.floor(node.viewport.width || 0)) + const viewportWidth = Number(node.viewport?.width ?? 0) + if (!Number.isFinite(viewportWidth)) return + const vpWidth = Math.floor(viewportWidth) + if (vpWidth <= 0) return // viewport.width already reflects inner content area; don't subtract again const cols = Math.max(1, vpWidth) setMeasuredCols(cols) @@ -253,6 +258,13 @@ export const MultilineInput = forwardRef< cursorPosition < displayValue.length && displayValue[cursorPosition] !== '\n' + // Use the actual input contents for measurement so placeholder text + // doesn't change height calculations when the user starts typing. + const measurementValue = isPlaceholder ? value : displayValue + const measurementAfterCursor = showCursor + ? measurementValue.slice(cursorPosition) + : '' + // Handle all keyboard input with advanced shortcuts useKeyboard( useCallback( @@ -716,15 +728,15 @@ export const MultilineInput = forwardRef< const layoutContent = showCursor ? shouldHighlight - ? displayValue - : `${displayValue.slice(0, cursorPosition)}${CURSOR_CHAR}${afterCursor}` - : displayValue + ? measurementValue + : `${measurementValue.slice(0, cursorPosition)}${CURSOR_CHAR}${measurementAfterCursor}` + : measurementValue const cursorProbe = showCursor ? shouldHighlight - ? displayValue.slice(0, cursorPosition + 1) - : `${displayValue.slice(0, cursorPosition)}${CURSOR_CHAR}` - : displayValue.slice(0, cursorPosition) + ? measurementValue.slice(0, cursorPosition + 1) + : `${measurementValue.slice(0, cursorPosition)}${CURSOR_CHAR}` + : measurementValue.slice(0, cursorPosition) const layoutMetrics = useMemo( () => @@ -733,8 +745,9 @@ export const MultilineInput = forwardRef< cursorProbe, cols: getEffectiveCols(), maxHeight, + minHeight, }), - [layoutContent, cursorProbe, getEffectiveCols, maxHeight], + [layoutContent, cursorProbe, getEffectiveCols, maxHeight, minHeight], ) const height = layoutMetrics.heightLines diff --git a/cli/src/utils/__tests__/text-layout.test.ts b/cli/src/utils/__tests__/text-layout.test.ts index bb90b8eca6..a4554d98ca 100644 --- a/cli/src/utils/__tests__/text-layout.test.ts +++ b/cli/src/utils/__tests__/text-layout.test.ts @@ -53,4 +53,30 @@ describe('computeInputLayoutMetrics', () => { expect(metrics.heightLines).toBe(2) expect(metrics.gutterEnabled).toBe(false) }) + + test('respects a minimum height constraint', () => { + const metrics = computeInputLayoutMetrics({ + layoutContent: 'short', + cursorProbe: 'short', + cols: 40, + maxHeight: 5, + minHeight: 3, + }) + + expect(metrics.heightLines).toBe(3) + expect(metrics.gutterEnabled).toBe(false) + }) + + test('caps the minimum height at the max height', () => { + const metrics = computeInputLayoutMetrics({ + layoutContent: 'tiny', + cursorProbe: 'tiny', + cols: 40, + maxHeight: 2, + minHeight: 5, + }) + + expect(metrics.heightLines).toBe(2) + expect(metrics.gutterEnabled).toBe(false) + }) }) diff --git a/cli/src/utils/text-layout.ts b/cli/src/utils/text-layout.ts index 5d4f834850..d7f17472b9 100644 --- a/cli/src/utils/text-layout.ts +++ b/cli/src/utils/text-layout.ts @@ -115,24 +115,33 @@ export function computeInputLayoutMetrics({ cursorProbe, cols, maxHeight, + minHeight = 1, }: { layoutContent: string cursorProbe: string cols: number maxHeight: number + minHeight?: number }): { heightLines: number; gutterEnabled: boolean } { + const safeMaxHeight = Math.max(1, maxHeight) + const effectiveMinHeight = Math.max( + 1, + Math.min(minHeight ?? 1, safeMaxHeight), + ) const totalLines = measureLines(layoutContent, cols) const cursorLines = measureLines(cursorProbe, cols) // Add bottom gutter when cursor is on line 2 of exactly 2 lines const gutterEnabled = - totalLines === 2 && cursorLines === 2 && totalLines + 1 <= maxHeight + totalLines === 2 && cursorLines === 2 && totalLines + 1 <= safeMaxHeight - const heightLines = Math.max( - 1, - Math.min(totalLines + (gutterEnabled ? 1 : 0), maxHeight), + const rawHeight = Math.min( + totalLines + (gutterEnabled ? 1 : 0), + safeMaxHeight, ) + const heightLines = Math.max(effectiveMinHeight, rawHeight) + return { heightLines, gutterEnabled, From b313cf731906b50f897c2b190e4b3f5231555b36 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 17 Nov 2025 12:14:52 -0800 Subject: [PATCH 10/12] feat(feedback): improve feedback UI with separator, category rename, and custom placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bullet separator (•) between completion time/credits and feedback icon - Rename feedback categories from good_code/bad_code to good_result/bad_result - Add category-specific placeholder messages to guide user input: - Good result: prompts for what they liked - Bad result: prompts for what went wrong - Bug: prompts for bug description - Other: general feedback prompt - Update feedback icon symbol logic to use new category names --- cli/src/chat.tsx | 16 +++- cli/src/components/feedback-icon-button.tsx | 37 +++++++-- cli/src/components/feedback-input-mode.tsx | 10 +-- cli/src/components/message-block.tsx | 28 +++++-- cli/src/components/message-with-agents.tsx | 55 +++++++++--- cli/src/components/multiline-input.tsx | 92 ++++++++++++++------- 6 files changed, 181 insertions(+), 57 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index fbc0f8c443..3467bc740d 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -462,6 +462,7 @@ export const Chat = ({ const [showFeedbackConfirmation, setShowFeedbackConfirmation] = useState(false) const [messagesWithFeedback, setMessagesWithFeedback] = useState>(new Set()) + const [messageFeedbackCategories, setMessageFeedbackCategories] = useState>(new Map()) const resetFeedbackForm = useCallback(() => { setFeedbackText('') @@ -526,6 +527,12 @@ export const Chat = ({ // Mark this message as having feedback submitted if (feedbackMessageId) { setMessagesWithFeedback(prev => new Set(prev).add(feedbackMessageId)) + // Remove the category since feedback is submitted + setMessageFeedbackCategories(prev => { + const next = new Map(prev) + next.delete(feedbackMessageId) + return next + }) } // Exit feedback mode first @@ -842,6 +849,7 @@ export const Chat = ({ feedbackMode={feedbackMode} onCloseFeedback={handleFeedbackCancel} messagesWithFeedback={messagesWithFeedback} + messageFeedbackCategories={messageFeedbackCategories} /> ) })} @@ -932,7 +940,13 @@ export const Chat = ({ setFeedbackText(text) setFeedbackCursor(cursor) }} - onCategoryChange={setFeedbackCategory} + onCategoryChange={(category) => { + setFeedbackCategory(category) + // Store category selection for this message so button can show it + if (feedbackMessageId) { + setMessageFeedbackCategories(prev => new Map(prev).set(feedbackMessageId, category)) + } + }} onSubmit={handleFeedbackSubmit} onCancel={handleFeedbackCancel} width={terminalWidth - 2} diff --git a/cli/src/components/feedback-icon-button.tsx b/cli/src/components/feedback-icon-button.tsx index af55779686..a05d3a7313 100644 --- a/cli/src/components/feedback-icon-button.tsx +++ b/cli/src/components/feedback-icon-button.tsx @@ -1,3 +1,4 @@ +import { TextAttributes } from '@opentui/core' import React, { useRef } from 'react' import { useHoverToggle } from './agent-mode-toggle' @@ -12,9 +13,16 @@ interface FeedbackIconButtonProps { onClose?: () => void isOpen?: boolean messageId?: string + selectedCategory?: string } -export const FeedbackIconButton: React.FC = ({ onClick, onClose, isOpen, messageId }) => { +export const FeedbackIconButton: React.FC = ({ + onClick, + onClose, + isOpen, + messageId, + selectedCategory, +}) => { const theme = useTheme() const hover = useHoverToggle() const hoveredOnceRef = useRef(false) @@ -36,8 +44,18 @@ export const FeedbackIconButton: React.FC = ({ onClick, } const handleMouseOut = () => hover.scheduleClose() - const textCollapsed = '[?]' - const textExpanded = '[share feedback]' + // Determine which symbol to show based on selected category + const getSymbol = () => { + if (selectedCategory === 'good_result') { + return '▲▽' // Good selected - filled up, outlined down + } else if (selectedCategory === 'bad_result') { + return '△▼' // Bad selected - outlined up, filled down + } + return '△▽' // Default - both outlined + } + + const textCollapsed = `${getSymbol()}` + const textExpanded = '[how was this?]' return ( ) diff --git a/cli/src/components/feedback-input-mode.tsx b/cli/src/components/feedback-input-mode.tsx index f38ae77c67..f1b281e7ce 100644 --- a/cli/src/components/feedback-input-mode.tsx +++ b/cli/src/components/feedback-input-mode.tsx @@ -63,10 +63,10 @@ export const FeedbackInputMode: React.FC = ({ ) const categoryOptions = [ - { id: 'good_code', label: 'Good result', highlight: theme.success }, - { id: 'bad_code', label: 'Bad result', highlight: theme.error }, - { id: 'bug', label: 'Bug', highlight: theme.warning }, - { id: 'other', label: 'Other', highlight: theme.info }, + { id: 'good_result', label: 'Good result', highlight: theme.success, placeholder: 'What did you like? (e.g., "Fast and accurate", "Great explanation")' }, + { id: 'bad_result', label: 'Bad result', highlight: theme.error, placeholder: 'What went wrong? (e.g., "Incorrect changes", "Missed the requirement")' }, + { id: 'app_bug', label: 'App bug', highlight: theme.warning, placeholder: 'Report a problem with Codebuff (crashes, errors, UI issues, etc.)' }, + { id: 'other', label: 'Other', highlight: theme.info, placeholder: 'Tell us more (what happened, what you expected)...' }, ] as const return ( @@ -157,7 +157,7 @@ export const FeedbackInputMode: React.FC = ({ onFeedbackTextChange(newText, feedbackCursor + 1) return true }} - placeholder={'Tell us more (what happened, what you expected)...'} + placeholder={categoryOptions.find(opt => opt.id === category)?.placeholder || 'Tell us more (what happened, what you expected)...'} focused={true} maxHeight={5} minHeight={3} diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index ed6f74f769..c374c115f9 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -84,6 +84,7 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => { feedbackMode, onCloseFeedback, messagesWithFeedback, + messageFeedbackCategories, } = props useWhyDidYouUpdateById('MessageBlock', messageId, props, { logLevel: 'debug', @@ -182,12 +183,26 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => { {typeof credits === 'number' && credits > 0 && ` • ${pluralize(credits, 'credit')}`} {!messagesWithFeedback?.has(messageId) && ( - onFeedback?.(messageId)} - onClose={onCloseFeedback} - isOpen={Boolean(feedbackMode && feedbackOpenMessageId === messageId)} - messageId={messageId} - /> + <> + + • + + onFeedback?.(messageId)} + onClose={onCloseFeedback} + isOpen={Boolean(feedbackMode && feedbackOpenMessageId === messageId)} + messageId={messageId} + selectedCategory={messageFeedbackCategories?.get(messageId)} + /> + )} )} @@ -270,6 +285,7 @@ interface MessageBlockProps { feedbackMode?: boolean onCloseFeedback?: () => void messagesWithFeedback?: Set + messageFeedbackCategories?: Map } interface AgentBodyProps { diff --git a/cli/src/components/message-with-agents.tsx b/cli/src/components/message-with-agents.tsx index b66fd0b470..c3284ef4da 100644 --- a/cli/src/components/message-with-agents.tsx +++ b/cli/src/components/message-with-agents.tsx @@ -37,6 +37,11 @@ interface MessageWithAgentsProps { onBuildFast: () => void onBuildMax: () => void onFeedback: (messageId: string) => void + feedbackOpenMessageId?: string | null + feedbackMode?: boolean + onCloseFeedback?: () => void + messagesWithFeedback?: Set + messageFeedbackCategories?: Map } export const MessageWithAgents = memo( @@ -66,7 +71,8 @@ export const MessageWithAgents = memo( feedbackMode, onCloseFeedback, messagesWithFeedback, - }: MessageWithAgentsProps & { feedbackOpenMessageId?: string | null; feedbackMode?: boolean; onCloseFeedback?: () => void; messagesWithFeedback?: Set }): ReactNode => { + messageFeedbackCategories, + }: MessageWithAgentsProps): ReactNode => { const SIDE_GUTTER = 1 const isAgent = message.variant === 'agent' @@ -93,6 +99,11 @@ export const MessageWithAgents = memo( onBuildFast={onBuildFast} onBuildMax={onBuildMax} onFeedback={onFeedback} + feedbackOpenMessageId={feedbackOpenMessageId} + feedbackMode={feedbackMode} + onCloseFeedback={onCloseFeedback} + messagesWithFeedback={messagesWithFeedback} + messageFeedbackCategories={messageFeedbackCategories} /> ) } @@ -216,11 +227,12 @@ export const MessageWithAgents = memo( onBuildMax={onBuildMax} setCollapsedAgents={setCollapsedAgents} addAutoCollapsedAgent={addAutoCollapsedAgent} - onFeedback={onFeedback} - feedbackOpenMessageId={feedbackOpenMessageId} - feedbackMode={feedbackMode} - onCloseFeedback={onCloseFeedback} - messagesWithFeedback={messagesWithFeedback} + onFeedback={onFeedback} + feedbackOpenMessageId={feedbackOpenMessageId} + feedbackMode={feedbackMode} + onCloseFeedback={onCloseFeedback} + messagesWithFeedback={messagesWithFeedback} + messageFeedbackCategories={messageFeedbackCategories} /> @@ -264,11 +276,12 @@ export const MessageWithAgents = memo( onBuildMax={onBuildMax} setCollapsedAgents={setCollapsedAgents} addAutoCollapsedAgent={addAutoCollapsedAgent} - onFeedback={onFeedback} - feedbackOpenMessageId={feedbackOpenMessageId} - feedbackMode={feedbackMode} - onCloseFeedback={onCloseFeedback} - messagesWithFeedback={messagesWithFeedback} + onFeedback={onFeedback} + feedbackOpenMessageId={feedbackOpenMessageId} + feedbackMode={feedbackMode} + onCloseFeedback={onCloseFeedback} + messagesWithFeedback={messagesWithFeedback} + messageFeedbackCategories={messageFeedbackCategories} /> )} @@ -300,6 +313,11 @@ export const MessageWithAgents = memo( onBuildFast={onBuildFast} onBuildMax={onBuildMax} onFeedback={onFeedback} + feedbackOpenMessageId={feedbackOpenMessageId} + feedbackMode={feedbackMode} + onCloseFeedback={onCloseFeedback} + messagesWithFeedback={messagesWithFeedback} + messageFeedbackCategories={messageFeedbackCategories} /> ))} @@ -331,6 +349,11 @@ interface AgentMessageProps { onBuildFast: () => void onBuildMax: () => void onFeedback: (messageId: string) => void + feedbackOpenMessageId?: string | null + feedbackMode?: boolean + onCloseFeedback?: () => void + messagesWithFeedback?: Set + messageFeedbackCategories?: Map } const AgentMessage = memo( @@ -355,6 +378,11 @@ const AgentMessage = memo( onBuildFast, onBuildMax, onFeedback, + feedbackOpenMessageId, + feedbackMode, + onCloseFeedback, + messagesWithFeedback, + messageFeedbackCategories, }: AgentMessageProps): ReactNode => { const agentInfo = message.agent! const isCollapsed = collapsedAgents.has(message.id) @@ -553,6 +581,11 @@ const AgentMessage = memo( onBuildFast={onBuildFast} onBuildMax={onBuildMax} onFeedback={onFeedback} + feedbackOpenMessageId={feedbackOpenMessageId} + feedbackMode={feedbackMode} + onCloseFeedback={onCloseFeedback} + messagesWithFeedback={messagesWithFeedback} + messageFeedbackCategories={messageFeedbackCategories} /> ))} diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index fdef02ec05..dfb76057cb 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -249,21 +249,69 @@ export const MultilineInput = forwardRef< const isPlaceholder = value.length === 0 && placeholder.length > 0 const displayValue = isPlaceholder ? placeholder : value const showCursor = focused - const beforeCursor = showCursor ? displayValue.slice(0, cursorPosition) : '' - const afterCursor = showCursor ? displayValue.slice(cursorPosition) : '' - const activeChar = afterCursor.charAt(0) || ' ' - const shouldHighlight = - showCursor && - !isPlaceholder && - cursorPosition < displayValue.length && - displayValue[cursorPosition] !== '\n' - - // Use the actual input contents for measurement so placeholder text - // doesn't change height calculations when the user starts typing. - const measurementValue = isPlaceholder ? value : displayValue - const measurementAfterCursor = showCursor - ? measurementValue.slice(cursorPosition) - : '' + + const { + beforeCursor, + afterCursor, + activeChar, + shouldHighlight, + layoutContent, + cursorProbe, + } = useMemo(() => { + if (!showCursor) { + const layoutText = displayValue + const safeCursor = Math.max( + 0, + Math.min(cursorPosition, layoutText.length), + ) + + return { + beforeCursor: '', + afterCursor: '', + activeChar: ' ', + shouldHighlight: false, + layoutContent: layoutText, + cursorProbe: layoutText.slice(0, safeCursor), + } + } + + const displayCursor = Math.max( + 0, + Math.min(cursorPosition, displayValue.length), + ) + const beforeCursor = displayValue.slice(0, displayCursor) + const afterCursor = displayValue.slice(displayCursor) + const activeChar = afterCursor.charAt(0) || ' ' + const shouldHighlight = + !isPlaceholder && + displayCursor < displayValue.length && + displayValue[displayCursor] !== '\n' + + // Use the actual input contents for measurement so placeholder text + // doesn't change height calculations when the user starts typing. + const measurementValue = isPlaceholder ? value : displayValue + const measurementCursor = Math.max( + 0, + Math.min(cursorPosition, measurementValue.length), + ) + + const layoutContent = shouldHighlight + ? measurementValue + : `${measurementValue.slice(0, measurementCursor)}${CURSOR_CHAR}${measurementValue.slice(measurementCursor)}` + + const cursorProbe = shouldHighlight + ? measurementValue.slice(0, measurementCursor + 1) + : `${measurementValue.slice(0, measurementCursor)}${CURSOR_CHAR}` + + return { + beforeCursor, + afterCursor, + activeChar, + shouldHighlight, + layoutContent, + cursorProbe, + } + }, [showCursor, displayValue, cursorPosition, isPlaceholder, value]) // Handle all keyboard input with advanced shortcuts useKeyboard( @@ -724,20 +772,6 @@ export const MultilineInput = forwardRef< ), ) - // Calculate display with cursor - - const layoutContent = showCursor - ? shouldHighlight - ? measurementValue - : `${measurementValue.slice(0, cursorPosition)}${CURSOR_CHAR}${measurementAfterCursor}` - : measurementValue - - const cursorProbe = showCursor - ? shouldHighlight - ? measurementValue.slice(0, cursorPosition + 1) - : `${measurementValue.slice(0, cursorPosition)}${CURSOR_CHAR}` - : measurementValue.slice(0, cursorPosition) - const layoutMetrics = useMemo( () => computeInputLayoutMetrics({ From 7cd5a06fd6a6f315c3bd524e69f7e7c810244832 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 17 Nov 2025 12:46:23 -0800 Subject: [PATCH 11/12] refactor: rename clipboardMessage to statusMessage Renamed clipboardMessage to statusMessage throughout the codebase for better semantic clarity. The statusMessage better represents that this can be used for various status updates, not just clipboard operations (e.g., feedback confirmations). --- cli/src/chat.tsx | 2 +- cli/src/components/status-bar.tsx | 8 ++++---- cli/src/utils/status-indicator-state.ts | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 38ddf5314a..11fe3ae039 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -843,7 +843,7 @@ export const Chat = ({ > {shouldShowStatusLine && ( Press Ctrl-C again to exit } - if (clipboardMessage) { - return {clipboardMessage} + if (statusMessage) { + return {statusMessage} } if (!isConnected) { diff --git a/cli/src/utils/status-indicator-state.ts b/cli/src/utils/status-indicator-state.ts index c9beb4ec45..e3dda926d8 100644 --- a/cli/src/utils/status-indicator-state.ts +++ b/cli/src/utils/status-indicator-state.ts @@ -9,7 +9,7 @@ export type StatusIndicatorState = | { kind: 'streaming' } export type StatusIndicatorStateArgs = { - clipboardMessage?: string | null + statusMessage?: string | null streamStatus: StreamStatus nextCtrlCWillExit: boolean isConnected: boolean @@ -17,20 +17,20 @@ export type StatusIndicatorStateArgs = { /** * Determines the status indicator state based on current context. - * + * * State priority (highest to lowest): * 1. nextCtrlCWillExit - User pressed Ctrl+C once, warn about exit - * 2. clipboardMessage - Temporary feedback for clipboard operations + * 2. statusMessage - Temporary feedback for clipboard operations * 3. connecting - Not connected to backend * 4. waiting - Waiting for AI response to start * 5. streaming - AI is actively responding * 6. idle - No activity - * + * * @param args - Context for determining indicator state * @returns The appropriate state indicator */ export const getStatusIndicatorState = ({ - clipboardMessage, + statusMessage, streamStatus, nextCtrlCWillExit, isConnected, @@ -39,8 +39,8 @@ export const getStatusIndicatorState = ({ return { kind: 'ctrlC' } } - if (clipboardMessage) { - return { kind: 'clipboard', message: clipboardMessage } + if (statusMessage) { + return { kind: 'clipboard', message: statusMessage } } if (!isConnected) { From 0c31c2d2cc732cf57cd9ec46dbe803ba406f023f Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 17 Nov 2025 12:48:39 -0800 Subject: [PATCH 12/12] feat: make feedback success message green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display 'Feedback sent ✔' message in green color (theme.success) to provide better visual confirmation that feedback was successfully submitted. --- cli/src/components/status-bar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/src/components/status-bar.tsx b/cli/src/components/status-bar.tsx index 20607f5727..2c72334009 100644 --- a/cli/src/components/status-bar.tsx +++ b/cli/src/components/status-bar.tsx @@ -57,7 +57,9 @@ export const StatusBar = ({ } if (statusMessage) { - return {statusMessage} + // Use green color for feedback success messages + const isFeedbackSuccess = statusMessage.includes('Feedback sent') + return {statusMessage} } if (!isConnected) {