Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion THIRD_PARTY_NOTICES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1725,7 +1725,6 @@ Apache License 2.0

The following component(s) are licensed under Apache License 2.0:
- rustls-platform-verifier 0.7.0 <https://github.com/rustls/rustls-platform-verifier>
- ureq 2.12.1 <https://github.com/algesten/ureq>

Apache License
Version 2.0, January 2004
Expand Down Expand Up @@ -5642,6 +5641,7 @@ The following component(s) are licensed under Apache License 2.0:
- unicode-truncate 2.0.1 <https://github.com/Aetf/unicode-truncate>
- unicode-width 0.1.14 <https://github.com/unicode-rs/unicode-width>
- unicode-width 0.2.0 <https://github.com/unicode-rs/unicode-width>
- ureq 2.12.1 <https://github.com/algesten/ureq>
- url 2.5.8 <https://github.com/servo/rust-url>
- uuid 1.23.3 <https://github.com/uuid-rs/uuid>
- wasi 0.11.1+wasi-snapshot-preview1 <https://github.com/bytecodealliance/wasi>
Expand Down
2 changes: 1 addition & 1 deletion crates/rocm-dash-tui/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ pub fn parse_host_port(base_url: &str) -> Option<(String, u16)> {
/// brackets from a bracketed IPv6 authority. Matches `localhost`
/// (case-insensitive), the IPv6 loopback (`::1`), and any `127.0.0.0/8` IPv4
/// address.
fn is_loopback_host(host: &str) -> bool {
pub(crate) fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host == "::1" || host.starts_with("127.")
}

Expand Down
209 changes: 196 additions & 13 deletions crates/rocm-dash-tui/src/ui/tabs/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};

use rocm_dash_core::metrics::InstanceStatus;

use crate::app::{AppState, ChatConsent, ChatRole};
use crate::ui::panel::{self, BoxRole};
use crate::ui::theme::Theme;
Expand Down Expand Up @@ -273,22 +275,68 @@ pub fn transcript_lines<'a>(state: &'a AppState, theme: &Theme) -> Vec<Line<'a>>

/// Render the single-row input line. While focused, a caret glyph trails the
/// buffer; otherwise a muted hint invites focus.
/// Parse the TCP port out of a chat endpoint base URL
/// (`http://127.0.0.1:8000/v1` → `8000`). `None` when no explicit port is
/// present (e.g. a bare host or a remote gateway URL).
fn port_from_base_url(base_url: &str) -> Option<u16> {
let after_scheme = base_url.split("://").nth(1).unwrap_or(base_url);
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
authority.rsplit(':').next()?.parse().ok()
}

/// Parse the host out of a chat endpoint base URL
/// (`http://127.0.0.1:8000/v1` → `127.0.0.1`, `https://gateway.example.com` →
/// `gateway.example.com`). `None` only for a malformed/empty authority.
fn host_from_base_url(base_url: &str) -> Option<String> {
let after_scheme = base_url.split("://").nth(1).unwrap_or(base_url);
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
let host = authority
.rsplit_once(':')
.map_or(authority, |(host, _)| host);
(!host.is_empty()).then(|| host.to_owned())
}

/// If the local instance backing the chat endpoint is not yet answering,
/// explain why so an in-flight request reads as "the model is still coming up"
/// rather than an unexplained spinner. Matched to a daemon-surfaced instance by
/// host + port (daemon-tracked instances are always co-located, so only a
/// loopback endpoint can be one of them — a remote gateway must never
/// false-match just because it happens to share a port number with a local
/// instance); returns `None` when the endpoint looks live, is remote, or
/// isn't a managed instance we can see (caller then shows the plain spinner).
fn chat_backend_wait_reason(state: &AppState) -> Option<String> {
let base_url = &state.chat_llm.as_ref()?.base_url;
let port = port_from_base_url(base_url)?;
let host = host_from_base_url(base_url)?;
if !crate::llm::is_loopback_host(&host) {
return None;
}
let inst = state.instances.values().find(|i| i.port == Some(port))?;
match inst.status {
InstanceStatus::Running => None,
InstanceStatus::Starting => Some("the model is still starting up — hang tight".to_owned()),
InstanceStatus::Stopped => {
Some("the endpoint has stopped — restart the service to chat".to_owned())
}
InstanceStatus::Error => {
Some("the endpoint reported an error — check the service logs".to_owned())
}
InstanceStatus::Unknown => None,
}
}

