Make portfolio state reactive in sidebar and improve command autocomplete controls

This commit is contained in:
2026-04-06 00:57:12 -04:00
parent 91cc3cc3d4
commit 21cbce8a41
13 changed files with 946 additions and 235 deletions

View File

@@ -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:?}"),

View File

@@ -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<Portfolio>,
},
/// Structured payload rendered by an existing terminal panel.
Panel { panel: PanelPayload },
}

View File

@@ -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<HTMLDivElement | null>(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}
/>

View File

@@ -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<PortfolioPanelProps> = ({
portfolio,
onRunCommand,
@@ -85,57 +83,29 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={actionButtonClass}
onClick={() => onStartAction('buy')}
>
Buy
</button>
<button
type="button"
className={actionButtonClass}
onClick={() => onStartAction('sell')}
>
Sell
</button>
<button
type="button"
className={actionButtonClass}
onClick={() => onStartAction('deposit')}
>
Deposit
</button>
<button
type="button"
className={actionButtonClass}
onClick={() => onStartAction('withdraw')}
>
Withdraw
</button>
<button
type="button"
className={actionButtonClass}
onClick={() => onRunCommand('/portfolio stats')}
>
Stats
</button>
<button
type="button"
className={actionButtonClass}
onClick={() => onRunCommand('/portfolio history')}
>
History
</button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<ButtonGroup>
<ActionButton onClick={() => onStartAction('buy')}>Buy</ActionButton>
<ActionButton onClick={() => onStartAction('sell')}>Sell</ActionButton>
<ActionButton onClick={() => onStartAction('deposit')}>Deposit</ActionButton>
<ActionButton onClick={() => onStartAction('withdraw')}>Withdraw</ActionButton>
</ButtonGroup>
<ButtonGroup>
<ActionButton onClick={() => onRunCommand('/portfolio stats')} size="sm">
Stats
</ActionButton>
<ActionButton onClick={() => onRunCommand('/portfolio history')} size="sm">
History
</ActionButton>
</ButtonGroup>
</div>
</header>
<section className="mb-8">
<DataSection divider="none" padding="sm">
<MetricGrid metrics={summaryMetrics} columns={3} />
</section>
</DataSection>
<section className="border-t border-[#1a1a1a] pt-4">
<DataSection divider="top" padding="sm">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-heading-sm text-[#e0e0e0]">
Holdings ({portfolio.holdings.length})
@@ -148,34 +118,26 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
Reload overview
</button>
</div>
{portfolio.holdings.length === 0 ? (
<div className="border border-[#1f1f1f] bg-[#111111] px-4 py-5">
<div className="text-sm font-mono text-[#e0e0e0]">No holdings yet.</div>
<div className="mt-1 text-[11px] font-mono text-[#888888]">
Deposit cash first, then open a buy workflow to record your first position.
<div className="flex flex-col items-center justify-center py-12 px-6 border border-[#1f1f1f] bg-[#111111] rounded-lg">
<div className="text-[#58a6ff] mb-4">
<Package className="h-12 w-12" />
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
className={actionButtonClass}
onClick={() => onStartAction('deposit')}
>
<div className="text-lg font-mono text-[#e0e0e0] mb-2">No holdings yet</div>
<div className="text-sm font-mono text-[#888888] mb-6 text-center max-w-md">
Start by depositing cash, then buy your first position to begin tracking your portfolio.
</div>
<ButtonGroup>
<ActionButton onClick={() => onStartAction('deposit')} variant="primary">
Deposit cash
</button>
<button
type="button"
className={actionButtonClass}
onClick={() => onStartAction('buy')}
>
Buy first company
</button>
</div>
</ActionButton>
<ActionButton onClick={() => onStartAction('buy')}>Buy first company</ActionButton>
</ButtonGroup>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm font-mono">
<thead className="text-[#888888]">
<thead className="bg-[#111111] text-[#888888] sticky top-0">
<tr className="border-b border-[#1a1a1a]">
<th className="px-4 py-2 text-left text-[10px] uppercase tracking-wider">
Symbol
@@ -201,15 +163,19 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
</tr>
</thead>
<tbody className="divide-y divide-[#1a1a1a]">
{portfolio.holdings.map((holding) => {
{portfolio.holdings.map((holding, index) => {
const gainPositive = holding.gainLoss >= 0;
return (
<tr key={holding.symbol} className="transition-colors hover:bg-[#151515]">
<tr
key={holding.symbol}
className="transition-all duration-150 hover:bg-[#151515] hover:shadow-sm"
style={{ animationDelay: `${index * 30}ms` }}
>
<td className="px-4 py-3">
<button
type="button"
className="text-left"
className="text-left transition-colors hover:text-[#58a6ff]"
onClick={() => onSelectHolding?.(holding.symbol)}
>
<div className="font-semibold text-[#e0e0e0]">{holding.symbol}</div>
@@ -241,22 +207,18 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button
type="button"
className="border border-[#2a2a2a] px-2 py-1 text-[11px] text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]"
onClick={() =>
onStartAction('sell', { symbol: holding.symbol })
}
<ActionButton
onClick={() => onStartAction('sell', { symbol: holding.symbol })}
size="sm"
>
Sell
</button>
<button
type="button"
className="border border-[#2a2a2a] px-2 py-1 text-[11px] text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]"
</ActionButton>
<ActionButton
onClick={() => onRunCommand(`/search ${holding.symbol}`)}
size="sm"
>
Search
</button>
</ActionButton>
</div>
</td>
</tr>
@@ -266,7 +228,7 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
</table>
</div>
)}
</section>
</DataSection>
</div>
);
};

View File

@@ -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<PortfolioSummaryProps> = ({
if (!portfolio) {
return (
<div className="border-l-2 border-[#1a1a1a] px-3 py-3">
<div className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">
Portfolio
<div className="border-l-2 border-[#1a1a1a] px-4 py-4 transition-colors hover:border-[#58a6ff]">
<div className="flex items-center gap-2 mb-3">
<div className="text-[#58a6ff]">
<Wallet className="h-4 w-4" />
</div>
<div className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">
Portfolio
</div>
</div>
<div className="mt-2 text-xs font-mono text-[#666666]">
Load your latest portfolio snapshot into the sidebar.
<div className="text-xs font-mono text-[#666666] mb-4">
Load your latest portfolio snapshot
</div>
<button
type="button"
onClick={onLoadPortfolio}
className="mt-3 border border-[#2a2a2a] bg-[#161616] px-3 py-2 text-xs font-mono text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]"
>
<ActionButton onClick={onLoadPortfolio} className="w-full">
Load portfolio
</button>
</ActionButton>
</div>
);
}
@@ -52,7 +54,6 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
<button
onClick={() => {
setIsExpanded((current) => !current);
onLoadPortfolio();
}}
className="w-full px-3 py-2 text-left"
aria-expanded={isExpanded}
@@ -68,7 +69,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
</div>
<div className="flex items-center gap-2">
<div
className={`flex items-center gap-1 ${
className={`flex items-center gap-1 transition-transform duration-200 ${
isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
}`}
>
@@ -84,11 +85,11 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
</div>
{hasMoreHoldings ? (
<span className="text-[#666666]">
{isExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<ChevronDown
className={`h-3 w-3 transition-transform duration-200 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</span>
) : null}
</div>
@@ -99,7 +100,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
{formatCurrency(portfolio.totalValue)}
</span>
<span
className={`text-xs font-mono ${
className={`text-xs font-mono transition-colors duration-200 ${
isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
}`}
>
@@ -109,15 +110,25 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
</div>
</button>
<div className="space-y-1 px-3 pb-2">
{visibleHoldings.map((holding) => {
<div
className="space-y-1 px-3 pb-2"
style={{
maxHeight: isExpanded
? `${portfolio.holdings.length * 44}px`
: `${initialHoldingsCount * 44}px`,
transition: 'max-height 0.3s ease-in-out, opacity 0.2s ease-in-out',
overflow: 'hidden',
}}
>
{visibleHoldings.map((holding, index) => {
const holdingPositive = holding.gainLoss >= 0;
return (
<button
key={holding.symbol}
type="button"
onClick={onLoadPortfolio}
className="group flex w-full items-center justify-between rounded px-2 py-1 text-xs transition-colors hover:bg-[#1a1a1a]"
className="group flex w-full items-center justify-between rounded-md px-3 py-2 text-xs transition-all duration-150 hover:bg-[#1a1a1a] hover:pl-4 border-l-2 border-transparent hover:border-[#58a6ff]"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[#e0e0e0] transition-colors group-hover:text-[#58a6ff]">
@@ -128,7 +139,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
</span>
</div>
<span
className={`font-mono ${
className={`font-mono transition-colors duration-150 ${
holdingPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
}`}
>
@@ -143,7 +154,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
<button
type="button"
onClick={() => setIsExpanded(true)}
className="w-full py-1.5 text-center text-[10px] font-mono text-[#888888] transition-colors hover:text-[#e0e0e0]"
className="w-full py-1.5 text-center text-[10px] font-mono text-[#888888] transition-colors hover:text-[#e0e0e0] hover:bg-[#1a1a1a] rounded"
>
See {portfolio.holdings.length - initialHoldingsCount} more positions
</button>
@@ -153,7 +164,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
<button
type="button"
onClick={() => setIsExpanded(false)}
className="w-full py-1.5 text-center text-[10px] font-mono text-[#888888] transition-colors hover:text-[#e0e0e0]"
className="w-full py-1.5 text-center text-[10px] font-mono text-[#888888] transition-colors hover:text-[#e0e0e0] hover:bg-[#1a1a1a] rounded"
>
Show less
</button>

View File

@@ -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<PortfolioActionDraft>) => 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} <ticker> <quantity> [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'} <amount>`;
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<CommandInputHandle, CommandInputPro
onUpdatePortfolioDraft,
onClearPortfolioAction,
isProcessing,
getPreviousCommand,
getNextCommand,
resetCommandIndex,
portfolioMode,
activePortfolioAction,
@@ -148,6 +134,8 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
) => {
const [input, setInput] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
const [shadowCollapsed, setShadowCollapsed] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const actionPrimaryFieldRef = useRef<HTMLInputElement>(null);
@@ -159,6 +147,10 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
: null,
[actionComposerActive, activePortfolioAction, portfolioDraft],
);
const shadowState = useMemo(
() => (actionComposerActive ? null : resolveTerminalShadow(input)),
[actionComposerActive, input],
);
useEffect(() => {
if (!isProcessing) {
@@ -170,12 +162,32 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
}
}, [actionComposerActive, isProcessing]);
const suggestionMatches = useMemo(
() =>
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<CommandInputHandle, CommandInputPro
onSubmit(trimmed);
setInput('');
setShowSuggestions(false);
setActiveSuggestionIndex(0);
resetCommandIndex();
}
};
@@ -202,6 +215,7 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
onClearPortfolioAction();
setInput('');
setShowSuggestions(false);
setActiveSuggestionIndex(0);
resetCommandIndex();
};
@@ -211,55 +225,72 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
onStartPortfolioAction(action);
setInput('');
setShowSuggestions(false);
setActiveSuggestionIndex(0);
resetCommandIndex();
return;
}
setInput(`${command} `);
setShowSuggestions(false);
setActiveSuggestionIndex(0);
inputRef.current?.focus();
};
const moveSuggestionSelection = (direction: -1 | 1) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<CommandInputHandle, CommandInputPro
</div>
) : null}
{!actionComposerActive && shadowState ? (
<div className="mb-2 border border-[#1f1f1f] bg-[#111111]">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[#1a1a1a]">
<span className="text-[9px] font-mono uppercase tracking-wider text-[#666666]">
Command Shadow
</span>
<div className="flex items-center gap-2">
<span
className={`text-[9px] font-mono ${
shadowState.readyToRun && !shadowState.hasExtraInput
? 'text-[#3fb950]'
: shadowState.mode === 'predictive'
? 'text-[#58a6ff]'
: 'text-[#888888]'
}`}
>
{shadowState.readyToRun && !shadowState.hasExtraInput
? 'Ready'
: shadowState.mode === 'predictive'
? 'Autocomplete'
: 'Fill Parameters'}
</span>
<button
type="button"
onClick={() => setShadowCollapsed(!shadowCollapsed)}
className="text-[#888888] hover:text-[#e0e0e0] transition-colors"
>
{shadowCollapsed ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronUp className="h-3 w-3" />
)}
</button>
</div>
</div>
{!shadowCollapsed && (
<>
<div className="px-3 py-2 border-b border-[#1a1a1a]">
<code className="text-xs font-mono text-[#e0e0e0]">
{shadowState.signature}
</code>
</div>
<div className="px-3 py-2">
{shadowState.arguments.length > 0 ? (
<div className="flex flex-wrap gap-2">
{shadowState.arguments.map((argument, index) => (
<div
key={`${shadowState.spec.command}-${argument.spec.key}`}
className={`px-2 py-1 border text-[10px] font-mono transition-colors ${
argument.isActive
? 'border-[#58a6ff] bg-[#0f1b2d] text-[#58a6ff]'
: argument.isFilled
? 'border-[#1f3d2d] bg-[#0f1712] text-[#3fb950]'
: 'border-[#2a2a2a] bg-[#161616] text-[#666666]'
}`}
>
<span className="text-[#888888]">{index + 1}.</span> {argument.spec.label}
{argument.spec.required && (
<span className="ml-1 text-[#ffb86c]">*</span>
)}
</div>
))}
</div>
) : (
<span className="text-[10px] font-mono text-[#666666]">
No parameters required
</span>
)}
</div>
{shadowState.spec.examples?.length && shadowState.spec.examples.length > 0 && (
<div className="px-3 py-2 border-t border-[#1a1a1a]">
<div className="flex flex-wrap gap-1.5">
{shadowState.spec.examples.slice(0, 3).map((example) => (
<button
key={example}
type="button"
onClick={() => {
setInput(example);
setShowSuggestions(false);
setActiveSuggestionIndex(0);
resetCommandIndex();
inputRef.current?.focus();
}}
className="text-[9px] font-mono px-2 py-0.5 border border-[#2a2a2a] text-[#888888] hover:border-[#58a6ff] hover:text-[#58a6ff] transition-colors"
>
{example}
</button>
))}
{shadowState.spec.examples.length > 3 && (
<span className="text-[9px] font-mono text-[#666666]">
+{shadowState.spec.examples.length - 3} more
</span>
)}
</div>
</div>
)}
</>
)}
</div>
) : null}
{showSuggestions && suggestionMatches.length > 0 ? (
<div className="absolute inset-x-0 top-full z-20 mt-2 border border-[#2a2a2a] bg-[#1a1a1a]">
{suggestionMatches.map((suggestion) => (
<button
key={suggestion.command}
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm font-mono transition-colors hover:bg-[#232323]"
className={`flex w-full items-center justify-between px-4 py-2 text-left text-sm font-mono transition-colors hover:bg-[#232323] ${
suggestionMatches[activeSuggestionIndex]?.command === suggestion.command
? 'bg-[#232323]'
: ''
}`}
onClick={() => activateSuggestion(suggestion.command)}
type="button"
>
@@ -420,7 +559,7 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] font-mono text-[#888888]">
<span>/ history</span>
<span>/ suggestions</span>
<span>Tab autocomplete</span>
<span>Ctrl+L clear</span>
<span className="text-[#666666]">{helperText}</span>

View File

@@ -22,8 +22,6 @@ interface TerminalProps {
) => void;
onUpdatePortfolioDraft: (patch: Partial<PortfolioActionDraft>) => void;
onClearPortfolioAction: () => void;
getPreviousCommand: () => string | null;
getNextCommand: () => string | null;
resetCommandIndex: () => void;
portfolioWorkflow: PortfolioWorkflowState;
}
@@ -38,8 +36,6 @@ export const Terminal: React.FC<TerminalProps> = ({
onStartPortfolioAction,
onUpdatePortfolioDraft,
onClearPortfolioAction,
getPreviousCommand,
getNextCommand,
resetCommandIndex,
portfolioWorkflow,
}) => {
@@ -57,8 +53,6 @@ export const Terminal: React.FC<TerminalProps> = ({
onUpdatePortfolioDraft={onUpdatePortfolioDraft}
onClearPortfolioAction={onClearPortfolioAction}
isProcessing={isProcessing}
getPreviousCommand={getPreviousCommand}
getNextCommand={getNextCommand}
resetCommandIndex={resetCommandIndex}
portfolioMode={portfolioWorkflow.isPortfolioMode}
activePortfolioAction={portfolioWorkflow.activePortfolioAction}

View File

@@ -0,0 +1,56 @@
import React from 'react';
interface ActionButtonProps {
onClick: () => void;
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md';
disabled?: boolean;
children: React.ReactNode;
className?: string;
type?: 'button' | 'submit';
}
export const ActionButton: React.FC<ActionButtonProps> = ({
onClick,
variant = 'secondary',
size = 'md',
disabled = false,
children,
className = '',
type = 'button',
}) => {
const baseClass =
'font-mono transition-all duration-200 border disabled:cursor-not-allowed disabled:opacity-40';
const sizeClasses = {
sm: 'px-3 py-1.5 text-[11px]',
md: 'px-4 py-2 text-xs',
};
const variantClasses = {
primary:
'bg-[#58a6ff] border-[#58a6ff] text-[#0a0a0a] hover:bg-[#4a96ef] hover:border-[#4a96ef] active:scale-[0.98]',
secondary:
'bg-[#161616] border-[#2a2a2a] text-[#888888] hover:border-[#58a6ff] hover:text-[#e0e0e0] active:scale-[0.98]',
};
return (
<button
type={type}
className={`${baseClass} ${sizeClasses[size]} ${variantClasses[variant]} ${className}`.trim()}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
};
interface ButtonGroupProps {
children: React.ReactNode;
className?: string;
}
export const ButtonGroup: React.FC<ButtonGroupProps> = ({ children, className = '' }) => {
return <div className={`flex flex-wrap gap-2 ${className}`.trim()}>{children}</div>;
};

View File

@@ -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,

View File

@@ -77,6 +77,7 @@ export const isPortfolioCommand = (command: string): boolean =>
export const usePortfolioWorkflow = () => {
const [workflows, setWorkflows] = useState<Record<string, PortfolioWorkflowState>>({});
const [portfolioSnapshot, setPortfolioSnapshot] = useState<Portfolio | null>(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,

View File

@@ -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<PortfolioAction, 'buy' | 'sell' | 'deposit' | 'withdraw'>,
): TerminalCommandSpec | undefined =>
TERMINAL_COMMAND_SPECS.find((spec) => spec.portfolioAction === action);

View File

@@ -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,
};
};

View File

@@ -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 {