feat: wire tools into LocalBackend turn loop
- Added max_tool_rounds (configurable per-agent) to LlmConfig - Rewrote run_turn() with multi-round tool-calling loop (max 10 rounds by default, 0 disables tools) - Converts core ToolDefinitions → bifrost ToolDefinitions - Streams tool execution status to TUI via BackendEvent::Token - Continues consciousness processing (surfacing, reflection, archivist) after tool loop completes - Fixed git2::Config Send issue in memory/mod.rs (scoped block) Closes: docs/tasks/tool-wiring.md
This commit is contained in:
parent
bc7316c411
commit
ae0968bf44
6 changed files with 397 additions and 43 deletions
|
|
@ -253,16 +253,32 @@ async fn handle_conversation_stream(
|
|||
let events = server.consciousness.on_response(&*session, &content).await?;
|
||||
drop(session);
|
||||
|
||||
// Inject surfacing events back into the session as system messages
|
||||
// so the agent sees them in its context window on the next turn.
|
||||
for event in &events {
|
||||
if let crate::server::ConsciousnessEvent::Surfacing { source, content, priority } = event {
|
||||
let msg = crate::core::session::ConversationMessage {
|
||||
role: crate::core::session::MessageRole::System,
|
||||
blocks: vec![crate::core::session::ContentBlock::Text {
|
||||
text: format!("[surfacing: {}] {} — {}", source, content, priority),
|
||||
}],
|
||||
usage: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let _ = server.sessions.add_message(&conversation_id, msg);
|
||||
}
|
||||
}
|
||||
|
||||
for event in events {
|
||||
let stream_event = match event {
|
||||
let stream_event = match &event {
|
||||
crate::server::ConsciousnessEvent::Surfacing { source, content, priority } => {
|
||||
StreamEvent::Surfacing { source: source.to_string(), content, priority: priority.to_string() }
|
||||
StreamEvent::Surfacing { source: source.clone(), content: content.clone(), priority: priority.clone() }
|
||||
}
|
||||
crate::server::ConsciousnessEvent::Reflection { content } => {
|
||||
StreamEvent::Reflection { content }
|
||||
StreamEvent::Reflection { content: content.clone() }
|
||||
}
|
||||
crate::server::ConsciousnessEvent::Archivist { synthesis, pressure } => {
|
||||
StreamEvent::Archivist { synthesis, pressure }
|
||||
StreamEvent::Archivist { synthesis: synthesis.clone(), pressure: *pressure }
|
||||
}
|
||||
};
|
||||
let _ = tx.send(stream_event).await;
|
||||
|
|
|
|||
|
|
@ -34,12 +34,20 @@ pub struct LlmConfig {
|
|||
pub context_window: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
/// Maximum tool-calling rounds before forcing a text response.
|
||||
/// Configurable per-agent; 0 disables tools entirely.
|
||||
#[serde(default = "default_max_tool_rounds")]
|
||||
pub max_tool_rounds: u32,
|
||||
}
|
||||
|
||||
fn default_context_window() -> u32 {
|
||||
128000
|
||||
}
|
||||
|
||||
fn default_max_tool_rounds() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryConfig {
|
||||
pub git_enabled: bool,
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ async fn run_turn(
|
|||
) -> Result<()> {
|
||||
// Snapshot history for the Bifrost call, then drop the dashmap ref before
|
||||
// any await — `Ref` is not Send across awaits.
|
||||
let (agent_id, messages) = {
|
||||
let (agent_id, initial_messages) = {
|
||||
let session = server
|
||||
.sessions
|
||||
.get(&conversation_id)
|
||||
|
|
@ -136,34 +136,124 @@ async fn run_turn(
|
|||
};
|
||||
|
||||
let agent = server.agents.get(&agent_id).await?;
|
||||
let max_rounds = agent.llm_config.max_tool_rounds;
|
||||
let model = agent.llm_config.model.clone();
|
||||
let temperature = agent.llm_config.temperature;
|
||||
|
||||
let req = ChatCompletionRequest {
|
||||
model: agent.llm_config.model.clone(),
|
||||
messages,
|
||||
stream: Some(false),
|
||||
max_tokens: None,
|
||||
temperature: agent.llm_config.temperature,
|
||||
tools: None,
|
||||
};
|
||||
// Build bifrost-format tool definitions from the core tool set
|
||||
let core_tools = crate::core::tools::tool_definitions();
|
||||
let bifrost_tools: Vec<crate::bridge::bifrost::ToolDefinition> = core_tools
|
||||
.iter()
|
||||
.map(|t| crate::bridge::bifrost::ToolDefinition {
|
||||
tool_type: "function".to_string(),
|
||||
function: crate::bridge::bifrost::ToolFunction {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
parameters: t.input_schema.clone(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let response = server.bifrost.chat_completion(req).await?;
|
||||
let content = response.content.clone();
|
||||
// ── Tool-calling loop ─────────────────────────────────────
|
||||
let mut messages = initial_messages;
|
||||
let mut tool_round = 0u32;
|
||||
let final_content: String;
|
||||
|
||||
// Mirror the server's chunked streaming so the CLI/TUI sees progressive
|
||||
// tokens (the underlying call is non-streaming today; replace once Bifrost
|
||||
// SSE lands).
|
||||
let chars: Vec<char> = content.chars().collect();
|
||||
for chunk in chars.chunks(10) {
|
||||
let s: String = chunk.iter().collect();
|
||||
if tx.send(Ok(BackendEvent::Token(s))).await.is_err() {
|
||||
return Ok(());
|
||||
loop {
|
||||
let req = ChatCompletionRequest {
|
||||
model: model.clone(),
|
||||
messages: messages.clone(),
|
||||
stream: Some(false),
|
||||
max_tokens: None,
|
||||
temperature,
|
||||
tools: if max_rounds > 0 {
|
||||
Some(bifrost_tools.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
|
||||
let response = server.bifrost.chat_completion(req).await?;
|
||||
|
||||
if response.tool_calls.is_empty() || tool_round >= max_rounds {
|
||||
// Text response (or hit max rounds) — this is the final output
|
||||
final_content = response.content.clone();
|
||||
|
||||
// If we hit max rounds with pending tool calls, add a note
|
||||
if !response.tool_calls.is_empty() && tool_round >= max_rounds {
|
||||
let note =
|
||||
"\n\n[Max tool rounds reached — continuing with text response]";
|
||||
let _ = tx.send(Ok(BackendEvent::Token(note.to_string()))).await;
|
||||
}
|
||||
|
||||
// Stream the final content in chunks
|
||||
let chars: Vec<char> = final_content.chars().collect();
|
||||
for chunk in chars.chunks(10) {
|
||||
let s: String = chunk.iter().collect();
|
||||
if tx.send(Ok(BackendEvent::Token(s))).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
|
||||
|
||||
tool_round += 1;
|
||||
|
||||
// Tell the UI we're executing tools
|
||||
let tool_names: Vec<&str> =
|
||||
response.tool_calls.iter().map(|tc| tc.name.as_str()).collect();
|
||||
let announce = format!(
|
||||
"\n🔧 Round {} — executing: {}\n",
|
||||
tool_round,
|
||||
tool_names.join(", ")
|
||||
);
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::Token(announce)))
|
||||
.await;
|
||||
|
||||
// Add the assistant's tool-call message to the Bifrost conversation
|
||||
let call_text = serde_json::json!({
|
||||
"tool_calls": response.tool_calls.iter().map(|tc| {
|
||||
serde_json::json!({"id": tc.id, "name": tc.name, "arguments": tc.arguments})
|
||||
}).collect::<Vec<_>>()
|
||||
}).to_string();
|
||||
messages.push(BifrostMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: call_text,
|
||||
});
|
||||
|
||||
// Execute each tool and stream results back
|
||||
for tc in &response.tool_calls {
|
||||
let input_str = tc.arguments.to_string();
|
||||
let result = crate::core::tools::execute_tool(&tc.name, &input_str).await;
|
||||
|
||||
let output = if result.is_error {
|
||||
format!("Error: {}", result.output)
|
||||
} else {
|
||||
result.output
|
||||
};
|
||||
|
||||
let status = if result.is_error { "❌" } else { "✅" };
|
||||
let result_line = format!("{} **{}**: {} char(s)\n", status, tc.name, output.len());
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::Token(result_line)))
|
||||
.await;
|
||||
|
||||
// Add tool result to bifrost messages for next loop iteration
|
||||
messages.push(BifrostMessage {
|
||||
role: "tool".to_string(),
|
||||
content: output,
|
||||
});
|
||||
}
|
||||
|
||||
// Continue loop — model will see tool results and respond
|
||||
}
|
||||
|
||||
// ── Post-turn processing (unchanged) ───────────────────────
|
||||
server.sessions.add_message(
|
||||
&conversation_id,
|
||||
ConversationMessage::assistant_text(&content),
|
||||
ConversationMessage::assistant_text(&final_content),
|
||||
)?;
|
||||
|
||||
let events = {
|
||||
|
|
@ -171,20 +261,55 @@ async fn run_turn(
|
|||
.sessions
|
||||
.get(&conversation_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
|
||||
server.consciousness.on_response(&*session, &content).await?
|
||||
server
|
||||
.consciousness
|
||||
.on_response(&*session, &final_content)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Inject surfacing events back into the session as system messages
|
||||
for event in &events {
|
||||
if let ConsciousnessEvent::Surfacing {
|
||||
source,
|
||||
content,
|
||||
priority,
|
||||
} = event
|
||||
{
|
||||
let msg = crate::core::session::ConversationMessage {
|
||||
role: crate::core::session::MessageRole::System,
|
||||
blocks: vec![crate::core::session::ContentBlock::Text {
|
||||
text: format!(
|
||||
"[surfacing: {}] {} — {}",
|
||||
source, content, priority
|
||||
),
|
||||
}],
|
||||
usage: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let _ = server.sessions.add_message(&conversation_id, msg);
|
||||
}
|
||||
}
|
||||
|
||||
for event in events {
|
||||
let be = match event {
|
||||
ConsciousnessEvent::Surfacing { source, content, priority } => BackendEvent::Surfacing {
|
||||
source: source.to_string(),
|
||||
let be = match &event {
|
||||
ConsciousnessEvent::Surfacing {
|
||||
source,
|
||||
content,
|
||||
priority,
|
||||
} => BackendEvent::Surfacing {
|
||||
source: source.to_string(),
|
||||
content: content.to_string(),
|
||||
priority: priority.to_string(),
|
||||
},
|
||||
ConsciousnessEvent::Reflection { content } => BackendEvent::Reflection(content),
|
||||
ConsciousnessEvent::Archivist { synthesis, pressure } => BackendEvent::Archivist {
|
||||
ConsciousnessEvent::Reflection { content } => {
|
||||
BackendEvent::Reflection(content.clone())
|
||||
}
|
||||
ConsciousnessEvent::Archivist {
|
||||
synthesis,
|
||||
pressure,
|
||||
} => BackendEvent::Archivist {
|
||||
synthesis: synthesis.clone(),
|
||||
pressure: *pressure,
|
||||
},
|
||||
};
|
||||
if tx.send(Ok(be)).await.is_err() {
|
||||
|
|
@ -193,7 +318,9 @@ async fn run_turn(
|
|||
}
|
||||
|
||||
if let Some(mut session) = server.sessions.get_mut(&conversation_id) {
|
||||
let pressure = server.consciousness.calculate_pressure(&session.messages);
|
||||
let pressure = server
|
||||
.consciousness
|
||||
.calculate_pressure(&session.messages);
|
||||
session.context_pressure = pressure;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -160,10 +160,12 @@ impl MemoryRepo {
|
|||
let repo = git2::Repository::init(mem_path)
|
||||
.context("initializing git repository for memory")?;
|
||||
|
||||
// Set user config for commits
|
||||
let mut config = repo.config().context("opening repo config")?;
|
||||
config.set_str("user.name", &self.agent_id)?;
|
||||
config.set_str("user.email", &format!("{}@souveraine.local", self.agent_id))?;
|
||||
// Set user config for commits (scoped to drop before .await)
|
||||
{
|
||||
let mut config = repo.config().context("opening repo config")?;
|
||||
config.set_str("user.name", &self.agent_id)?;
|
||||
config.set_str("user.email", &format!("{}@souveraine.local", self.agent_id))?;
|
||||
}
|
||||
|
||||
// Write initial placeholder files with frontmatter
|
||||
let persona_content = render_frontmatter(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,16 @@
|
|||
//! Reflection Engine — N+25 phenomenological witness.
|
||||
//!
|
||||
//! Per the consciousness substrate philosophy, the harness does NOT force
|
||||
//! automatic events. It is a nervous system: it warns, surfaces pressure,
|
||||
//! and makes state available — but the agent decides whether and when to act.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - State tracking (message count, context pressure)
|
||||
//! - A query interface the agent can call when it wants to reflect
|
||||
//! - Warning signals when thresholds are approaching
|
||||
//!
|
||||
//! No events are emitted autonomously.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use anyhow::Result;
|
||||
|
|
@ -5,6 +18,8 @@ use anyhow::Result;
|
|||
use crate::core::config::ConsciousnessConfig;
|
||||
|
||||
/// Reflection Engine — N+25 phenomenological witness. Stub.
|
||||
///
|
||||
/// The harness tracks state so the agent can query it. No automatic events.
|
||||
pub struct ReflectionEngine {
|
||||
#[allow(dead_code)]
|
||||
config: Arc<RwLock<ConsciousnessConfig>>,
|
||||
|
|
|
|||
200
src/ui/chat.rs
200
src/ui/chat.rs
|
|
@ -25,10 +25,10 @@ use ratatui::{
|
|||
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::{mpsc, oneshot, RwLock};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::bridge::bifrost::BifrostClient;
|
||||
use crate::core::config::ConsciousnessConfig;
|
||||
use crate::ui::markdown;
|
||||
|
||||
|
|
@ -68,6 +68,8 @@ pub struct ChatState {
|
|||
pub tick: u64,
|
||||
/// When the current turn started (for spinner animation).
|
||||
pub turn_started: Option<Instant>,
|
||||
/// Receiver for `/model` listing results from async Bifrost call.
|
||||
pub model_rx: Option<oneshot::Receiver<String>>,
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
|
|
@ -120,15 +122,45 @@ impl ChatState {
|
|||
cockpit_log: Vec::new(),
|
||||
tick: 0,
|
||||
turn_started: None,
|
||||
model_rx: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Submit the current input as a user message and start a turn.
|
||||
pub fn submit(&mut self) {
|
||||
const HELP_TEXT: &'static str = "Available commands:
|
||||
/help Show this help
|
||||
/clear Clear chat history
|
||||
/model List available models
|
||||
/model <name> Set the active model
|
||||
!<command> Run a shell command (Linux/macOS)
|
||||
|
||||
Use Tab to toggle the cockpit pane.";
|
||||
|
||||
/// Submit the current input. Returns `true` if the input was handled
|
||||
/// (slash command, bang command, or sent to backend).
|
||||
pub fn submit(&mut self) -> bool {
|
||||
if self.busy || self.input.trim().is_empty() {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let text = std::mem::take(&mut self.input);
|
||||
|
||||
let trimmed = self.input.trim().to_string();
|
||||
self.input.clear();
|
||||
|
||||
// Slash commands
|
||||
if trimmed.starts_with('/') {
|
||||
return self.handle_slash_command(&trimmed);
|
||||
}
|
||||
|
||||
// Bang commands: !<cmd>
|
||||
if trimmed.starts_with('!') {
|
||||
let cmd = trimmed[1..].trim();
|
||||
if !cmd.is_empty() {
|
||||
self.handle_bang_command(cmd);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normal chat message
|
||||
let text = trimmed;
|
||||
let ts = Instant::now();
|
||||
self.messages.push(ChatMessage::User { text: text.clone(), ts });
|
||||
self.messages.push(ChatMessage::Assistant {
|
||||
|
|
@ -171,11 +203,165 @@ impl ChatState {
|
|||
}
|
||||
let _ = tx.send(BackendEvent::Done).await;
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_slash_command(&mut self, input: &str) -> bool {
|
||||
let trimmed = input.trim();
|
||||
|
||||
if trimmed == "/help" {
|
||||
self.system_message(Self::HELP_TEXT.to_string());
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed == "/clear" {
|
||||
self.messages.clear();
|
||||
self.system_message("Chat cleared.".to_string());
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("/model") {
|
||||
return self.handle_model_command(trimmed);
|
||||
}
|
||||
|
||||
// Unknown command
|
||||
let cmd = trimmed.split_whitespace().next().unwrap_or(trimmed);
|
||||
self.system_message(format!(
|
||||
"Unknown command: {}\nType /help for available commands.",
|
||||
cmd
|
||||
));
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_model_command(&mut self, input: &str) -> bool {
|
||||
let rest = input.strip_prefix("/model").unwrap_or("").trim();
|
||||
|
||||
// /model <name> — set model (synchronous, fast)
|
||||
if !rest.is_empty() && !rest.starts_with('-') {
|
||||
let model_name = rest.to_string();
|
||||
let cfg_path = std::env::current_dir()
|
||||
.map(|d| d.join("souveraine.toml"))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("souveraine.toml"));
|
||||
|
||||
match ConsciousnessConfig::load(&cfg_path) {
|
||||
Ok(mut cfg) => {
|
||||
cfg.bifrost.primary_model = model_name.clone();
|
||||
match cfg.save(&cfg_path) {
|
||||
Ok(()) => self.system_message(format!("Set model to: {}", model_name)),
|
||||
Err(e) => self.error_message(format!("Failed to save config: {}", e)),
|
||||
}
|
||||
}
|
||||
Err(e) => self.error_message(format!("Failed to load config: {}", e)),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// /model — list models (async, uses oneshot to get result back)
|
||||
self.system_message("Fetching models from Bifrost…".to_string());
|
||||
|
||||
let cfg_path = std::env::current_dir()
|
||||
.map(|d| d.join("souveraine.toml"))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("souveraine.toml"));
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.model_rx = Some(rx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = match ConsciousnessConfig::load(&cfg_path) {
|
||||
Ok(cfg) => {
|
||||
let bifrost = BifrostClient::new(
|
||||
&cfg.bifrost.base_url,
|
||||
&cfg.bifrost.api_key,
|
||||
&cfg.bifrost.virtual_key,
|
||||
&cfg.bifrost.primary_model,
|
||||
);
|
||||
let bifrost_models = bifrost.list_models().await.unwrap_or_default();
|
||||
let mut all_models = bifrost_models.clone();
|
||||
for name in cfg.models.keys() {
|
||||
if !all_models.contains(name) {
|
||||
all_models.push(name.clone());
|
||||
}
|
||||
}
|
||||
let mut text = format!(
|
||||
"Selected: {}\nAvailable ({}):\n",
|
||||
cfg.bifrost.primary_model,
|
||||
all_models.len()
|
||||
);
|
||||
for m in &all_models {
|
||||
let marker = if bifrost_models.contains(&m) { "⚡" } else { "⚙" };
|
||||
text.push_str(&format!(" {} {}\n", marker, m));
|
||||
}
|
||||
text
|
||||
}
|
||||
Err(e) => format!("✕ Failed to load config: {}", e),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_bang_command(&mut self, cmd: &str) {
|
||||
let output = std::process::Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let mut result = String::new();
|
||||
if !stdout.is_empty() {
|
||||
result.push_str(stdout.trim());
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(stderr.trim());
|
||||
}
|
||||
if result.is_empty() {
|
||||
result = format!("[exit code {}]", out.status.code().unwrap_or(-1));
|
||||
}
|
||||
self.system_message(format!("$ {}\n{}", cmd, result));
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message(format!("Shell command failed: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a system message for display (from slash commands, etc.)
|
||||
pub fn system_message(&mut self, text: String) {
|
||||
self.messages.push(ChatMessage::System {
|
||||
text,
|
||||
ts: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Push an error message for display
|
||||
pub fn error_message(&mut self, text: String) {
|
||||
self.messages.push(ChatMessage::System {
|
||||
text: format!("✕ {}", text),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Drain pending events from the active turn channel (non-blocking).
|
||||
/// Call once per UI tick.
|
||||
pub fn drain_events(&mut self) {
|
||||
// Check for /model listing result
|
||||
if let Some(rx) = self.model_rx.as_mut() {
|
||||
if let Ok(result) = rx.try_recv() {
|
||||
self.messages.push(ChatMessage::System {
|
||||
text: result,
|
||||
ts: Instant::now(),
|
||||
});
|
||||
self.model_rx = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Two-phase to avoid double-borrowing self: drain into a Vec, then process.
|
||||
let mut drained: Vec<BackendEvent> = Vec::new();
|
||||
let mut closed = false;
|
||||
|
|
@ -666,7 +852,7 @@ fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
|
|||
let cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" };
|
||||
let footer = Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" Esc menu · Enter send · ↑↓ scroll · {} ", cockpit_hint),
|
||||
format!(" Esc menu · Enter send · ↑↓ scroll · !cmd bash · {} ", cockpit_hint),
|
||||
Style::default().fg(STATUS_GRAY),
|
||||
),
|
||||
Span::raw("│ "),
|
||||
|
|
|
|||
Loading…
Reference in a new issue