fn draw_input(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) {
// While a request is in flight, show a spinner and suppress the caret —
// input is disabled until the reply or error turn lands.
// input is disabled until the reply or error turn lands. When the backing
// endpoint isn't ready yet, say so instead of an unexplained spinner.
if state.chat_sending {
let inner = panel::bento(
f,
area,
Some("Message (sending…)"),
BoxRole::Warning,
false,
theme,
);
let line = Line::from(Span::styled(
"⠿ waiting for the agent…",
Style::default().fg(theme.muted),
));
let reason = chat_backend_wait_reason(state);
let (title, body): (&str, String) = match &reason {
Some(reason) => ("Message (waiting on the model)", format!("⠿ {reason}")),
None => ("Message (sending…)", "⠿ waiting for the agent…".to_owned()),
};
let inner = panel::bento(f, area, Some(title), BoxRole::Warning, false, theme);
let line = Line::from(Span::styled(body, Style::default().fg(theme.muted)));
f.render_widget(Paragraph::new(line), inner);
return;
}
Expand Down Expand Up @@ -439,4 +487,139 @@ mod tests {
assert!(out.contains("use now"));
assert!(out.contains("use & save"));
}

#[test]
fn port_from_base_url_parses_local_endpoints() {
assert_eq!(port_from_base_url("http://127.0.0.1:8000/v1"), Some(8000));
assert_eq!(port_from_base_url("http://localhost:11435"), Some(11435));
// Remote gateway (no explicit port) and a bare host yield None.
assert_eq!(port_from_base_url("https://gateway.example.com/v1"), None);
assert_eq!(port_from_base_url("http://127.0.0.1"), None);
}

#[test]
fn host_from_base_url_parses_local_and_remote_endpoints() {
assert_eq!(
host_from_base_url("http://127.0.0.1:8000/v1"),
Some("127.0.0.1".to_owned())
);
assert_eq!(
host_from_base_url("http://localhost:11435"),
Some("localhost".to_owned())
);
assert_eq!(
host_from_base_url("https://gateway.example.com:8000/v1"),
Some("gateway.example.com".to_owned())
);
assert_eq!(
host_from_base_url("http://127.0.0.1"),
Some("127.0.0.1".to_owned())
);
}

fn accepted_chat_at_port(port: u16) -> AppState {
let mut s = AppState::new("t".into(), "default-dark".into());
s.active_tab = crate::app::ActiveTab::Chat;
let llm = crate::llm::LlmConfig {
base_url: format!("http://127.0.0.1:{port}/v1"),
model: "m".into(),
api_key: None,
auth_header: None,
};
s.set_chat_config(Some(llm), true);
s
}

fn set_backing_instance(s: &mut AppState, port: u16, status: InstanceStatus) {
use rocm_dash_core::metrics::Instance;
s.instances.clear();
s.instances.insert(
"svc".into(),
Instance {
container_id: "svc".into(),
port: Some(port),
status,
..Default::default()
},
);
}

#[test]
fn backend_wait_reason_reflects_instance_status() {
let mut s = accepted_chat_at_port(8000);
// No instance visible → no endpoint-specific reason (falls back to spinner).
assert_eq!(chat_backend_wait_reason(&s), None);

set_backing_instance(&mut s, 8000, InstanceStatus::Starting);
assert!(
chat_backend_wait_reason(&s)
.unwrap()
.contains("starting up")
);
set_backing_instance(&mut s, 8000, InstanceStatus::Stopped);
assert!(chat_backend_wait_reason(&s).unwrap().contains("stopped"));
set_backing_instance(&mut s, 8000, InstanceStatus::Error);
assert!(chat_backend_wait_reason(&s).unwrap().contains("error"));
// A live instance needs no explanation.
set_backing_instance(&mut s, 8000, InstanceStatus::Running);
assert_eq!(chat_backend_wait_reason(&s), None);
// A mismatched port is not our endpoint.
set_backing_instance(&mut s, 9999, InstanceStatus::Starting);
assert_eq!(chat_backend_wait_reason(&s), None);
}

#[test]
fn backend_wait_reason_ignores_remote_endpoint_sharing_a_local_port_number() {
// A remote gateway can happen to use the same port number (e.g. 8000)
// as a local daemon-tracked instance. Matching by port alone would
// wrongly borrow that unrelated instance's status; matching requires
// the endpoint's host to be loopback, so a remote host must fall back
// to the generic spinner even when a same-port local instance exists.
let mut s = AppState::new("t".into(), "default-dark".into());
s.active_tab = crate::app::ActiveTab::Chat;
let llm = crate::llm::LlmConfig {
base_url: "https://gateway.example.com:8000/v1".into(),
model: "m".into(),
api_key: None,
auth_header: None,
};
s.set_chat_config(Some(llm), true);
set_backing_instance(&mut s, 8000, InstanceStatus::Starting);
assert_eq!(chat_backend_wait_reason(&s), None);
}

#[test]
fn sending_input_surfaces_startup_reason_not_generic_spinner() {
let mut s = accepted_chat_at_port(8000);
s.chat_sending = true;
set_backing_instance(&mut s, 8000, InstanceStatus::Starting);
let out = render_str(&s);
assert!(
out.contains("starting up"),
"shows the specific startup reason"
);
assert!(
!out.contains("waiting for the agent"),
"not the generic spinner"
);
}

#[test]
fn sending_input_falls_back_to_generic_spinner_for_remote_endpoint() {
let mut s = AppState::new("t".into(), "default-dark".into());
s.active_tab = crate::app::ActiveTab::Chat;
let llm = crate::llm::LlmConfig {
base_url: "https://gateway.example.com/v1".into(),
model: "m".into(),
api_key: None,
auth_header: None,
};
s.set_chat_config(Some(llm), true);
s.chat_sending = true;
let out = render_str(&s);
assert!(
out.contains("waiting for the agent"),
"generic spinner remains"
);
}
}