use std::collections::HashMap;
use std::thread;
use crate::app::App;
use crate::app::toast::Toast;
use crate::copilot::{
self, CheckStatus, CopilotClient, CopilotEvent, InlineCompletionRaw, SignInInitiate,
};
use crate::editor::{Cursor, RequestId, Suggestion, SuggestionState};
use crate::event::AppEvent;
use crate::lsp::path_to_uri;
use crate::vlog;
fn open_url_in_browser(url: &str) -> bool {
use std::process::{Command, Stdio};
#[cfg(target_os = "macos")]
let cmd = Command::new("open")
.arg(url)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
#[cfg(all(unix, not(target_os = "macos")))]
let cmd = Command::new("xdg-open")
.arg(url)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
#[cfg(target_os = "windows")]
let cmd = Command::new("cmd")
.args(["/C", "start", "", url])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
#[cfg(not(any(target_os = "macos", unix, target_os = "windows")))]
let cmd: std::io::Result<std::process::Child> = Err(std::io::Error::new(
std::io::ErrorKind::Other,
"unsupported platform",
));
cmd.is_ok()
}
fn strip_already_typed(
raw: &InlineCompletionRaw,
anchor: Cursor,
lines: &[String],
) -> Option<String> {
let Some(range) = raw.range else {
return Some(raw.text.clone());
};
if range.start_line != range.end_line
|| range.end_line as usize != anchor.row
|| range.end_character as usize != anchor.col
|| (range.start_character as usize) > anchor.col
{
return Some(raw.text.clone());
}
let line = lines.get(anchor.row)?;
let start = range.start_character as usize;
let end = anchor.col;
let prefix: String = line.chars().skip(start).take(end - start).collect();
if raw.text.starts_with(&prefix) {
Some(raw.text[prefix.len()..].to_string())
} else {
None
}
}
#[derive(Debug, Clone, Copy)]
pub enum CopilotRequestKind {
InlineCompletion,
CheckStatus,
SignInInitiate,
SignInConfirm,
SignOut,
}
pub struct CopilotSubcommand {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub description: &'static str,
pub handler: fn(&mut App),
}
pub const COPILOT_SUBCOMMANDS: &[CopilotSubcommand] = &[
CopilotSubcommand {
name: "status",
aliases: &[],
description: "show Copilot auth state",
handler: App::copilot_status_toast,
},
CopilotSubcommand {
name: "signin",
aliases: &["login"],
description: "start device-flow sign-in",
handler: App::copilot_signin,
},
CopilotSubcommand {
name: "signout",
aliases: &["logout"],
description: "sign out of Copilot",
handler: App::copilot_signout,
},
CopilotSubcommand {
name: "code",
aliases: &[],
description: "re-show signin modal + re-copy code",
handler: App::copilot_recopy_code,
},
];
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum CopilotAuthState {
#[default]
Unknown,
SignedIn {
user: Option<String>,
},
NotSignedIn,
NotAuthorized {
reason: Option<String>,
},
}
impl CopilotAuthState {
fn signed_in(&self) -> bool {
matches!(self, Self::SignedIn { .. })
}
}
#[derive(Default)]
pub struct CopilotPending {
inner: HashMap<u64, CopilotRequestKind>,
}
impl CopilotPending {
pub fn insert(&mut self, id: u64, kind: CopilotRequestKind) {
self.inner.insert(id, kind);
}
pub fn take(&mut self, id: u64) -> Option<CopilotRequestKind> {
self.inner.remove(&id)
}
}
impl App {
pub fn spawn_copilot_if_needed(&mut self) {
if self.copilot.is_some() || self.copilot_spawning {
return;
}
self.copilot_spawning = true;
let root_uri = path_to_uri(&self.startup_cwd);
let event_tx = self.event_tx.clone();
let emit_tx = self.event_tx.clone();
thread::spawn(move || {
let emit: Box<dyn Fn(CopilotEvent) + Send + 'static> = Box::new(move |ev| {
let _ = emit_tx.send(AppEvent::Copilot(ev));
});
let result = CopilotClient::spawn(&root_uri, emit);
let _ = event_tx.send(AppEvent::CopilotReady { result });
});
}
pub fn handle_copilot_ready(&mut self, result: anyhow::Result<Option<CopilotClient>>) {
self.copilot_spawning = false;
match result {
Ok(Some(mut client)) => {
match client.check_status(true) {
Ok(id) => {
self.copilot_pending
.insert(id, CopilotRequestKind::CheckStatus);
}
Err(e) => vlog!("copilot checkStatus send failed: {e:#}"),
}
self.copilot = Some(client);
}
Ok(None) => {
}
Err(e) => {
vlog!("copilot spawn failed: {e:#}");
}
}
}
pub(super) fn copilot_needs_sync(&self) -> bool {
let Some(copilot) = &self.copilot else {
return false;
};
let Some(uri) = self.copilot_active_uri() else {
return false;
};
copilot.needs_sync(&uri, self.buffer.version)
}
pub(super) fn sync_buffer_to_copilot(&mut self, text: &str) {
let Some(uri) = self.copilot_active_uri() else {
return;
};
let language_id = self.copilot_active_language_id();
let version = self.buffer.version;
let Some(copilot) = self.copilot.as_mut() else {
return;
};
let result = if copilot.is_open(&uri) {
copilot.did_change(&uri, text, version)
} else {
copilot.did_open(&uri, &language_id, text, version)
};
if let Err(e) = result {
vlog!("copilot sync failed uri={uri}: {e:#}");
}
}
pub(super) fn update_inline_suggestion(&mut self) {
if self.copilot.is_none() || !self.copilot_auth.signed_in() {
self.inline_suggestion.dismiss();
return;
}
let cursor = self.buffer.cursor;
let row_len = self
.buffer
.lines
.get(cursor.row)
.map(|l| l.chars().count())
.unwrap_or(0);
if cursor.col != row_len {
self.inline_suggestion.dismiss();
return;
}
let Some(uri) = self.copilot_active_uri() else {
self.inline_suggestion.dismiss();
return;
};
self.inline_suggestion.dismiss();
if self.copilot_needs_sync() {
let text = self.buffer.lines.join("\n");
self.sync_buffer_to_copilot(&text);
}
let indent = self.indent_settings();
let Some(copilot) = self.copilot.as_mut() else {
return;
};
let id = match copilot.inline_completion(
&uri,
cursor.row as u32,
cursor.col as u32,
indent.width as u32,
!indent.use_tabs,
) {
Ok(id) => id,
Err(e) => {
vlog!("copilot inlineCompletion send failed: {e:#}");
return;
}
};
self.copilot_pending
.insert(id, CopilotRequestKind::InlineCompletion);
self.inline_suggestion = SuggestionState::Pending {
id: RequestId(id),
anchor: cursor,
};
}
pub fn handle_copilot_event(&mut self, ev: CopilotEvent) {
match ev {
CopilotEvent::Response { id, result, error } => {
let Some(kind) = self.copilot_pending.take(id) else {
return;
};
match kind {
CopilotRequestKind::InlineCompletion => {
self.handle_copilot_inline_completion(id, result, error);
}
CopilotRequestKind::CheckStatus => {
self.handle_copilot_check_status(result, error);
}
CopilotRequestKind::SignInInitiate => {
self.handle_copilot_sign_in_initiate(result, error);
}
CopilotRequestKind::SignInConfirm => {
self.handle_copilot_sign_in_confirm(result, error);
}
CopilotRequestKind::SignOut => {
self.handle_copilot_sign_out(result, error);
}
}
}
CopilotEvent::Error { message } => {
vlog!("copilot client dropped: {message}");
self.copilot = None;
self.copilot_pending = CopilotPending::default();
self.inline_suggestion.dismiss();
}
}
}
pub(super) fn run_copilot_command(&mut self, sub: &str) {
let sub = sub.trim();
let name = if sub.is_empty() { "status" } else { sub };
match COPILOT_SUBCOMMANDS
.iter()
.find(|s| s.name == name || s.aliases.contains(&name))
{
Some(cmd) => (cmd.handler)(self),
None => {
self.push_toast(Toast::error(format!("unknown copilot subcommand: {name}")));
}
}
}
fn copilot_recopy_code(&mut self) {
let Some((code, url)) = self.copilot_pending_code.clone() else {
self.push_toast(Toast::info(
"Copilot: no signin in flight — run :copilot signin first".to_string(),
));
return;
};
self.sync_text_to_clipboard(&code);
self.prompt.open_copilot_signin(code, url);
}
fn copilot_status_toast(&mut self) {
let msg = match &self.copilot_auth {
CopilotAuthState::Unknown => match &self.copilot {
None => "Copilot: not running (binary missing?)".to_string(),
Some(_) => "Copilot: checking status...".to_string(),
},
CopilotAuthState::SignedIn { user } => format!(
"Copilot: signed in as {}",
user.as_deref().unwrap_or("(unknown user)")
),
CopilotAuthState::NotSignedIn => {
"Copilot: not signed in — run :copilot signin".to_string()
}
CopilotAuthState::NotAuthorized { reason } => format!(
"Copilot: not authorized ({})",
reason.as_deref().unwrap_or("no entitlement")
),
};
self.push_toast(Toast::info(msg));
}
fn copilot_signin(&mut self) {
if matches!(self.copilot_auth, CopilotAuthState::SignedIn { .. }) {
self.push_toast(Toast::info("Copilot: already signed in".to_string()));
return;
}
let Some(copilot) = self.copilot.as_mut() else {
self.push_toast(Toast::error(
"Copilot: server not running (install copilot-language-server)".to_string(),
));
return;
};
match copilot.sign_in_initiate() {
Ok(id) => {
self.copilot_pending
.insert(id, CopilotRequestKind::SignInInitiate);
}
Err(e) => {
vlog!("copilot signInInitiate send failed: {e:#}");
self.push_toast(Toast::error(format!(
"Copilot: signin failed to start ({e})"
)));
}
}
}
fn copilot_signout(&mut self) {
let Some(copilot) = self.copilot.as_mut() else {
self.push_toast(Toast::error("Copilot: server not running".to_string()));
return;
};
match copilot.sign_out() {
Ok(id) => {
self.copilot_pending.insert(id, CopilotRequestKind::SignOut);
}
Err(e) => {
vlog!("copilot signOut send failed: {e:#}");
self.push_toast(Toast::error(format!("Copilot: signout failed ({e})")));
}
}
}
fn handle_copilot_sign_in_initiate(
&mut self,
result: Option<serde_json::Value>,
error: Option<String>,
) {
if let Some(msg) = error {
vlog!("copilot signInInitiate error: {msg}");
self.push_toast(Toast::error(format!("Copilot signin: {msg}")));
return;
}
let parsed = result.as_ref().and_then(copilot::parse_sign_in_initiate);
match parsed {
Some(SignInInitiate::AlreadySignedIn { user }) => {
self.copilot_auth = CopilotAuthState::SignedIn { user };
self.push_toast(Toast::info("Copilot: already signed in".to_string()));
}
Some(SignInInitiate::PromptDeviceFlow {
user_code,
verification_uri,
}) => {
self.sync_text_to_clipboard(&user_code);
let _ = open_url_in_browser(&verification_uri);
self.copilot_pending_code = Some((user_code.clone(), verification_uri.clone()));
self.prompt
.open_copilot_signin(user_code.clone(), verification_uri);
if let Some(copilot) = self.copilot.as_mut() {
match copilot.sign_in_confirm(&user_code) {
Ok(id) => {
self.copilot_pending
.insert(id, CopilotRequestKind::SignInConfirm);
}
Err(e) => vlog!("copilot signInConfirm send failed: {e:#}"),
}
}
}
None => {
vlog!("copilot signInInitiate: unparseable result {result:?}");
self.push_toast(Toast::error(
"Copilot signin: unexpected response from server".to_string(),
));
}
}
}
fn handle_copilot_sign_in_confirm(
&mut self,
result: Option<serde_json::Value>,
error: Option<String>,
) {
self.copilot_pending_code = None;
if let Some(msg) = error {
vlog!("copilot signInConfirm error: {msg}");
self.push_toast(Toast::error(format!("Copilot signin: {msg}")));
return;
}
let status = result.as_ref().and_then(copilot::parse_check_status);
match status {
Some(CheckStatus::SignedIn { user }) => {
self.copilot_auth = CopilotAuthState::SignedIn { user: user.clone() };
if matches!(
self.prompt.state,
crate::prompt::Prompt::CopilotSignin { .. }
) {
self.prompt.state = crate::prompt::Prompt::None;
}
self.push_toast(Toast::info(format!(
"Copilot: signed in as {}",
user.as_deref().unwrap_or("(unknown user)")
)));
}
Some(CheckStatus::NotSignedIn) | None => {
self.push_toast(Toast::error(
"Copilot signin: not completed (timed out or rejected)".to_string(),
));
}
Some(CheckStatus::NotAuthorized { reason }) => {
self.copilot_auth = CopilotAuthState::NotAuthorized {
reason: reason.clone(),
};
self.push_toast(Toast::error(format!(
"Copilot signin: not authorized ({})",
reason.as_deref().unwrap_or("no entitlement")
)));
}
Some(CheckStatus::Other(s)) => {
vlog!("copilot signInConfirm unexpected status: {s}");
self.push_toast(Toast::error(format!("Copilot signin: {s}")));
}
}
}
fn handle_copilot_sign_out(
&mut self,
_result: Option<serde_json::Value>,
error: Option<String>,
) {
if let Some(msg) = error {
vlog!("copilot signOut error: {msg}");
self.push_toast(Toast::error(format!("Copilot signout: {msg}")));
return;
}
self.copilot_auth = CopilotAuthState::NotSignedIn;
self.inline_suggestion.dismiss();
self.push_toast(Toast::info("Copilot: signed out".to_string()));
}
fn sync_text_to_clipboard(&mut self, text: &str) {
if self.clipboard.is_none() {
self.clipboard = arboard::Clipboard::new().ok();
}
if let Some(cb) = self.clipboard.as_mut() {
let _ = cb.set_text(text.to_string());
}
}
fn handle_copilot_check_status(
&mut self,
result: Option<serde_json::Value>,
error: Option<String>,
) {
if let Some(msg) = error {
vlog!("copilot checkStatus error: {msg}");
self.copilot_auth = CopilotAuthState::Unknown;
return;
}
let status = result.as_ref().and_then(copilot::parse_check_status);
let new_state = match status {
Some(CheckStatus::SignedIn { user }) => CopilotAuthState::SignedIn { user },
Some(CheckStatus::NotSignedIn) => CopilotAuthState::NotSignedIn,
Some(CheckStatus::NotAuthorized { reason }) => {
CopilotAuthState::NotAuthorized { reason }
}
Some(CheckStatus::Other(s)) => {
vlog!("copilot checkStatus unrecognised status: {s}");
CopilotAuthState::Unknown
}
None => {
vlog!("copilot checkStatus: missing/unparseable result");
CopilotAuthState::Unknown
}
};
match &new_state {
CopilotAuthState::SignedIn { user } => {
vlog!("copilot signed in user={}", user.as_deref().unwrap_or("?"));
}
CopilotAuthState::NotSignedIn => {
vlog!("copilot not signed in");
}
CopilotAuthState::NotAuthorized { reason } => {
vlog!(
"copilot not authorized: {}",
reason.as_deref().unwrap_or("no entitlement")
);
}
CopilotAuthState::Unknown => {}
}
self.copilot_auth = new_state;
}
fn handle_copilot_inline_completion(
&mut self,
id: u64,
result: Option<serde_json::Value>,
error: Option<String>,
) {
if let Some(msg) = error {
vlog!("copilot inlineCompletion error id={id} {msg}");
self.maybe_dismiss_pending(id);
return;
}
let raw = match result.as_ref().and_then(copilot::parse_inline_completion) {
Some(r) => r,
None => {
self.maybe_dismiss_pending(id);
return;
}
};
vlog!(
"copilot inlineCompletion id={id} chars={} lines={}",
raw.text.chars().count(),
raw.text.matches('\n').count() + 1
);
let (matches, anchor) = match &self.inline_suggestion {
SuggestionState::Pending { id: pid, anchor } => (pid.0 == id, *anchor),
_ => (false, self.buffer.cursor),
};
if !matches || self.buffer.cursor != anchor {
return;
}
let suffix = strip_already_typed(&raw, anchor, &self.buffer.lines);
let Some(suffix) = suffix else {
self.inline_suggestion.dismiss();
return;
};
if suffix.is_empty() {
self.inline_suggestion.dismiss();
return;
}
self.inline_suggestion = SuggestionState::Showing {
id: RequestId(id),
suggestion: Suggestion {
text: suffix,
anchor,
},
};
}
fn maybe_dismiss_pending(&mut self, id: u64) {
if let SuggestionState::Pending { id: pid, .. } = &self.inline_suggestion
&& pid.0 == id
{
self.inline_suggestion.dismiss();
}
}
fn copilot_active_uri(&self) -> Option<String> {
self.buffer.path.as_ref().map(|p| path_to_uri(p))
}
fn copilot_active_language_id(&self) -> String {
self.buffer
.path
.as_deref()
.and_then(|p| self.config.languages.by_path(p))
.map(|spec| spec.name.clone())
.unwrap_or_else(|| "plaintext".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::copilot::ReplaceRange;
fn cur(row: usize, col: usize) -> Cursor {
Cursor { row, col }
}
fn raw(text: &str, range: Option<ReplaceRange>) -> InlineCompletionRaw {
InlineCompletionRaw {
text: text.to_string(),
range,
}
}
#[test]
fn strip_returns_text_verbatim_when_no_range() {
let r = raw("hello", None);
let lines = vec!["abc".to_string()];
assert_eq!(
strip_already_typed(&r, cur(0, 3), &lines).as_deref(),
Some("hello")
);
}
#[test]
fn strip_removes_already_typed_prefix() {
let r = raw(
"fn hello() {}",
Some(ReplaceRange {
start_line: 0,
start_character: 0,
end_line: 0,
end_character: 8,
}),
);
let lines = vec!["fn hello".to_string()];
assert_eq!(
strip_already_typed(&r, cur(0, 8), &lines).as_deref(),
Some("() {}")
);
}
#[test]
fn strip_returns_none_when_buffer_diverges_from_insert_text() {
let r = raw(
"let x = 1;",
Some(ReplaceRange {
start_line: 0,
start_character: 0,
end_line: 0,
end_character: 5,
}),
);
let lines = vec!["const".to_string()];
assert!(strip_already_typed(&r, cur(0, 5), &lines).is_none());
}
#[test]
fn strip_falls_back_to_verbatim_for_multi_line_ranges() {
let r = raw(
"foo",
Some(ReplaceRange {
start_line: 0,
start_character: 0,
end_line: 1,
end_character: 0,
}),
);
let lines = vec!["x".to_string(), "y".to_string()];
assert_eq!(
strip_already_typed(&r, cur(1, 0), &lines).as_deref(),
Some("foo")
);
}
#[test]
fn strip_falls_back_when_range_end_isnt_at_anchor() {
let r = raw(
"abcdef",
Some(ReplaceRange {
start_line: 0,
start_character: 0,
end_line: 0,
end_character: 3,
}),
);
let lines = vec!["xyz".to_string()];
assert_eq!(
strip_already_typed(&r, cur(0, 5), &lines).as_deref(),
Some("abcdef")
);
}
#[test]
fn strip_handles_empty_prefix() {
let r = raw(
"hello",
Some(ReplaceRange {
start_line: 0,
start_character: 5,
end_line: 0,
end_character: 5,
}),
);
let lines = vec!["abcde".to_string()];
assert_eq!(
strip_already_typed(&r, cur(0, 5), &lines).as_deref(),
Some("hello")
);
}
}