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 {
|
if command.args.len() > 2 {
|
||||||
TerminalCommandResponse::Text {
|
TerminalCommandResponse::Text {
|
||||||
content: "Usage: /fa [ticker] [annual|quarterly]".to_string(),
|
content: "Usage: /fa [ticker] [annual|quarterly]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.financials(command.args.first(), command.args.get(1), Frequency::Annual)
|
self.financials(command.args.first(), command.args.get(1), Frequency::Annual)
|
||||||
@@ -80,6 +81,7 @@ impl TerminalCommandService {
|
|||||||
if command.args.len() > 2 {
|
if command.args.len() > 2 {
|
||||||
TerminalCommandResponse::Text {
|
TerminalCommandResponse::Text {
|
||||||
content: "Usage: /cf [ticker] [annual|quarterly]".to_string(),
|
content: "Usage: /cf [ticker] [annual|quarterly]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.cash_flow(command.args.first(), command.args.get(1), Frequency::Annual)
|
self.cash_flow(command.args.first(), command.args.get(1), Frequency::Annual)
|
||||||
@@ -90,6 +92,7 @@ impl TerminalCommandService {
|
|||||||
if command.args.len() > 1 {
|
if command.args.len() > 1 {
|
||||||
TerminalCommandResponse::Text {
|
TerminalCommandResponse::Text {
|
||||||
content: "Usage: /dvd [ticker]".to_string(),
|
content: "Usage: /dvd [ticker]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.dividends(command.args.first(), command.args.get(1))
|
self.dividends(command.args.first(), command.args.get(1))
|
||||||
@@ -100,6 +103,7 @@ impl TerminalCommandService {
|
|||||||
if command.args.len() > 2 {
|
if command.args.len() > 2 {
|
||||||
TerminalCommandResponse::Text {
|
TerminalCommandResponse::Text {
|
||||||
content: "Usage: /em [ticker] [annual|quarterly]".to_string(),
|
content: "Usage: /em [ticker] [annual|quarterly]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.earnings(
|
self.earnings(
|
||||||
@@ -115,6 +119,7 @@ impl TerminalCommandService {
|
|||||||
"/help" => help_response(),
|
"/help" => help_response(),
|
||||||
_ => TerminalCommandResponse::Text {
|
_ => TerminalCommandResponse::Text {
|
||||||
content: format!("Unknown command: {}\n\n{}", command.command, help_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 {
|
let Some(ticker) = ticker.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
return TerminalCommandResponse::Text {
|
return TerminalCommandResponse::Text {
|
||||||
content: "Usage: /analyze [ticker]".to_string(),
|
content: "Usage: /analyze [ticker]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,6 +282,7 @@ impl TerminalCommandService {
|
|||||||
},
|
},
|
||||||
None => TerminalCommandResponse::Text {
|
None => TerminalCommandResponse::Text {
|
||||||
content: format!("Analysis not available for \"{ticker}\"."),
|
content: format!("Analysis not available for \"{ticker}\"."),
|
||||||
|
portfolio: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,6 +299,7 @@ impl TerminalCommandService {
|
|||||||
match self.portfolio_service.stats().await {
|
match self.portfolio_service.stats().await {
|
||||||
Ok(stats) => TerminalCommandResponse::Text {
|
Ok(stats) => TerminalCommandResponse::Text {
|
||||||
content: format_portfolio_stats(&stats),
|
content: format_portfolio_stats(&stats),
|
||||||
|
portfolio: None,
|
||||||
},
|
},
|
||||||
Err(error) => portfolio_error_response(error),
|
Err(error) => portfolio_error_response(error),
|
||||||
}
|
}
|
||||||
@@ -303,12 +311,14 @@ impl TerminalCommandService {
|
|||||||
let Some(limit) = parse_positive_usize(limit) else {
|
let Some(limit) = parse_positive_usize(limit) else {
|
||||||
return TerminalCommandResponse::Text {
|
return TerminalCommandResponse::Text {
|
||||||
content: "Usage: /portfolio history [limit]".to_string(),
|
content: "Usage: /portfolio history [limit]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
self.portfolio_history_response(limit).await
|
self.portfolio_history_response(limit).await
|
||||||
}
|
}
|
||||||
_ => TerminalCommandResponse::Text {
|
_ => TerminalCommandResponse::Text {
|
||||||
content: "Usage: /portfolio [stats|history [limit]]".to_string(),
|
content: "Usage: /portfolio [stats|history [limit]]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,9 +327,11 @@ impl TerminalCommandService {
|
|||||||
match self.portfolio_service.history(limit).await {
|
match self.portfolio_service.history(limit).await {
|
||||||
Ok(history) if history.is_empty() => TerminalCommandResponse::Text {
|
Ok(history) if history.is_empty() => TerminalCommandResponse::Text {
|
||||||
content: "Portfolio history is empty.".to_string(),
|
content: "Portfolio history is empty.".to_string(),
|
||||||
|
portfolio: None,
|
||||||
},
|
},
|
||||||
Ok(history) => TerminalCommandResponse::Text {
|
Ok(history) => TerminalCommandResponse::Text {
|
||||||
content: format_portfolio_history(&history),
|
content: format_portfolio_history(&history),
|
||||||
|
portfolio: None,
|
||||||
},
|
},
|
||||||
Err(error) => portfolio_error_response(error),
|
Err(error) => portfolio_error_response(error),
|
||||||
}
|
}
|
||||||
@@ -329,6 +341,7 @@ impl TerminalCommandService {
|
|||||||
let Some((symbol, quantity, price_override)) = parse_trade_args("/buy", args) else {
|
let Some((symbol, quantity, price_override)) = parse_trade_args("/buy", args) else {
|
||||||
return TerminalCommandResponse::Text {
|
return TerminalCommandResponse::Text {
|
||||||
content: "Usage: /buy [ticker] [quantity] [price?]".to_string(),
|
content: "Usage: /buy [ticker] [quantity] [price?]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -337,9 +350,10 @@ impl TerminalCommandService {
|
|||||||
.buy(symbol.as_str(), quantity, price_override)
|
.buy(symbol.as_str(), quantity, price_override)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(confirmation) => TerminalCommandResponse::Text {
|
Ok(confirmation) => {
|
||||||
content: format_buy_confirmation(&confirmation),
|
self.text_response_with_portfolio(format_buy_confirmation(&confirmation))
|
||||||
},
|
.await
|
||||||
|
}
|
||||||
Err(error) => portfolio_error_response(error),
|
Err(error) => portfolio_error_response(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +362,7 @@ impl TerminalCommandService {
|
|||||||
let Some((symbol, quantity, price_override)) = parse_trade_args("/sell", args) else {
|
let Some((symbol, quantity, price_override)) = parse_trade_args("/sell", args) else {
|
||||||
return TerminalCommandResponse::Text {
|
return TerminalCommandResponse::Text {
|
||||||
content: "Usage: /sell [ticker] [quantity] [price?]".to_string(),
|
content: "Usage: /sell [ticker] [quantity] [price?]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -356,9 +371,10 @@ impl TerminalCommandService {
|
|||||||
.sell(symbol.as_str(), quantity, price_override)
|
.sell(symbol.as_str(), quantity, price_override)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(confirmation) => TerminalCommandResponse::Text {
|
Ok(confirmation) => {
|
||||||
content: format_sell_confirmation(&confirmation),
|
self.text_response_with_portfolio(format_sell_confirmation(&confirmation))
|
||||||
},
|
.await
|
||||||
|
}
|
||||||
Err(error) => portfolio_error_response(error),
|
Err(error) => portfolio_error_response(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,6 +383,7 @@ impl TerminalCommandService {
|
|||||||
let Some((subcommand, amount)) = parse_cash_args(args) else {
|
let Some((subcommand, amount)) = parse_cash_args(args) else {
|
||||||
return TerminalCommandResponse::Text {
|
return TerminalCommandResponse::Text {
|
||||||
content: "Usage: /cash [deposit|withdraw] [amount]".to_string(),
|
content: "Usage: /cash [deposit|withdraw] [amount]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -377,13 +394,20 @@ impl TerminalCommandService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(confirmation) => TerminalCommandResponse::Text {
|
Ok(confirmation) => {
|
||||||
content: format_cash_confirmation(&confirmation),
|
self.text_response_with_portfolio(format_cash_confirmation(&confirmation))
|
||||||
},
|
.await
|
||||||
|
}
|
||||||
Err(error) => portfolio_error_response(error),
|
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(
|
async fn financials(
|
||||||
&self,
|
&self,
|
||||||
ticker: Option<&String>,
|
ticker: Option<&String>,
|
||||||
@@ -437,11 +461,13 @@ impl TerminalCommandService {
|
|||||||
Some(_) => {
|
Some(_) => {
|
||||||
return TerminalCommandResponse::Text {
|
return TerminalCommandResponse::Text {
|
||||||
content: "Usage: /dvd [ticker]".to_string(),
|
content: "Usage: /dvd [ticker]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
return TerminalCommandResponse::Text {
|
return TerminalCommandResponse::Text {
|
||||||
content: "Usage: /dvd [ticker]".to_string(),
|
content: "Usage: /dvd [ticker]".to_string(),
|
||||||
|
portfolio: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -617,6 +643,7 @@ fn parse_symbol_and_frequency(
|
|||||||
else {
|
else {
|
||||||
return Err(TerminalCommandResponse::Text {
|
return Err(TerminalCommandResponse::Text {
|
||||||
content: format!("Usage: {command} [ticker] [annual|quarterly]"),
|
content: format!("Usage: {command} [ticker] [annual|quarterly]"),
|
||||||
|
portfolio: None,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -627,6 +654,7 @@ fn parse_symbol_and_frequency(
|
|||||||
Some(_) => {
|
Some(_) => {
|
||||||
return Err(TerminalCommandResponse::Text {
|
return Err(TerminalCommandResponse::Text {
|
||||||
content: format!("Usage: {command} [ticker] [annual|quarterly]"),
|
content: format!("Usage: {command} [ticker] [annual|quarterly]"),
|
||||||
|
portfolio: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -700,12 +728,14 @@ fn help_text() -> &'static str {
|
|||||||
fn help_response() -> TerminalCommandResponse {
|
fn help_response() -> TerminalCommandResponse {
|
||||||
TerminalCommandResponse::Text {
|
TerminalCommandResponse::Text {
|
||||||
content: help_text().to_string(),
|
content: help_text().to_string(),
|
||||||
|
portfolio: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn portfolio_error_response(error: PortfolioCommandError) -> TerminalCommandResponse {
|
fn portfolio_error_response(error: PortfolioCommandError) -> TerminalCommandResponse {
|
||||||
TerminalCommandResponse::Text {
|
TerminalCommandResponse::Text {
|
||||||
content: error.to_string(),
|
content: error.to_string(),
|
||||||
|
portfolio: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1382,7 +1412,7 @@ mod tests {
|
|||||||
let response = execute(&service, "/wat");
|
let response = execute(&service, "/wat");
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
TerminalCommandResponse::Text { content } => {
|
TerminalCommandResponse::Text { content, .. } => {
|
||||||
assert!(content.contains("Unknown command"));
|
assert!(content.contains("Unknown command"));
|
||||||
}
|
}
|
||||||
other => panic!("expected text response, got {other:?}"),
|
other => panic!("expected text response, got {other:?}"),
|
||||||
@@ -1402,7 +1432,7 @@ mod tests {
|
|||||||
let response = execute(&service, "/analyze");
|
let response = execute(&service, "/analyze");
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
TerminalCommandResponse::Text { content } => {
|
TerminalCommandResponse::Text { content, .. } => {
|
||||||
assert_eq!(content, "Usage: /analyze [ticker]");
|
assert_eq!(content, "Usage: /analyze [ticker]");
|
||||||
}
|
}
|
||||||
other => panic!("expected text response, got {other:?}"),
|
other => panic!("expected text response, got {other:?}"),
|
||||||
@@ -1451,9 +1481,13 @@ mod tests {
|
|||||||
let response = execute(&service, "/buy AAPL 2 178.25");
|
let response = execute(&service, "/buy AAPL 2 178.25");
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
TerminalCommandResponse::Text { content } => {
|
TerminalCommandResponse::Text {
|
||||||
|
content,
|
||||||
|
portfolio: Some(portfolio),
|
||||||
|
} => {
|
||||||
assert!(content.contains("Bought 2 AAPL @ $178.25"));
|
assert!(content.contains("Bought 2 AAPL @ $178.25"));
|
||||||
assert!(content.contains("Cash balance: $643.50"));
|
assert!(content.contains("Cash balance: $643.50"));
|
||||||
|
assert_eq!(portfolio.holdings_count, Some(0));
|
||||||
}
|
}
|
||||||
other => panic!("expected text response, got {other:?}"),
|
other => panic!("expected text response, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -1480,8 +1514,12 @@ mod tests {
|
|||||||
let response = execute(&service, "/buy AAPL 2");
|
let response = execute(&service, "/buy AAPL 2");
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
TerminalCommandResponse::Text { content } => {
|
TerminalCommandResponse::Text {
|
||||||
|
content,
|
||||||
|
portfolio: Some(portfolio),
|
||||||
|
} => {
|
||||||
assert!(content.contains("Bought 2 AAPL @ $100.00"));
|
assert!(content.contains("Bought 2 AAPL @ $100.00"));
|
||||||
|
assert_eq!(portfolio.cash_balance, Some(0.0));
|
||||||
}
|
}
|
||||||
other => panic!("expected text response, got {other:?}"),
|
other => panic!("expected text response, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -1505,7 +1543,7 @@ mod tests {
|
|||||||
let response = execute(&service, "/buy AAPL 2");
|
let response = execute(&service, "/buy AAPL 2");
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
TerminalCommandResponse::Text { content } => {
|
TerminalCommandResponse::Text { content, .. } => {
|
||||||
assert!(content.contains("quote unavailable for AAPL"));
|
assert!(content.contains("quote unavailable for AAPL"));
|
||||||
}
|
}
|
||||||
other => panic!("expected text response, got {other:?}"),
|
other => panic!("expected text response, got {other:?}"),
|
||||||
@@ -1533,9 +1571,13 @@ mod tests {
|
|||||||
let response = execute(&service, "/sell AAPL 1.5 110");
|
let response = execute(&service, "/sell AAPL 1.5 110");
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
TerminalCommandResponse::Text { content } => {
|
TerminalCommandResponse::Text {
|
||||||
|
content,
|
||||||
|
portfolio: Some(portfolio),
|
||||||
|
} => {
|
||||||
assert!(content.contains("Sold 1.5 AAPL @ $110.00"));
|
assert!(content.contains("Sold 1.5 AAPL @ $110.00"));
|
||||||
assert!(content.contains("Realized P/L: $15.00"));
|
assert!(content.contains("Realized P/L: $15.00"));
|
||||||
|
assert_eq!(portfolio.total_value, 0.0);
|
||||||
}
|
}
|
||||||
other => panic!("expected text response, got {other:?}"),
|
other => panic!("expected text response, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -1638,7 +1680,7 @@ mod tests {
|
|||||||
let response = execute(&service, "/portfolio history 2");
|
let response = execute(&service, "/portfolio history 2");
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
TerminalCommandResponse::Text { content } => {
|
TerminalCommandResponse::Text { content, .. } => {
|
||||||
assert!(content.contains("2026-01-03T10:00:00Z"));
|
assert!(content.contains("2026-01-03T10:00:00Z"));
|
||||||
assert!(content.contains("2026-01-02T10:00:00Z"));
|
assert!(content.contains("2026-01-02T10:00:00Z"));
|
||||||
assert!(!content.contains("2026-01-01T10:00:00Z"));
|
assert!(!content.contains("2026-01-01T10:00:00Z"));
|
||||||
@@ -1654,7 +1696,7 @@ mod tests {
|
|||||||
let response = execute(&service, "/buy AAPL nope");
|
let response = execute(&service, "/buy AAPL nope");
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
TerminalCommandResponse::Text { content } => {
|
TerminalCommandResponse::Text { content, .. } => {
|
||||||
assert_eq!(content, "Usage: /buy [ticker] [quantity] [price?]");
|
assert_eq!(content, "Usage: /buy [ticker] [quantity] [price?]");
|
||||||
}
|
}
|
||||||
other => panic!("expected text response, got {other:?}"),
|
other => panic!("expected text response, got {other:?}"),
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ pub struct ChatCommandRequest {
|
|||||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
pub enum TerminalCommandResponse {
|
pub enum TerminalCommandResponse {
|
||||||
/// Plain text response rendered directly in the terminal.
|
/// 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.
|
/// Structured payload rendered by an existing terminal panel.
|
||||||
Panel { panel: PanelPayload },
|
Panel { panel: PanelPayload },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,10 +59,6 @@ function App() {
|
|||||||
return tabs.activeWorkspace?.history || [];
|
return tabs.activeWorkspace?.history || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActiveCommandHistory = () => {
|
|
||||||
return commandHistoryRefs.current[tabs.activeWorkspaceId] || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const pushCommandHistory = useCallback((workspaceId: string, command: string) => {
|
const pushCommandHistory = useCallback((workspaceId: string, command: string) => {
|
||||||
if (!commandHistoryRefs.current[workspaceId]) {
|
if (!commandHistoryRefs.current[workspaceId]) {
|
||||||
commandHistoryRefs.current[workspaceId] = [];
|
commandHistoryRefs.current[workspaceId] = [];
|
||||||
@@ -263,43 +259,13 @@ function App() {
|
|||||||
// Command history navigation
|
// Command history navigation
|
||||||
// Accesses from END of array (most recent commands first)
|
// Accesses from END of array (most recent commands first)
|
||||||
// Index -1 = current input, 0 = most recent, 1 = second most recent, etc.
|
// 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(() => {
|
const resetCommandIndex = useCallback(() => {
|
||||||
commandIndexRefs.current[tabs.activeWorkspaceId] = -1;
|
commandIndexRefs.current[tabs.activeWorkspaceId] = -1;
|
||||||
}, [tabs.activeWorkspaceId]);
|
}, [tabs.activeWorkspaceId]);
|
||||||
|
|
||||||
const outputRef = useRef<HTMLDivElement | null>(null);
|
const outputRef = useRef<HTMLDivElement | null>(null);
|
||||||
const activePortfolioWorkflow = portfolioWorkflow.readWorkflow(tabs.activeWorkspaceId);
|
const activePortfolioWorkflow = portfolioWorkflow.readWorkflow(tabs.activeWorkspaceId);
|
||||||
const portfolioSnapshot: Portfolio | null = activePortfolioWorkflow.portfolioSnapshot;
|
const portfolioSnapshot: Portfolio | null = portfolioWorkflow.portfolioSnapshot;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -469,8 +435,6 @@ function App() {
|
|||||||
onStartPortfolioAction={handleStartPortfolioAction}
|
onStartPortfolioAction={handleStartPortfolioAction}
|
||||||
onUpdatePortfolioDraft={handleUpdatePortfolioDraft}
|
onUpdatePortfolioDraft={handleUpdatePortfolioDraft}
|
||||||
onClearPortfolioAction={handleClearPortfolioAction}
|
onClearPortfolioAction={handleClearPortfolioAction}
|
||||||
getPreviousCommand={getPreviousCommand}
|
|
||||||
getNextCommand={getNextCommand}
|
|
||||||
resetCommandIndex={resetCommandIndex}
|
resetCommandIndex={resetCommandIndex}
|
||||||
portfolioWorkflow={activePortfolioWorkflow}
|
portfolioWorkflow={activePortfolioWorkflow}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Package } from 'lucide-react';
|
||||||
import { Portfolio } from '../../types/financial';
|
import { Portfolio } from '../../types/financial';
|
||||||
import { PortfolioAction, PortfolioActionSeed } from '../../types/terminal';
|
import { PortfolioAction, PortfolioActionSeed } from '../../types/terminal';
|
||||||
import { MetricGrid } from '../ui';
|
import { MetricGrid, ActionButton, ButtonGroup, DataSection } from '../ui';
|
||||||
|
|
||||||
interface PortfolioPanelProps {
|
interface PortfolioPanelProps {
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
@@ -24,9 +25,6 @@ const formatQuantity = (value: number) => {
|
|||||||
return rendered.replace(/\.?0+$/, '');
|
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> = ({
|
export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
|
||||||
portfolio,
|
portfolio,
|
||||||
onRunCommand,
|
onRunCommand,
|
||||||
@@ -85,57 +83,29 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<button
|
<ButtonGroup>
|
||||||
type="button"
|
<ActionButton onClick={() => onStartAction('buy')}>Buy</ActionButton>
|
||||||
className={actionButtonClass}
|
<ActionButton onClick={() => onStartAction('sell')}>Sell</ActionButton>
|
||||||
onClick={() => onStartAction('buy')}
|
<ActionButton onClick={() => onStartAction('deposit')}>Deposit</ActionButton>
|
||||||
>
|
<ActionButton onClick={() => onStartAction('withdraw')}>Withdraw</ActionButton>
|
||||||
Buy
|
</ButtonGroup>
|
||||||
</button>
|
<ButtonGroup>
|
||||||
<button
|
<ActionButton onClick={() => onRunCommand('/portfolio stats')} size="sm">
|
||||||
type="button"
|
Stats
|
||||||
className={actionButtonClass}
|
</ActionButton>
|
||||||
onClick={() => onStartAction('sell')}
|
<ActionButton onClick={() => onRunCommand('/portfolio history')} size="sm">
|
||||||
>
|
History
|
||||||
Sell
|
</ActionButton>
|
||||||
</button>
|
</ButtonGroup>
|
||||||
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="mb-8">
|
<DataSection divider="none" padding="sm">
|
||||||
<MetricGrid metrics={summaryMetrics} columns={3} />
|
<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">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-heading-sm text-[#e0e0e0]">
|
<h3 className="text-heading-sm text-[#e0e0e0]">
|
||||||
Holdings ({portfolio.holdings.length})
|
Holdings ({portfolio.holdings.length})
|
||||||
@@ -148,34 +118,26 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
|
|||||||
Reload overview
|
Reload overview
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{portfolio.holdings.length === 0 ? (
|
{portfolio.holdings.length === 0 ? (
|
||||||
<div className="border border-[#1f1f1f] bg-[#111111] px-4 py-5">
|
<div className="flex flex-col items-center justify-center py-12 px-6 border border-[#1f1f1f] bg-[#111111] rounded-lg">
|
||||||
<div className="text-sm font-mono text-[#e0e0e0]">No holdings yet.</div>
|
<div className="text-[#58a6ff] mb-4">
|
||||||
<div className="mt-1 text-[11px] font-mono text-[#888888]">
|
<Package className="h-12 w-12" />
|
||||||
Deposit cash first, then open a buy workflow to record your first position.
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="text-lg font-mono text-[#e0e0e0] mb-2">No holdings yet</div>
|
||||||
<button
|
<div className="text-sm font-mono text-[#888888] mb-6 text-center max-w-md">
|
||||||
type="button"
|
Start by depositing cash, then buy your first position to begin tracking your portfolio.
|
||||||
className={actionButtonClass}
|
</div>
|
||||||
onClick={() => onStartAction('deposit')}
|
<ButtonGroup>
|
||||||
>
|
<ActionButton onClick={() => onStartAction('deposit')} variant="primary">
|
||||||
Deposit cash
|
Deposit cash
|
||||||
</button>
|
</ActionButton>
|
||||||
<button
|
<ActionButton onClick={() => onStartAction('buy')}>Buy first company</ActionButton>
|
||||||
type="button"
|
</ButtonGroup>
|
||||||
className={actionButtonClass}
|
|
||||||
onClick={() => onStartAction('buy')}
|
|
||||||
>
|
|
||||||
Buy first company
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm font-mono">
|
<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]">
|
<tr className="border-b border-[#1a1a1a]">
|
||||||
<th className="px-4 py-2 text-left text-[10px] uppercase tracking-wider">
|
<th className="px-4 py-2 text-left text-[10px] uppercase tracking-wider">
|
||||||
Symbol
|
Symbol
|
||||||
@@ -201,15 +163,19 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#1a1a1a]">
|
<tbody className="divide-y divide-[#1a1a1a]">
|
||||||
{portfolio.holdings.map((holding) => {
|
{portfolio.holdings.map((holding, index) => {
|
||||||
const gainPositive = holding.gainLoss >= 0;
|
const gainPositive = holding.gainLoss >= 0;
|
||||||
|
|
||||||
return (
|
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">
|
<td className="px-4 py-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-left"
|
className="text-left transition-colors hover:text-[#58a6ff]"
|
||||||
onClick={() => onSelectHolding?.(holding.symbol)}
|
onClick={() => onSelectHolding?.(holding.symbol)}
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-[#e0e0e0]">{holding.symbol}</div>
|
<div className="font-semibold text-[#e0e0e0]">{holding.symbol}</div>
|
||||||
@@ -241,22 +207,18 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<ActionButton
|
||||||
type="button"
|
onClick={() => onStartAction('sell', { symbol: holding.symbol })}
|
||||||
className="border border-[#2a2a2a] px-2 py-1 text-[11px] text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]"
|
size="sm"
|
||||||
onClick={() =>
|
|
||||||
onStartAction('sell', { symbol: holding.symbol })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Sell
|
Sell
|
||||||
</button>
|
</ActionButton>
|
||||||
<button
|
<ActionButton
|
||||||
type="button"
|
|
||||||
className="border border-[#2a2a2a] px-2 py-1 text-[11px] text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]"
|
|
||||||
onClick={() => onRunCommand(`/search ${holding.symbol}`)}
|
onClick={() => onRunCommand(`/search ${holding.symbol}`)}
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
</button>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -266,7 +228,7 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</DataSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
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 { Portfolio } from '../../types/financial';
|
||||||
|
import { ActionButton } from '../ui';
|
||||||
|
|
||||||
interface PortfolioSummaryProps {
|
interface PortfolioSummaryProps {
|
||||||
portfolio: Portfolio | null;
|
portfolio: Portfolio | null;
|
||||||
@@ -23,20 +24,21 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
|||||||
|
|
||||||
if (!portfolio) {
|
if (!portfolio) {
|
||||||
return (
|
return (
|
||||||
<div className="border-l-2 border-[#1a1a1a] px-3 py-3">
|
<div className="border-l-2 border-[#1a1a1a] px-4 py-4 transition-colors hover:border-[#58a6ff]">
|
||||||
<div className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
Portfolio
|
<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>
|
||||||
<div className="mt-2 text-xs font-mono text-[#666666]">
|
<div className="text-xs font-mono text-[#666666] mb-4">
|
||||||
Load your latest portfolio snapshot into the sidebar.
|
Load your latest portfolio snapshot
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ActionButton onClick={onLoadPortfolio} className="w-full">
|
||||||
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]"
|
|
||||||
>
|
|
||||||
Load portfolio
|
Load portfolio
|
||||||
</button>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -52,7 +54,6 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsExpanded((current) => !current);
|
setIsExpanded((current) => !current);
|
||||||
onLoadPortfolio();
|
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 text-left"
|
className="w-full px-3 py-2 text-left"
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
@@ -68,7 +69,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1 ${
|
className={`flex items-center gap-1 transition-transform duration-200 ${
|
||||||
isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -84,11 +85,11 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{hasMoreHoldings ? (
|
{hasMoreHoldings ? (
|
||||||
<span className="text-[#666666]">
|
<span className="text-[#666666]">
|
||||||
{isExpanded ? (
|
<ChevronDown
|
||||||
<ChevronUp className="h-3 w-3" />
|
className={`h-3 w-3 transition-transform duration-200 ${
|
||||||
) : (
|
isExpanded ? 'rotate-180' : ''
|
||||||
<ChevronDown className="h-3 w-3" />
|
}`}
|
||||||
)}
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +100,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
|||||||
{formatCurrency(portfolio.totalValue)}
|
{formatCurrency(portfolio.totalValue)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-mono ${
|
className={`text-xs font-mono transition-colors duration-200 ${
|
||||||
isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -109,15 +110,25 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="space-y-1 px-3 pb-2">
|
<div
|
||||||
{visibleHoldings.map((holding) => {
|
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;
|
const holdingPositive = holding.gainLoss >= 0;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={holding.symbol}
|
key={holding.symbol}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onLoadPortfolio}
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono text-[#e0e0e0] transition-colors group-hover:text-[#58a6ff]">
|
<span className="font-mono text-[#e0e0e0] transition-colors group-hover:text-[#58a6ff]">
|
||||||
@@ -128,7 +139,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`font-mono ${
|
className={`font-mono transition-colors duration-150 ${
|
||||||
holdingPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
holdingPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -143,7 +154,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsExpanded(true)}
|
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
|
See {portfolio.holdings.length - initialHoldingsCount} more positions
|
||||||
</button>
|
</button>
|
||||||
@@ -153,7 +164,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsExpanded(false)}
|
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
|
Show less
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
CommandSuggestion,
|
|
||||||
PortfolioAction,
|
PortfolioAction,
|
||||||
PortfolioActionDraft,
|
PortfolioActionDraft,
|
||||||
} from '../../types/terminal';
|
} from '../../types/terminal';
|
||||||
|
import {
|
||||||
|
getTerminalCommandSpec,
|
||||||
|
getTerminalCommandSpecForAction,
|
||||||
|
TERMINAL_COMMAND_SUGGESTIONS,
|
||||||
|
} from '../../lib/terminalCommandSpecs';
|
||||||
|
import {
|
||||||
|
buildCommandSignature,
|
||||||
|
resolveTerminalShadow,
|
||||||
|
} from '../../lib/terminalShadow';
|
||||||
|
|
||||||
interface CommandInputProps {
|
interface CommandInputProps {
|
||||||
onSubmit: (command: string) => void;
|
onSubmit: (command: string) => void;
|
||||||
@@ -18,8 +27,6 @@ interface CommandInputProps {
|
|||||||
onUpdatePortfolioDraft: (patch: Partial<PortfolioActionDraft>) => void;
|
onUpdatePortfolioDraft: (patch: Partial<PortfolioActionDraft>) => void;
|
||||||
onClearPortfolioAction: () => void;
|
onClearPortfolioAction: () => void;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
getPreviousCommand: () => string | null;
|
|
||||||
getNextCommand: () => string | null;
|
|
||||||
resetCommandIndex: () => void;
|
resetCommandIndex: () => void;
|
||||||
portfolioMode: boolean;
|
portfolioMode: boolean;
|
||||||
activePortfolioAction: PortfolioAction | null;
|
activePortfolioAction: PortfolioAction | null;
|
||||||
@@ -32,24 +39,6 @@ export interface CommandInputHandle {
|
|||||||
focusWithText: (text: string) => void;
|
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> = {
|
const ACTION_LABELS: Record<'buy' | 'sell' | 'deposit' | 'withdraw', string> = {
|
||||||
buy: 'Buy',
|
buy: 'Buy',
|
||||||
sell: 'Sell',
|
sell: 'Sell',
|
||||||
@@ -65,64 +54,63 @@ const isActionComposer = (
|
|||||||
const suggestionToAction = (
|
const suggestionToAction = (
|
||||||
command: string,
|
command: string,
|
||||||
): 'buy' | 'sell' | 'deposit' | 'withdraw' | null => {
|
): 'buy' | 'sell' | 'deposit' | 'withdraw' | null => {
|
||||||
switch (command) {
|
return getTerminalCommandSpec(command)?.portfolioAction ?? null;
|
||||||
case '/buy':
|
|
||||||
return 'buy';
|
|
||||||
case '/sell':
|
|
||||||
return 'sell';
|
|
||||||
case '/cash deposit':
|
|
||||||
return 'deposit';
|
|
||||||
case '/cash withdraw':
|
|
||||||
return 'withdraw';
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildGeneratedCommand = (
|
const buildGeneratedCommand = (
|
||||||
action: 'buy' | 'sell' | 'deposit' | 'withdraw',
|
action: 'buy' | 'sell' | 'deposit' | 'withdraw',
|
||||||
draft: PortfolioActionDraft,
|
draft: PortfolioActionDraft,
|
||||||
) => {
|
) => {
|
||||||
|
const spec = getTerminalCommandSpecForAction(action);
|
||||||
if (action === 'buy' || action === 'sell') {
|
if (action === 'buy' || action === 'sell') {
|
||||||
const symbol = draft.symbol.trim().toUpperCase();
|
const symbol = draft.symbol.trim().toUpperCase();
|
||||||
const quantity = draft.quantity.trim();
|
const quantity = draft.quantity.trim();
|
||||||
const price = draft.price.trim();
|
const price = draft.price.trim();
|
||||||
|
const signature = spec
|
||||||
|
? buildCommandSignature(spec, [symbol, quantity, price])
|
||||||
|
: `/${action} <ticker> <quantity> [price]`;
|
||||||
|
|
||||||
if (!symbol) {
|
if (!symbol) {
|
||||||
return { command: `/${action} [ticker] [quantity] [price?]`, error: 'Ticker symbol is required.' };
|
return { command: signature, error: 'Ticker symbol is required.' };
|
||||||
}
|
}
|
||||||
if (!quantity) {
|
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) {
|
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)) {
|
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 {
|
return {
|
||||||
command: price ? `/${action} ${symbol} ${quantity} ${price}` : `/${action} ${symbol} ${quantity}`,
|
command: signature,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount = draft.amount.trim();
|
const amount = draft.amount.trim();
|
||||||
|
const signature = spec
|
||||||
|
? buildCommandSignature(spec, [amount])
|
||||||
|
: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} <amount>`;
|
||||||
if (!amount) {
|
if (!amount) {
|
||||||
return {
|
return {
|
||||||
command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} [amount]`,
|
command: signature,
|
||||||
error: 'Amount is required.',
|
error: 'Amount is required.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Number.isNaN(Number(amount)) || Number(amount) <= 0) {
|
if (Number.isNaN(Number(amount)) || Number(amount) <= 0) {
|
||||||
return {
|
return {
|
||||||
command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} ${amount}`,
|
command: signature,
|
||||||
error: 'Amount must be greater than zero.',
|
error: 'Amount must be greater than zero.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} ${amount}`,
|
command: signature,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -135,8 +123,6 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
onUpdatePortfolioDraft,
|
onUpdatePortfolioDraft,
|
||||||
onClearPortfolioAction,
|
onClearPortfolioAction,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
getPreviousCommand,
|
|
||||||
getNextCommand,
|
|
||||||
resetCommandIndex,
|
resetCommandIndex,
|
||||||
portfolioMode,
|
portfolioMode,
|
||||||
activePortfolioAction,
|
activePortfolioAction,
|
||||||
@@ -148,6 +134,8 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
) => {
|
) => {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
|
||||||
|
const [shadowCollapsed, setShadowCollapsed] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const actionPrimaryFieldRef = useRef<HTMLInputElement>(null);
|
const actionPrimaryFieldRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -159,6 +147,10 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
: null,
|
: null,
|
||||||
[actionComposerActive, activePortfolioAction, portfolioDraft],
|
[actionComposerActive, activePortfolioAction, portfolioDraft],
|
||||||
);
|
);
|
||||||
|
const shadowState = useMemo(
|
||||||
|
() => (actionComposerActive ? null : resolveTerminalShadow(input)),
|
||||||
|
[actionComposerActive, input],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isProcessing) {
|
if (!isProcessing) {
|
||||||
@@ -170,12 +162,32 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
}
|
}
|
||||||
}, [actionComposerActive, isProcessing]);
|
}, [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(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
focusWithText: (text: string) => {
|
focusWithText: (text: string) => {
|
||||||
setInput(text);
|
setInput(text);
|
||||||
setShowSuggestions(text.startsWith('/'));
|
setShowSuggestions(text.startsWith('/'));
|
||||||
|
setActiveSuggestionIndex(0);
|
||||||
resetCommandIndex();
|
resetCommandIndex();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
},
|
},
|
||||||
@@ -189,6 +201,7 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
onSubmit(trimmed);
|
onSubmit(trimmed);
|
||||||
setInput('');
|
setInput('');
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
setActiveSuggestionIndex(0);
|
||||||
resetCommandIndex();
|
resetCommandIndex();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -202,6 +215,7 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
onClearPortfolioAction();
|
onClearPortfolioAction();
|
||||||
setInput('');
|
setInput('');
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
setActiveSuggestionIndex(0);
|
||||||
resetCommandIndex();
|
resetCommandIndex();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -211,55 +225,72 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
onStartPortfolioAction(action);
|
onStartPortfolioAction(action);
|
||||||
setInput('');
|
setInput('');
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
setActiveSuggestionIndex(0);
|
||||||
resetCommandIndex();
|
resetCommandIndex();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInput(`${command} `);
|
setInput(`${command} `);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
setActiveSuggestionIndex(0);
|
||||||
inputRef.current?.focus();
|
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>) => {
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (showSuggestions && suggestionMatches[activeSuggestionIndex]) {
|
||||||
|
activateSuggestion(suggestionMatches[activeSuggestionIndex].command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
} else if (event.key === 'ArrowUp') {
|
} else if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const previous = getPreviousCommand();
|
moveSuggestionSelection(-1);
|
||||||
if (previous !== null) {
|
|
||||||
setInput(previous);
|
|
||||||
}
|
|
||||||
} else if (event.key === 'ArrowDown') {
|
} else if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const next = getNextCommand();
|
moveSuggestionSelection(1);
|
||||||
if (next !== null) {
|
|
||||||
setInput(next);
|
|
||||||
}
|
|
||||||
} else if (event.key === 'Tab') {
|
} else if (event.key === 'Tab') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (input.startsWith('/')) {
|
if (showSuggestions) {
|
||||||
const match = SUGGESTIONS.find((suggestion) => suggestion.command.startsWith(input));
|
const match = suggestionMatches[activeSuggestionIndex] ?? suggestionMatches[0];
|
||||||
if (match) {
|
if (match) {
|
||||||
activateSuggestion(match.command);
|
activateSuggestion(match.command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.key === 'Escape') {
|
} else if (event.key === 'Escape') {
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
setActiveSuggestionIndex(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInput(event.target.value);
|
setInput(event.target.value);
|
||||||
setShowSuggestions(event.target.value.startsWith('/'));
|
setShowSuggestions(event.target.value.startsWith('/'));
|
||||||
|
setActiveSuggestionIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestionMatches = SUGGESTIONS.filter(
|
|
||||||
(suggestion) => !input || suggestion.command.startsWith(input),
|
|
||||||
);
|
|
||||||
|
|
||||||
const helperText = actionComposerActive
|
const helperText = actionComposerActive
|
||||||
? `Running ${actionMeta?.command ?? lastPortfolioCommand ?? '/portfolio'} will keep raw terminal output in view.`
|
? `Running ${actionMeta?.command ?? lastPortfolioCommand ?? '/portfolio'} will keep raw terminal output in view.`
|
||||||
|
: shadowState
|
||||||
|
? shadowState.helperText
|
||||||
: portfolioMode
|
: portfolioMode
|
||||||
? 'Portfolio tools stay active while you move between overview, history, stats, and trade actions.'
|
? 'Portfolio tools stay active while you move between overview, history, stats, and trade actions.'
|
||||||
: 'Use /portfolio to load interactive portfolio tools.';
|
: 'Use /portfolio to load interactive portfolio tools.';
|
||||||
@@ -373,12 +404,120 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{showSuggestions && suggestionMatches.length > 0 ? (
|
||||||
<div className="absolute inset-x-0 top-full z-20 mt-2 border border-[#2a2a2a] bg-[#1a1a1a]">
|
<div className="absolute inset-x-0 top-full z-20 mt-2 border border-[#2a2a2a] bg-[#1a1a1a]">
|
||||||
{suggestionMatches.map((suggestion) => (
|
{suggestionMatches.map((suggestion) => (
|
||||||
<button
|
<button
|
||||||
key={suggestion.command}
|
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)}
|
onClick={() => activateSuggestion(suggestion.command)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -420,7 +559,7 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] font-mono text-[#888888]">
|
<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>Tab autocomplete</span>
|
||||||
<span>Ctrl+L clear</span>
|
<span>Ctrl+L clear</span>
|
||||||
<span className="text-[#666666]">{helperText}</span>
|
<span className="text-[#666666]">{helperText}</span>
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ interface TerminalProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onUpdatePortfolioDraft: (patch: Partial<PortfolioActionDraft>) => void;
|
onUpdatePortfolioDraft: (patch: Partial<PortfolioActionDraft>) => void;
|
||||||
onClearPortfolioAction: () => void;
|
onClearPortfolioAction: () => void;
|
||||||
getPreviousCommand: () => string | null;
|
|
||||||
getNextCommand: () => string | null;
|
|
||||||
resetCommandIndex: () => void;
|
resetCommandIndex: () => void;
|
||||||
portfolioWorkflow: PortfolioWorkflowState;
|
portfolioWorkflow: PortfolioWorkflowState;
|
||||||
}
|
}
|
||||||
@@ -38,8 +36,6 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
onStartPortfolioAction,
|
onStartPortfolioAction,
|
||||||
onUpdatePortfolioDraft,
|
onUpdatePortfolioDraft,
|
||||||
onClearPortfolioAction,
|
onClearPortfolioAction,
|
||||||
getPreviousCommand,
|
|
||||||
getNextCommand,
|
|
||||||
resetCommandIndex,
|
resetCommandIndex,
|
||||||
portfolioWorkflow,
|
portfolioWorkflow,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -57,8 +53,6 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
onUpdatePortfolioDraft={onUpdatePortfolioDraft}
|
onUpdatePortfolioDraft={onUpdatePortfolioDraft}
|
||||||
onClearPortfolioAction={onClearPortfolioAction}
|
onClearPortfolioAction={onClearPortfolioAction}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
getPreviousCommand={getPreviousCommand}
|
|
||||||
getNextCommand={getNextCommand}
|
|
||||||
resetCommandIndex={resetCommandIndex}
|
resetCommandIndex={resetCommandIndex}
|
||||||
portfolioMode={portfolioWorkflow.isPortfolioMode}
|
portfolioMode={portfolioWorkflow.isPortfolioMode}
|
||||||
activePortfolioAction={portfolioWorkflow.activePortfolioAction}
|
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 { CompanyIdentity } from './CompanyIdentity';
|
||||||
export { PriceDisplay } from './PriceDisplay';
|
export { PriceDisplay } from './PriceDisplay';
|
||||||
|
|
||||||
|
// Action components
|
||||||
|
export { ActionButton, ButtonGroup } from './ActionButton';
|
||||||
|
|
||||||
// Financial components
|
// Financial components
|
||||||
export {
|
export {
|
||||||
StatementTableMinimal,
|
StatementTableMinimal,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const isPortfolioCommand = (command: string): boolean =>
|
|||||||
|
|
||||||
export const usePortfolioWorkflow = () => {
|
export const usePortfolioWorkflow = () => {
|
||||||
const [workflows, setWorkflows] = useState<Record<string, PortfolioWorkflowState>>({});
|
const [workflows, setWorkflows] = useState<Record<string, PortfolioWorkflowState>>({});
|
||||||
|
const [portfolioSnapshot, setPortfolioSnapshot] = useState<Portfolio | null>(null);
|
||||||
|
|
||||||
const readWorkflow = useCallback(
|
const readWorkflow = useCallback(
|
||||||
(workspaceId: string): PortfolioWorkflowState =>
|
(workspaceId: string): PortfolioWorkflowState =>
|
||||||
@@ -130,13 +131,24 @@ export const usePortfolioWorkflow = () => {
|
|||||||
return;
|
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) => {
|
updateWorkflow(workspaceId, (current) => {
|
||||||
if (response.kind === 'panel' && response.panel.type === 'portfolio') {
|
if (response.kind === 'panel' && response.panel.type === 'portfolio') {
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
isPortfolioMode: true,
|
isPortfolioMode: true,
|
||||||
activePortfolioAction: 'overview',
|
activePortfolioAction: 'overview',
|
||||||
portfolioSnapshot: response.panel.data,
|
portfolioSnapshot: latestPortfolioSnapshot,
|
||||||
portfolioSnapshotStatus: 'ready',
|
portfolioSnapshotStatus: 'ready',
|
||||||
lastPortfolioCommand: command,
|
lastPortfolioCommand: command,
|
||||||
};
|
};
|
||||||
@@ -152,6 +164,10 @@ export const usePortfolioWorkflow = () => {
|
|||||||
...current,
|
...current,
|
||||||
isPortfolioMode: true,
|
isPortfolioMode: true,
|
||||||
activePortfolioAction: completedTradeAction ? null : action,
|
activePortfolioAction: completedTradeAction ? null : action,
|
||||||
|
portfolioSnapshot: latestPortfolioSnapshot ?? current.portfolioSnapshot,
|
||||||
|
portfolioSnapshotStatus: latestPortfolioSnapshot
|
||||||
|
? 'ready'
|
||||||
|
: current.portfolioSnapshotStatus,
|
||||||
lastPortfolioCommand: command,
|
lastPortfolioCommand: command,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -235,6 +251,7 @@ export const usePortfolioWorkflow = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
portfolioSnapshot,
|
||||||
readWorkflow,
|
readWorkflow,
|
||||||
noteCommandStart,
|
noteCommandStart,
|
||||||
noteCommandResponse,
|
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 };
|
| { type: 'earnings'; data: EarningsPanelData };
|
||||||
|
|
||||||
export type TerminalCommandResponse =
|
export type TerminalCommandResponse =
|
||||||
| { kind: 'text'; content: string }
|
| { kind: 'text'; content: string; portfolio?: Portfolio }
|
||||||
| { kind: 'panel'; panel: TransportPanelPayload };
|
| { kind: 'panel'; panel: TransportPanelPayload };
|
||||||
|
|
||||||
export type ResolvedTerminalCommandResponse =
|
export type ResolvedTerminalCommandResponse =
|
||||||
| { kind: 'text'; content: string }
|
| { kind: 'text'; content: string; portfolio?: Portfolio }
|
||||||
| { kind: 'panel'; panel: PanelPayload };
|
| { kind: 'panel'; panel: PanelPayload };
|
||||||
|
|
||||||
export interface ExecuteTerminalCommandRequest {
|
export interface ExecuteTerminalCommandRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user