From 21cbce8a41e5899b44deadb8ac5b5e9cc918917c Mon Sep 17 00:00:00 2001 From: francy51 Date: Mon, 6 Apr 2026 00:57:12 -0400 Subject: [PATCH] Make portfolio state reactive in sidebar and improve command autocomplete controls --- .../src-tauri/src/terminal/command_service.rs | 76 ++++- MosaicIQ/src-tauri/src/terminal/types.rs | 6 +- MosaicIQ/src/App.tsx | 38 +-- .../src/components/Panels/PortfolioPanel.tsx | 134 +++----- .../components/Sidebar/PortfolioSummary.tsx | 63 ++-- .../src/components/Terminal/CommandInput.tsx | 257 +++++++++++---- MosaicIQ/src/components/Terminal/Terminal.tsx | 6 - MosaicIQ/src/components/ui/ActionButton.tsx | 56 ++++ MosaicIQ/src/components/ui/index.ts | 3 + MosaicIQ/src/hooks/usePortfolioWorkflow.ts | 19 +- MosaicIQ/src/lib/terminalCommandSpecs.ts | 305 ++++++++++++++++++ MosaicIQ/src/lib/terminalShadow.ts | 214 ++++++++++++ MosaicIQ/src/types/terminal.ts | 4 +- 13 files changed, 946 insertions(+), 235 deletions(-) create mode 100644 MosaicIQ/src/components/ui/ActionButton.tsx create mode 100644 MosaicIQ/src/lib/terminalCommandSpecs.ts create mode 100644 MosaicIQ/src/lib/terminalShadow.ts diff --git a/MosaicIQ/src-tauri/src/terminal/command_service.rs b/MosaicIQ/src-tauri/src/terminal/command_service.rs index 068d37d..1661395 100644 --- a/MosaicIQ/src-tauri/src/terminal/command_service.rs +++ b/MosaicIQ/src-tauri/src/terminal/command_service.rs @@ -70,6 +70,7 @@ impl TerminalCommandService { if command.args.len() > 2 { TerminalCommandResponse::Text { content: "Usage: /fa [ticker] [annual|quarterly]".to_string(), + portfolio: None, } } else { self.financials(command.args.first(), command.args.get(1), Frequency::Annual) @@ -80,6 +81,7 @@ impl TerminalCommandService { if command.args.len() > 2 { TerminalCommandResponse::Text { content: "Usage: /cf [ticker] [annual|quarterly]".to_string(), + portfolio: None, } } else { self.cash_flow(command.args.first(), command.args.get(1), Frequency::Annual) @@ -90,6 +92,7 @@ impl TerminalCommandService { if command.args.len() > 1 { TerminalCommandResponse::Text { content: "Usage: /dvd [ticker]".to_string(), + portfolio: None, } } else { self.dividends(command.args.first(), command.args.get(1)) @@ -100,6 +103,7 @@ impl TerminalCommandService { if command.args.len() > 2 { TerminalCommandResponse::Text { content: "Usage: /em [ticker] [annual|quarterly]".to_string(), + portfolio: None, } } else { self.earnings( @@ -115,6 +119,7 @@ impl TerminalCommandService { "/help" => help_response(), _ => TerminalCommandResponse::Text { content: format!("Unknown command: {}\n\n{}", command.command, help_text()), + portfolio: None, }, } } @@ -265,6 +270,7 @@ impl TerminalCommandService { let Some(ticker) = ticker.map(str::trim).filter(|value| !value.is_empty()) else { return TerminalCommandResponse::Text { content: "Usage: /analyze [ticker]".to_string(), + portfolio: None, }; }; @@ -276,6 +282,7 @@ impl TerminalCommandService { }, None => TerminalCommandResponse::Text { content: format!("Analysis not available for \"{ticker}\"."), + portfolio: None, }, } } @@ -292,6 +299,7 @@ impl TerminalCommandService { match self.portfolio_service.stats().await { Ok(stats) => TerminalCommandResponse::Text { content: format_portfolio_stats(&stats), + portfolio: None, }, Err(error) => portfolio_error_response(error), } @@ -303,12 +311,14 @@ impl TerminalCommandService { let Some(limit) = parse_positive_usize(limit) else { return TerminalCommandResponse::Text { content: "Usage: /portfolio history [limit]".to_string(), + portfolio: None, }; }; self.portfolio_history_response(limit).await } _ => TerminalCommandResponse::Text { content: "Usage: /portfolio [stats|history [limit]]".to_string(), + portfolio: None, }, } } @@ -317,9 +327,11 @@ impl TerminalCommandService { match self.portfolio_service.history(limit).await { Ok(history) if history.is_empty() => TerminalCommandResponse::Text { content: "Portfolio history is empty.".to_string(), + portfolio: None, }, Ok(history) => TerminalCommandResponse::Text { content: format_portfolio_history(&history), + portfolio: None, }, Err(error) => portfolio_error_response(error), } @@ -329,6 +341,7 @@ impl TerminalCommandService { let Some((symbol, quantity, price_override)) = parse_trade_args("/buy", args) else { return TerminalCommandResponse::Text { content: "Usage: /buy [ticker] [quantity] [price?]".to_string(), + portfolio: None, }; }; @@ -337,9 +350,10 @@ impl TerminalCommandService { .buy(symbol.as_str(), quantity, price_override) .await { - Ok(confirmation) => TerminalCommandResponse::Text { - content: format_buy_confirmation(&confirmation), - }, + Ok(confirmation) => { + self.text_response_with_portfolio(format_buy_confirmation(&confirmation)) + .await + } Err(error) => portfolio_error_response(error), } } @@ -348,6 +362,7 @@ impl TerminalCommandService { let Some((symbol, quantity, price_override)) = parse_trade_args("/sell", args) else { return TerminalCommandResponse::Text { content: "Usage: /sell [ticker] [quantity] [price?]".to_string(), + portfolio: None, }; }; @@ -356,9 +371,10 @@ impl TerminalCommandService { .sell(symbol.as_str(), quantity, price_override) .await { - Ok(confirmation) => TerminalCommandResponse::Text { - content: format_sell_confirmation(&confirmation), - }, + Ok(confirmation) => { + self.text_response_with_portfolio(format_sell_confirmation(&confirmation)) + .await + } Err(error) => portfolio_error_response(error), } } @@ -367,6 +383,7 @@ impl TerminalCommandService { let Some((subcommand, amount)) = parse_cash_args(args) else { return TerminalCommandResponse::Text { content: "Usage: /cash [deposit|withdraw] [amount]".to_string(), + portfolio: None, }; }; @@ -377,13 +394,20 @@ impl TerminalCommandService { }; match result { - Ok(confirmation) => TerminalCommandResponse::Text { - content: format_cash_confirmation(&confirmation), - }, + Ok(confirmation) => { + self.text_response_with_portfolio(format_cash_confirmation(&confirmation)) + .await + } Err(error) => portfolio_error_response(error), } } + async fn text_response_with_portfolio(&self, content: String) -> TerminalCommandResponse { + let portfolio = self.portfolio_service.portfolio().await.ok(); + + TerminalCommandResponse::Text { content, portfolio } + } + async fn financials( &self, ticker: Option<&String>, @@ -437,11 +461,13 @@ impl TerminalCommandService { Some(_) => { return TerminalCommandResponse::Text { content: "Usage: /dvd [ticker]".to_string(), + portfolio: None, } } None => { return TerminalCommandResponse::Text { content: "Usage: /dvd [ticker]".to_string(), + portfolio: None, } } }; @@ -617,6 +643,7 @@ fn parse_symbol_and_frequency( else { return Err(TerminalCommandResponse::Text { content: format!("Usage: {command} [ticker] [annual|quarterly]"), + portfolio: None, }); }; @@ -627,6 +654,7 @@ fn parse_symbol_and_frequency( Some(_) => { return Err(TerminalCommandResponse::Text { content: format!("Usage: {command} [ticker] [annual|quarterly]"), + portfolio: None, }) } }; @@ -700,12 +728,14 @@ fn help_text() -> &'static str { fn help_response() -> TerminalCommandResponse { TerminalCommandResponse::Text { content: help_text().to_string(), + portfolio: None, } } fn portfolio_error_response(error: PortfolioCommandError) -> TerminalCommandResponse { TerminalCommandResponse::Text { content: error.to_string(), + portfolio: None, } } @@ -1382,7 +1412,7 @@ mod tests { let response = execute(&service, "/wat"); match response { - TerminalCommandResponse::Text { content } => { + TerminalCommandResponse::Text { content, .. } => { assert!(content.contains("Unknown command")); } other => panic!("expected text response, got {other:?}"), @@ -1402,7 +1432,7 @@ mod tests { let response = execute(&service, "/analyze"); match response { - TerminalCommandResponse::Text { content } => { + TerminalCommandResponse::Text { content, .. } => { assert_eq!(content, "Usage: /analyze [ticker]"); } other => panic!("expected text response, got {other:?}"), @@ -1451,9 +1481,13 @@ mod tests { let response = execute(&service, "/buy AAPL 2 178.25"); match response { - TerminalCommandResponse::Text { content } => { + TerminalCommandResponse::Text { + content, + portfolio: Some(portfolio), + } => { assert!(content.contains("Bought 2 AAPL @ $178.25")); assert!(content.contains("Cash balance: $643.50")); + assert_eq!(portfolio.holdings_count, Some(0)); } other => panic!("expected text response, got {other:?}"), } @@ -1480,8 +1514,12 @@ mod tests { let response = execute(&service, "/buy AAPL 2"); match response { - TerminalCommandResponse::Text { content } => { + TerminalCommandResponse::Text { + content, + portfolio: Some(portfolio), + } => { assert!(content.contains("Bought 2 AAPL @ $100.00")); + assert_eq!(portfolio.cash_balance, Some(0.0)); } other => panic!("expected text response, got {other:?}"), } @@ -1505,7 +1543,7 @@ mod tests { let response = execute(&service, "/buy AAPL 2"); match response { - TerminalCommandResponse::Text { content } => { + TerminalCommandResponse::Text { content, .. } => { assert!(content.contains("quote unavailable for AAPL")); } other => panic!("expected text response, got {other:?}"), @@ -1533,9 +1571,13 @@ mod tests { let response = execute(&service, "/sell AAPL 1.5 110"); match response { - TerminalCommandResponse::Text { content } => { + TerminalCommandResponse::Text { + content, + portfolio: Some(portfolio), + } => { assert!(content.contains("Sold 1.5 AAPL @ $110.00")); assert!(content.contains("Realized P/L: $15.00")); + assert_eq!(portfolio.total_value, 0.0); } other => panic!("expected text response, got {other:?}"), } @@ -1638,7 +1680,7 @@ mod tests { let response = execute(&service, "/portfolio history 2"); match response { - TerminalCommandResponse::Text { content } => { + TerminalCommandResponse::Text { content, .. } => { assert!(content.contains("2026-01-03T10:00:00Z")); assert!(content.contains("2026-01-02T10:00:00Z")); assert!(!content.contains("2026-01-01T10:00:00Z")); @@ -1654,7 +1696,7 @@ mod tests { let response = execute(&service, "/buy AAPL nope"); match response { - TerminalCommandResponse::Text { content } => { + TerminalCommandResponse::Text { content, .. } => { assert_eq!(content, "Usage: /buy [ticker] [quantity] [price?]"); } other => panic!("expected text response, got {other:?}"), diff --git a/MosaicIQ/src-tauri/src/terminal/types.rs b/MosaicIQ/src-tauri/src/terminal/types.rs index 5d747ca..736ebb9 100644 --- a/MosaicIQ/src-tauri/src/terminal/types.rs +++ b/MosaicIQ/src-tauri/src/terminal/types.rs @@ -36,7 +36,11 @@ pub struct ChatCommandRequest { #[serde(tag = "kind", rename_all = "camelCase")] pub enum TerminalCommandResponse { /// Plain text response rendered directly in the terminal. - Text { content: String }, + Text { + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + portfolio: Option, + }, /// Structured payload rendered by an existing terminal panel. Panel { panel: PanelPayload }, } diff --git a/MosaicIQ/src/App.tsx b/MosaicIQ/src/App.tsx index 76dc735..fae05fb 100644 --- a/MosaicIQ/src/App.tsx +++ b/MosaicIQ/src/App.tsx @@ -59,10 +59,6 @@ function App() { return tabs.activeWorkspace?.history || []; }; - const getActiveCommandHistory = () => { - return commandHistoryRefs.current[tabs.activeWorkspaceId] || []; - }; - const pushCommandHistory = useCallback((workspaceId: string, command: string) => { if (!commandHistoryRefs.current[workspaceId]) { commandHistoryRefs.current[workspaceId] = []; @@ -263,43 +259,13 @@ function App() { // Command history navigation // Accesses from END of array (most recent commands first) // Index -1 = current input, 0 = most recent, 1 = second most recent, etc. - const getPreviousCommand = useCallback(() => { - const history = getActiveCommandHistory(); - const currentIndex = commandIndexRefs.current[tabs.activeWorkspaceId] || -1; - - // Up arrow: go BACK in history (toward older commands) - if (currentIndex < history.length - 1) { - const newIndex = currentIndex + 1; - commandIndexRefs.current[tabs.activeWorkspaceId] = newIndex; - return history[history.length - 1 - newIndex]; - } - return null; - }, [tabs.activeWorkspaceId]); - - const getNextCommand = useCallback(() => { - const history = getActiveCommandHistory(); - const currentIndex = commandIndexRefs.current[tabs.activeWorkspaceId] || -1; - - // Down arrow: go FORWARD in history (toward newer commands) - if (currentIndex > 0) { - const newIndex = currentIndex - 1; - commandIndexRefs.current[tabs.activeWorkspaceId] = newIndex; - return history[history.length - 1 - newIndex]; - } else if (currentIndex === 0) { - // At most recent command, return to current input - commandIndexRefs.current[tabs.activeWorkspaceId] = -1; - return ''; - } - return null; - }, [tabs.activeWorkspaceId]); - const resetCommandIndex = useCallback(() => { commandIndexRefs.current[tabs.activeWorkspaceId] = -1; }, [tabs.activeWorkspaceId]); const outputRef = useRef(null); const activePortfolioWorkflow = portfolioWorkflow.readWorkflow(tabs.activeWorkspaceId); - const portfolioSnapshot: Portfolio | null = activePortfolioWorkflow.portfolioSnapshot; + const portfolioSnapshot: Portfolio | null = portfolioWorkflow.portfolioSnapshot; useEffect(() => { let active = true; @@ -469,8 +435,6 @@ function App() { onStartPortfolioAction={handleStartPortfolioAction} onUpdatePortfolioDraft={handleUpdatePortfolioDraft} onClearPortfolioAction={handleClearPortfolioAction} - getPreviousCommand={getPreviousCommand} - getNextCommand={getNextCommand} resetCommandIndex={resetCommandIndex} portfolioWorkflow={activePortfolioWorkflow} /> diff --git a/MosaicIQ/src/components/Panels/PortfolioPanel.tsx b/MosaicIQ/src/components/Panels/PortfolioPanel.tsx index f7be0df..16acc87 100644 --- a/MosaicIQ/src/components/Panels/PortfolioPanel.tsx +++ b/MosaicIQ/src/components/Panels/PortfolioPanel.tsx @@ -1,7 +1,8 @@ import React from 'react'; +import { Package } from 'lucide-react'; import { Portfolio } from '../../types/financial'; import { PortfolioAction, PortfolioActionSeed } from '../../types/terminal'; -import { MetricGrid } from '../ui'; +import { MetricGrid, ActionButton, ButtonGroup, DataSection } from '../ui'; interface PortfolioPanelProps { portfolio: Portfolio; @@ -24,9 +25,6 @@ const formatQuantity = (value: number) => { return rendered.replace(/\.?0+$/, ''); }; -const actionButtonClass = - 'border border-[#2a2a2a] bg-[#161616] px-3 py-1.5 text-xs font-mono text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]'; - export const PortfolioPanel: React.FC = ({ portfolio, onRunCommand, @@ -85,57 +83,29 @@ export const PortfolioPanel: React.FC = ({ -
- - - - - - +
+ + onStartAction('buy')}>Buy + onStartAction('sell')}>Sell + onStartAction('deposit')}>Deposit + onStartAction('withdraw')}>Withdraw + + + onRunCommand('/portfolio stats')} size="sm"> + Stats + + onRunCommand('/portfolio history')} size="sm"> + History + +
-
+ -
+ -
+

Holdings ({portfolio.holdings.length}) @@ -148,34 +118,26 @@ export const PortfolioPanel: React.FC = ({ Reload overview

- {portfolio.holdings.length === 0 ? ( -
-
No holdings yet.
-
- Deposit cash first, then open a buy workflow to record your first position. +
+
+
-
- - -
+ + onStartAction('buy')}>Buy first company +
) : (
- + - {portfolio.holdings.map((holding) => { + {portfolio.holdings.map((holding, index) => { const gainPositive = holding.gainLoss >= 0; return ( - + @@ -266,7 +228,7 @@ export const PortfolioPanel: React.FC = ({
Symbol @@ -201,15 +163,19 @@ export const PortfolioPanel: React.FC = ({
- - +
)} -
+
); }; diff --git a/MosaicIQ/src/components/Sidebar/PortfolioSummary.tsx b/MosaicIQ/src/components/Sidebar/PortfolioSummary.tsx index d22c292..a40f56c 100644 --- a/MosaicIQ/src/components/Sidebar/PortfolioSummary.tsx +++ b/MosaicIQ/src/components/Sidebar/PortfolioSummary.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; -import { ChevronDown, ChevronUp, TrendingDown, TrendingUp } from 'lucide-react'; +import { ChevronDown, TrendingDown, TrendingUp, Wallet } from 'lucide-react'; import { Portfolio } from '../../types/financial'; +import { ActionButton } from '../ui'; interface PortfolioSummaryProps { portfolio: Portfolio | null; @@ -23,20 +24,21 @@ export const PortfolioSummary: React.FC = ({ if (!portfolio) { return ( -
-
- Portfolio +
+
+
+ +
+
+ Portfolio +
-
- Load your latest portfolio snapshot into the sidebar. +
+ Load your latest portfolio snapshot
- +
); } @@ -52,7 +54,6 @@ export const PortfolioSummary: React.FC = ({
@@ -84,11 +85,11 @@ export const PortfolioSummary: React.FC = ({
{hasMoreHoldings ? ( - {isExpanded ? ( - - ) : ( - - )} + ) : null}
@@ -99,7 +100,7 @@ export const PortfolioSummary: React.FC = ({ {formatCurrency(portfolio.totalValue)} @@ -109,15 +110,25 @@ export const PortfolioSummary: React.FC = ({
-
- {visibleHoldings.map((holding) => { +
+ {visibleHoldings.map((holding, index) => { const holdingPositive = holding.gainLoss >= 0; return ( @@ -153,7 +164,7 @@ export const PortfolioSummary: React.FC = ({ diff --git a/MosaicIQ/src/components/Terminal/CommandInput.tsx b/MosaicIQ/src/components/Terminal/CommandInput.tsx index 34fb13c..6a7c635 100644 --- a/MosaicIQ/src/components/Terminal/CommandInput.tsx +++ b/MosaicIQ/src/components/Terminal/CommandInput.tsx @@ -6,11 +6,20 @@ import React, { useRef, useState, } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; import { - CommandSuggestion, PortfolioAction, PortfolioActionDraft, } from '../../types/terminal'; +import { + getTerminalCommandSpec, + getTerminalCommandSpecForAction, + TERMINAL_COMMAND_SUGGESTIONS, +} from '../../lib/terminalCommandSpecs'; +import { + buildCommandSignature, + resolveTerminalShadow, +} from '../../lib/terminalShadow'; interface CommandInputProps { onSubmit: (command: string) => void; @@ -18,8 +27,6 @@ interface CommandInputProps { onUpdatePortfolioDraft: (patch: Partial) => void; onClearPortfolioAction: () => void; isProcessing: boolean; - getPreviousCommand: () => string | null; - getNextCommand: () => string | null; resetCommandIndex: () => void; portfolioMode: boolean; activePortfolioAction: PortfolioAction | null; @@ -32,24 +39,6 @@ export interface CommandInputHandle { focusWithText: (text: string) => void; } -const SUGGESTIONS: CommandSuggestion[] = [ - { command: '/search', description: 'Search live security data', category: 'search' }, - { command: '/portfolio', description: 'Show portfolio overview', category: 'portfolio' }, - { command: '/portfolio stats', description: 'Show portfolio statistics', category: 'portfolio' }, - { command: '/portfolio history', description: 'Show recent portfolio transactions', category: 'portfolio' }, - { command: '/buy', description: 'Create a buy order', category: 'portfolio' }, - { command: '/sell', description: 'Create a sell order', category: 'portfolio' }, - { command: '/cash deposit', description: 'Add cash to the portfolio', category: 'cash' }, - { command: '/cash withdraw', description: 'Withdraw cash from the portfolio', category: 'cash' }, - { command: '/fa', description: 'SEC financial statements', category: 'financials' }, - { command: '/cf', description: 'SEC cash flow summary', category: 'cashflow' }, - { command: '/dvd', description: 'SEC dividends history', category: 'dividends' }, - { command: '/em', description: 'SEC earnings history', category: 'earnings' }, - { command: '/news', description: 'Market news', category: 'news' }, - { command: '/analyze', description: 'AI analysis', category: 'analysis' }, - { command: '/help', description: 'List commands', category: 'system' }, -]; - const ACTION_LABELS: Record<'buy' | 'sell' | 'deposit' | 'withdraw', string> = { buy: 'Buy', sell: 'Sell', @@ -65,64 +54,63 @@ const isActionComposer = ( const suggestionToAction = ( command: string, ): 'buy' | 'sell' | 'deposit' | 'withdraw' | null => { - switch (command) { - case '/buy': - return 'buy'; - case '/sell': - return 'sell'; - case '/cash deposit': - return 'deposit'; - case '/cash withdraw': - return 'withdraw'; - default: - return null; - } + return getTerminalCommandSpec(command)?.portfolioAction ?? null; }; const buildGeneratedCommand = ( action: 'buy' | 'sell' | 'deposit' | 'withdraw', draft: PortfolioActionDraft, ) => { + const spec = getTerminalCommandSpecForAction(action); if (action === 'buy' || action === 'sell') { const symbol = draft.symbol.trim().toUpperCase(); const quantity = draft.quantity.trim(); const price = draft.price.trim(); + const signature = spec + ? buildCommandSignature(spec, [symbol, quantity, price]) + : `/${action} [price]`; if (!symbol) { - return { command: `/${action} [ticker] [quantity] [price?]`, error: 'Ticker symbol is required.' }; + return { command: signature, error: 'Ticker symbol is required.' }; } if (!quantity) { - return { command: `/${action} ${symbol} [quantity] [price?]`, error: 'Quantity is required.' }; + return { command: signature, error: 'Quantity is required.' }; } if (Number.isNaN(Number(quantity)) || Number(quantity) <= 0) { - return { command: `/${action} ${symbol} ${quantity}`, error: 'Quantity must be greater than zero.' }; + return { command: signature, error: 'Quantity must be greater than zero.' }; } if (price && (Number.isNaN(Number(price)) || Number(price) <= 0)) { - return { command: `/${action} ${symbol} ${quantity} ${price}`, error: 'Price must be greater than zero when provided.' }; + return { + command: signature, + error: 'Price must be greater than zero when provided.', + }; } return { - command: price ? `/${action} ${symbol} ${quantity} ${price}` : `/${action} ${symbol} ${quantity}`, + command: signature, error: null, }; } const amount = draft.amount.trim(); + const signature = spec + ? buildCommandSignature(spec, [amount]) + : `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} `; if (!amount) { return { - command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} [amount]`, + command: signature, error: 'Amount is required.', }; } if (Number.isNaN(Number(amount)) || Number(amount) <= 0) { return { - command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} ${amount}`, + command: signature, error: 'Amount must be greater than zero.', }; } return { - command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} ${amount}`, + command: signature, error: null, }; }; @@ -135,8 +123,6 @@ export const CommandInput = React.forwardRef { const [input, setInput] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); + const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0); + const [shadowCollapsed, setShadowCollapsed] = useState(false); const inputRef = useRef(null); const actionPrimaryFieldRef = useRef(null); @@ -159,6 +147,10 @@ export const CommandInput = React.forwardRef (actionComposerActive ? null : resolveTerminalShadow(input)), + [actionComposerActive, input], + ); useEffect(() => { if (!isProcessing) { @@ -170,12 +162,32 @@ export const CommandInput = React.forwardRef + TERMINAL_COMMAND_SUGGESTIONS.filter( + (suggestion) => !input || suggestion.command.startsWith(input), + ), + [input], + ); + + useEffect(() => { + if (!showSuggestions || suggestionMatches.length === 0) { + setActiveSuggestionIndex(0); + return; + } + + setActiveSuggestionIndex((current) => + Math.min(current, suggestionMatches.length - 1), + ); + }, [showSuggestions, suggestionMatches]); + useImperativeHandle( ref, () => ({ focusWithText: (text: string) => { setInput(text); setShowSuggestions(text.startsWith('/')); + setActiveSuggestionIndex(0); resetCommandIndex(); inputRef.current?.focus(); }, @@ -189,6 +201,7 @@ export const CommandInput = React.forwardRef { + if (!showSuggestions || suggestionMatches.length === 0) { + return; + } + + setActiveSuggestionIndex((current) => { + const next = current + direction; + if (next < 0) { + return suggestionMatches.length - 1; + } + if (next >= suggestionMatches.length) { + return 0; + } + return next; + }); + }; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); + if (showSuggestions && suggestionMatches[activeSuggestionIndex]) { + activateSuggestion(suggestionMatches[activeSuggestionIndex].command); + return; + } handleSubmit(); } else if (event.key === 'ArrowUp') { event.preventDefault(); - const previous = getPreviousCommand(); - if (previous !== null) { - setInput(previous); - } + moveSuggestionSelection(-1); } else if (event.key === 'ArrowDown') { event.preventDefault(); - const next = getNextCommand(); - if (next !== null) { - setInput(next); - } + moveSuggestionSelection(1); } else if (event.key === 'Tab') { event.preventDefault(); - if (input.startsWith('/')) { - const match = SUGGESTIONS.find((suggestion) => suggestion.command.startsWith(input)); + if (showSuggestions) { + const match = suggestionMatches[activeSuggestionIndex] ?? suggestionMatches[0]; if (match) { activateSuggestion(match.command); } } } else if (event.key === 'Escape') { setShowSuggestions(false); + setActiveSuggestionIndex(0); } }; const handleInputChange = (event: React.ChangeEvent) => { setInput(event.target.value); setShowSuggestions(event.target.value.startsWith('/')); + setActiveSuggestionIndex(0); }; - const suggestionMatches = SUGGESTIONS.filter( - (suggestion) => !input || suggestion.command.startsWith(input), - ); - const helperText = actionComposerActive ? `Running ${actionMeta?.command ?? lastPortfolioCommand ?? '/portfolio'} will keep raw terminal output in view.` + : shadowState + ? shadowState.helperText : portfolioMode ? 'Portfolio tools stay active while you move between overview, history, stats, and trade actions.' : 'Use /portfolio to load interactive portfolio tools.'; @@ -373,12 +404,120 @@ export const CommandInput = React.forwardRef ) : null} + {!actionComposerActive && shadowState ? ( +
+
+ + Command Shadow + +
+ + {shadowState.readyToRun && !shadowState.hasExtraInput + ? 'Ready' + : shadowState.mode === 'predictive' + ? 'Autocomplete' + : 'Fill Parameters'} + + +
+
+ + {!shadowCollapsed && ( + <> +
+ + {shadowState.signature} + +
+ +
+ {shadowState.arguments.length > 0 ? ( +
+ {shadowState.arguments.map((argument, index) => ( +
+ {index + 1}. {argument.spec.label} + {argument.spec.required && ( + * + )} +
+ ))} +
+ ) : ( + + No parameters required + + )} +
+ + {shadowState.spec.examples?.length && shadowState.spec.examples.length > 0 && ( +
+
+ {shadowState.spec.examples.slice(0, 3).map((example) => ( + + ))} + {shadowState.spec.examples.length > 3 && ( + + +{shadowState.spec.examples.length - 3} more + + )} +
+
+ )} + + )} +
+ ) : null} + {showSuggestions && suggestionMatches.length > 0 ? (
{suggestionMatches.map((suggestion) => ( + ); +}; + +interface ButtonGroupProps { + children: React.ReactNode; + className?: string; +} + +export const ButtonGroup: React.FC = ({ children, className = '' }) => { + return
{children}
; +}; diff --git a/MosaicIQ/src/components/ui/index.ts b/MosaicIQ/src/components/ui/index.ts index 19d2b13..1f3e439 100644 --- a/MosaicIQ/src/components/ui/index.ts +++ b/MosaicIQ/src/components/ui/index.ts @@ -18,6 +18,9 @@ export { ExpandableText } from './ExpandableText'; export { CompanyIdentity } from './CompanyIdentity'; export { PriceDisplay } from './PriceDisplay'; +// Action components +export { ActionButton, ButtonGroup } from './ActionButton'; + // Financial components export { StatementTableMinimal, diff --git a/MosaicIQ/src/hooks/usePortfolioWorkflow.ts b/MosaicIQ/src/hooks/usePortfolioWorkflow.ts index 0fea91c..790c493 100644 --- a/MosaicIQ/src/hooks/usePortfolioWorkflow.ts +++ b/MosaicIQ/src/hooks/usePortfolioWorkflow.ts @@ -77,6 +77,7 @@ export const isPortfolioCommand = (command: string): boolean => export const usePortfolioWorkflow = () => { const [workflows, setWorkflows] = useState>({}); + const [portfolioSnapshot, setPortfolioSnapshot] = useState(null); const readWorkflow = useCallback( (workspaceId: string): PortfolioWorkflowState => @@ -130,13 +131,24 @@ export const usePortfolioWorkflow = () => { return; } + const latestPortfolioSnapshot = + response.kind === 'panel' && response.panel.type === 'portfolio' + ? response.panel.data + : response.kind === 'text' + ? response.portfolio ?? null + : null; + + if (latestPortfolioSnapshot) { + setPortfolioSnapshot(latestPortfolioSnapshot); + } + updateWorkflow(workspaceId, (current) => { if (response.kind === 'panel' && response.panel.type === 'portfolio') { return { ...current, isPortfolioMode: true, activePortfolioAction: 'overview', - portfolioSnapshot: response.panel.data, + portfolioSnapshot: latestPortfolioSnapshot, portfolioSnapshotStatus: 'ready', lastPortfolioCommand: command, }; @@ -152,6 +164,10 @@ export const usePortfolioWorkflow = () => { ...current, isPortfolioMode: true, activePortfolioAction: completedTradeAction ? null : action, + portfolioSnapshot: latestPortfolioSnapshot ?? current.portfolioSnapshot, + portfolioSnapshotStatus: latestPortfolioSnapshot + ? 'ready' + : current.portfolioSnapshotStatus, lastPortfolioCommand: command, }; }); @@ -235,6 +251,7 @@ export const usePortfolioWorkflow = () => { ); return { + portfolioSnapshot, readWorkflow, noteCommandStart, noteCommandResponse, diff --git a/MosaicIQ/src/lib/terminalCommandSpecs.ts b/MosaicIQ/src/lib/terminalCommandSpecs.ts new file mode 100644 index 0000000..d077b09 --- /dev/null +++ b/MosaicIQ/src/lib/terminalCommandSpecs.ts @@ -0,0 +1,305 @@ +import { CommandSuggestion, PortfolioAction } from '../types/terminal'; + +export interface TerminalCommandArgumentSpec { + key: string; + label: string; + description: string; + placeholder: string; + required: boolean; + options?: string[]; + consumeRest?: boolean; +} + +export interface TerminalCommandSpec extends CommandSuggestion { + arguments: TerminalCommandArgumentSpec[]; + examples?: string[]; + portfolioAction?: Extract< + PortfolioAction, + 'buy' | 'sell' | 'deposit' | 'withdraw' + >; +} + +export const TERMINAL_COMMAND_SPECS: TerminalCommandSpec[] = [ + { + command: '/search', + description: 'Search live security data', + category: 'search', + arguments: [ + { + key: 'query', + label: 'Query', + description: 'Ticker or company name to search for.', + placeholder: 'ticker-or-company', + required: true, + consumeRest: true, + }, + ], + examples: ['/search AAPL', '/search Apple'], + }, + { + command: '/portfolio', + description: 'Show portfolio overview', + category: 'portfolio', + arguments: [], + examples: ['/portfolio'], + }, + { + command: '/portfolio stats', + description: 'Show portfolio statistics', + category: 'portfolio', + arguments: [], + examples: ['/portfolio stats'], + }, + { + command: '/portfolio history', + description: 'Show recent portfolio transactions', + category: 'portfolio', + arguments: [ + { + key: 'limit', + label: 'Limit', + description: 'Optional number of transactions to return.', + placeholder: 'limit', + required: false, + }, + ], + examples: ['/portfolio history', '/portfolio history 25'], + }, + { + command: '/buy', + description: 'Create a buy order', + category: 'portfolio', + portfolioAction: 'buy', + arguments: [ + { + key: 'ticker', + label: 'Ticker', + description: 'Security symbol to purchase.', + placeholder: 'ticker', + required: true, + }, + { + key: 'quantity', + label: 'Quantity', + description: 'Number of shares to buy.', + placeholder: 'quantity', + required: true, + }, + { + key: 'price', + label: 'Price', + description: 'Optional execution price override.', + placeholder: 'price', + required: false, + }, + ], + examples: ['/buy NVDA 10', '/buy NVDA 10 875.50'], + }, + { + command: '/sell', + description: 'Create a sell order', + category: 'portfolio', + portfolioAction: 'sell', + arguments: [ + { + key: 'ticker', + label: 'Ticker', + description: 'Security symbol to sell.', + placeholder: 'ticker', + required: true, + }, + { + key: 'quantity', + label: 'Quantity', + description: 'Number of shares to sell.', + placeholder: 'quantity', + required: true, + }, + { + key: 'price', + label: 'Price', + description: 'Optional execution price override.', + placeholder: 'price', + required: false, + }, + ], + examples: ['/sell MSFT 5', '/sell MSFT 5 440'], + }, + { + command: '/cash deposit', + description: 'Add cash to the portfolio', + category: 'cash', + portfolioAction: 'deposit', + arguments: [ + { + key: 'amount', + label: 'Amount', + description: 'Cash amount to deposit.', + placeholder: 'amount', + required: true, + }, + ], + examples: ['/cash deposit 1000'], + }, + { + command: '/cash withdraw', + description: 'Withdraw cash from the portfolio', + category: 'cash', + portfolioAction: 'withdraw', + arguments: [ + { + key: 'amount', + label: 'Amount', + description: 'Cash amount to withdraw.', + placeholder: 'amount', + required: true, + }, + ], + examples: ['/cash withdraw 500'], + }, + { + command: '/fa', + description: 'SEC financial statements', + category: 'financials', + arguments: [ + { + key: 'ticker', + label: 'Ticker', + description: 'Security symbol to load filings for.', + placeholder: 'ticker', + required: true, + }, + { + key: 'period', + label: 'Period', + description: 'Reporting cadence.', + placeholder: 'annual-or-quarterly', + required: false, + options: ['annual', 'quarterly'], + }, + ], + examples: ['/fa AAPL', '/fa AAPL quarterly'], + }, + { + command: '/cf', + description: 'SEC cash flow summary', + category: 'cashflow', + arguments: [ + { + key: 'ticker', + label: 'Ticker', + description: 'Security symbol to load filings for.', + placeholder: 'ticker', + required: true, + }, + { + key: 'period', + label: 'Period', + description: 'Reporting cadence.', + placeholder: 'annual-or-quarterly', + required: false, + options: ['annual', 'quarterly'], + }, + ], + examples: ['/cf KO', '/cf KO annual'], + }, + { + command: '/dvd', + description: 'SEC dividends history', + category: 'dividends', + arguments: [ + { + key: 'ticker', + label: 'Ticker', + description: 'Security symbol to load dividends for.', + placeholder: 'ticker', + required: true, + }, + ], + examples: ['/dvd KO'], + }, + { + command: '/em', + description: 'SEC earnings history', + category: 'earnings', + arguments: [ + { + key: 'ticker', + label: 'Ticker', + description: 'Security symbol to load earnings for.', + placeholder: 'ticker', + required: true, + }, + { + key: 'period', + label: 'Period', + description: 'Reporting cadence.', + placeholder: 'annual-or-quarterly', + required: false, + options: ['annual', 'quarterly'], + }, + ], + examples: ['/em SAP', '/em SAP quarterly'], + }, + { + command: '/news', + description: 'Market news', + category: 'news', + arguments: [ + { + key: 'ticker', + label: 'Ticker', + description: 'Optional symbol to filter news.', + placeholder: 'ticker', + required: false, + }, + ], + examples: ['/news', '/news NVDA'], + }, + { + command: '/analyze', + description: 'AI analysis', + category: 'analysis', + arguments: [ + { + key: 'ticker', + label: 'Ticker', + description: 'Security symbol to analyze.', + placeholder: 'ticker', + required: true, + }, + ], + examples: ['/analyze AAPL'], + }, + { + command: '/help', + description: 'List commands', + category: 'system', + arguments: [], + examples: ['/help'], + }, + { + command: '/clear', + description: 'Clear the current terminal workspace', + category: 'system', + arguments: [], + examples: ['/clear'], + }, +]; + +export const TERMINAL_COMMAND_SUGGESTIONS: CommandSuggestion[] = + TERMINAL_COMMAND_SPECS.map(({ command, description, category }) => ({ + command, + description, + category, + })); + +export const getTerminalCommandSpec = ( + command: string, +): TerminalCommandSpec | undefined => + TERMINAL_COMMAND_SPECS.find((spec) => spec.command === command); + +export const getTerminalCommandSpecForAction = ( + action: Extract, +): TerminalCommandSpec | undefined => + TERMINAL_COMMAND_SPECS.find((spec) => spec.portfolioAction === action); diff --git a/MosaicIQ/src/lib/terminalShadow.ts b/MosaicIQ/src/lib/terminalShadow.ts new file mode 100644 index 0000000..741c78a --- /dev/null +++ b/MosaicIQ/src/lib/terminalShadow.ts @@ -0,0 +1,214 @@ +import { + TERMINAL_COMMAND_SPECS, + TerminalCommandArgumentSpec, + TerminalCommandSpec, +} from './terminalCommandSpecs'; + +export interface TerminalShadowArgumentState { + spec: TerminalCommandArgumentSpec; + value: string; + isFilled: boolean; + isActive: boolean; +} + +export interface TerminalShadowState { + spec: TerminalCommandSpec; + mode: 'exact' | 'predictive'; + arguments: TerminalShadowArgumentState[]; + currentArgumentIndex: number; + helperText: string; + signature: string; + readyToRun: boolean; + hasExtraInput: boolean; +} + +const SPECS_BY_LENGTH = [...TERMINAL_COMMAND_SPECS].sort( + (left, right) => right.command.length - left.command.length, +); + +const getArgumentToken = ( + argument: TerminalCommandArgumentSpec, + value?: string, +): string => { + const normalizedValue = value?.trim(); + if (normalizedValue) { + return normalizedValue; + } + + return argument.required + ? `<${argument.placeholder}>` + : `[${argument.placeholder}]`; +}; + +const parseProvidedValues = ( + spec: TerminalCommandSpec, + input: string, +): { values: string[]; hasExtraInput: boolean } => { + const remainder = input.slice(spec.command.length); + const trimmedRemainder = remainder.trim(); + + if (spec.arguments.length === 0) { + return { + values: [], + hasExtraInput: trimmedRemainder.length > 0, + }; + } + + const restArgumentIndex = spec.arguments.findIndex( + (argument) => argument.consumeRest, + ); + + if (restArgumentIndex === 0) { + return { + values: trimmedRemainder ? [trimmedRemainder] : [''], + hasExtraInput: false, + }; + } + + const tokens = trimmedRemainder ? trimmedRemainder.split(/\s+/) : []; + const values = spec.arguments.map((_, index) => tokens[index] ?? ''); + + return { + values, + hasExtraInput: tokens.length > spec.arguments.length, + }; +}; + +export const buildCommandSignature = ( + spec: TerminalCommandSpec, + values: string[] = [], +): string => { + if (spec.arguments.length === 0) { + return spec.command; + } + + const tokens = spec.arguments.map((argument, index) => + getArgumentToken(argument, values[index]), + ); + + return `${spec.command} ${tokens.join(' ')}`; +}; + +export const resolveTerminalShadow = ( + rawInput: string, +): TerminalShadowState | null => { + const input = rawInput.trimStart(); + if (!input.startsWith('/')) { + return null; + } + + const predictiveInput = input.trimEnd(); + const predictiveSpec = SPECS_BY_LENGTH.find( + (spec) => + predictiveInput.length > 0 && + spec.command !== predictiveInput && + spec.command.startsWith(predictiveInput), + ); + + const exactSpec = SPECS_BY_LENGTH.find( + (spec) => input === spec.command || input.startsWith(`${spec.command} `), + ); + + if (!exactSpec && !predictiveSpec) { + return null; + } + + if ( + predictiveSpec && + exactSpec && + predictiveInput !== exactSpec.command && + predictiveSpec.command.startsWith(predictiveInput) + ) { + return { + spec: predictiveSpec, + mode: 'predictive', + arguments: predictiveSpec.arguments.map((argument, index) => ({ + spec: argument, + value: '', + isFilled: false, + isActive: index === 0, + })), + currentArgumentIndex: predictiveSpec.arguments.length > 0 ? 0 : -1, + helperText: + predictiveSpec.arguments.length > 0 + ? `Press Tab to complete ${predictiveSpec.command} and start with ${predictiveSpec.arguments[0].label.toLowerCase()}.` + : `Press Tab to complete ${predictiveSpec.command}.`, + signature: buildCommandSignature(predictiveSpec), + readyToRun: predictiveSpec.arguments.length === 0, + hasExtraInput: false, + }; + } + + const spec = exactSpec ?? predictiveSpec; + if (!spec) { + return null; + } + + if (!exactSpec) { + return { + spec, + mode: 'predictive', + arguments: spec.arguments.map((argument, index) => ({ + spec: argument, + value: '', + isFilled: false, + isActive: index === 0, + })), + currentArgumentIndex: spec.arguments.length > 0 ? 0 : -1, + helperText: + spec.arguments.length > 0 + ? `Press Tab to complete ${spec.command} and start with ${spec.arguments[0].label.toLowerCase()}.` + : `Press Tab to complete ${spec.command}.`, + signature: buildCommandSignature(spec), + readyToRun: spec.arguments.length === 0, + hasExtraInput: false, + }; + } + + const { values, hasExtraInput } = parseProvidedValues(spec, input); + const firstMissingRequiredIndex = spec.arguments.findIndex( + (argument, index) => argument.required && !values[index]?.trim(), + ); + const nextOptionalIndex = spec.arguments.findIndex( + (_, index) => !values[index]?.trim(), + ); + + let currentArgumentIndex = -1; + if (firstMissingRequiredIndex >= 0) { + currentArgumentIndex = firstMissingRequiredIndex; + } else if (nextOptionalIndex >= 0) { + currentArgumentIndex = nextOptionalIndex; + } + + const readyToRun = + !hasExtraInput && + spec.arguments.every( + (argument, index) => !argument.required || Boolean(values[index]?.trim()), + ); + + const helperText = hasExtraInput + ? `Too many arguments. Expected ${buildCommandSignature(spec)}.` + : currentArgumentIndex >= 0 + ? spec.arguments[currentArgumentIndex].required + ? `Next: enter ${spec.arguments[currentArgumentIndex].label.toLowerCase()}.` + : `Optional: add ${spec.arguments[currentArgumentIndex].label.toLowerCase()} or press Enter to run.` + : spec.arguments.length === 0 + ? `${spec.command} is ready to run.` + : 'Ready to run.'; + + return { + spec, + mode: 'exact', + arguments: spec.arguments.map((argument, index) => ({ + spec: argument, + value: values[index] ?? '', + isFilled: Boolean(values[index]?.trim()), + isActive: index === currentArgumentIndex, + })), + currentArgumentIndex, + helperText, + signature: buildCommandSignature(spec, values), + readyToRun, + hasExtraInput, + }; +}; diff --git a/MosaicIQ/src/types/terminal.ts b/MosaicIQ/src/types/terminal.ts index 11a82c3..1b4a6b4 100644 --- a/MosaicIQ/src/types/terminal.ts +++ b/MosaicIQ/src/types/terminal.ts @@ -34,11 +34,11 @@ export type TransportPanelPayload = | { type: 'earnings'; data: EarningsPanelData }; export type TerminalCommandResponse = - | { kind: 'text'; content: string } + | { kind: 'text'; content: string; portfolio?: Portfolio } | { kind: 'panel'; panel: TransportPanelPayload }; export type ResolvedTerminalCommandResponse = - | { kind: 'text'; content: string } + | { kind: 'text'; content: string; portfolio?: Portfolio } | { kind: 'panel'; panel: PanelPayload }; export interface ExecuteTerminalCommandRequest {