Make portfolio state reactive in sidebar and improve command autocomplete controls
This commit is contained in:
@@ -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:?}"),
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
56
MosaicIQ/src/components/ui/ActionButton.tsx
Normal file
56
MosaicIQ/src/components/ui/ActionButton.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
305
MosaicIQ/src/lib/terminalCommandSpecs.ts
Normal file
305
MosaicIQ/src/lib/terminalCommandSpecs.ts
Normal 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);
|
||||
214
MosaicIQ/src/lib/terminalShadow.ts
Normal file
214
MosaicIQ/src/lib/terminalShadow.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user