ocs 0.1.5

A session picker for OpenCode
use super::*;

pub(crate) struct SessionItem {
  pub(crate) cost: Option<String>,
  pub(crate) database: PathBuf,
  pub(crate) id: String,
  pub(crate) model: Option<String>,
  pub(crate) preview: OnceLock<String>,
  pub(crate) project: Option<String>,
  pub(crate) search_text: String,
  pub(crate) title: String,
  pub(crate) tokens: Option<String>,
  pub(crate) updated: String,
}

impl SessionItem {
  fn format_cost(cost: f64) -> Option<String> {
    if cost <= 0.0 {
      None
    } else if cost < 0.01 {
      Some("<$0.01".into())
    } else {
      Some(format!("${cost:.2}"))
    }
  }

  fn format_tokens(tokens: u64) -> Option<String> {
    let tokens = if tokens == 0 {
      return None;
    } else if tokens >= 1_000_000 {
      format!("{}.{}m", tokens / 1_000_000, tokens % 1_000_000 / 100_000)
    } else if tokens >= 1_000 {
      format!("{}.{}k", tokens / 1_000, tokens % 1_000 / 100)
    } else {
      tokens.to_string()
    };

    Some(tokens)
  }

  pub(crate) fn new(
    storage: &Storage,
    session: &Session,
    show_project: bool,
  ) -> Self {
    let project = Path::new(&session.directory)
      .file_name()
      .and_then(|name| name.to_str())
      .unwrap_or(&session.directory);

    let now = SystemTime::now()
      .duration_since(UNIX_EPOCH)
      .unwrap_or_default()
      .as_millis()
      .try_into()
      .unwrap_or(u64::MAX);

    Self {
      cost: Self::format_cost(session.cost),
      database: storage.database.clone(),
      id: session.id.clone(),
      model: session.model.clone(),
      preview: OnceLock::new(),
      project: show_project.then(|| project.into()),
      search_text: session.search_text(),
      title: session.title.clone(),
      tokens: Self::format_tokens(session.tokens),
      updated: session.time.relative_updated(now),
    }
  }
}

impl SkimItem for SessionItem {
  fn display(&self, _context: DisplayContext) -> Line<'_> {
    let muted = Style::new().fg(DARK_GRAY);

    let mut metadata = Vec::new();

    let mut push_metadata = |value: &str, style: Style| {
      if !metadata.is_empty() {
        metadata.push(Span::styled(" ยท ", muted));
      }

      metadata.push(Span::styled(value.to_owned(), style));
    };

    if let Some(project) = &self.project {
      push_metadata(project, muted);
    }

    push_metadata(&self.updated, muted);

    if let Some(model) = &self.model {
      push_metadata(model, muted);
    }

    if let Some(cost) = &self.cost {
      push_metadata(cost, muted);
    }

    if let Some(tokens) = &self.tokens {
      push_metadata(tokens, muted);
    }

    let mut line = vec![Span::raw(self.title.as_str()), Span::raw("  ")];

    line.extend(metadata);

    Line::from(line)
  }

  fn output(&self) -> Cow<'_, str> {
    Cow::Borrowed(&self.id)
  }

  fn preview(&self, _context: PreviewContext) -> ItemPreview {
    ItemPreview::AnsiText(
      self
        .preview
        .get_or_init(|| {
          Storage::new(self.database.clone())
            .and_then(|storage| storage.get_session(&self.id))
            .map_or_else(
              |error| format!("could not load preview: {error}"),
              |session| session.preview(),
            )
        })
        .clone(),
    )
  }

  fn text(&self) -> Cow<'_, str> {
    Cow::Borrowed(&self.search_text)
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  fn session() -> Session {
    Session {
      cost: 0.125,
      directory: "/tmp/bar".into(),
      id: "ses_foo".into(),
      messages: vec![Message {
        id: "msg_foo".into(),
        role: "user".into(),
        session_id: "ses_foo".into(),
        text: "first line\nsecond line".into(),
        time: Time::default(),
      }],
      model: Some("model-foo".into()),
      time: Time {
        created: u64::MAX,
        updated: u64::MAX,
      },
      title: "foo".into(),
      tokens: 12_345,
    }
  }

  #[test]
  fn project_name_visibility() {
    let storage = Storage::new("/tmp/foo.db".into()).unwrap();
    let session = session();

    let item = SessionItem::new(&storage, &session, true);

    let display = item.display(DisplayContext::default());

    assert_eq!(display.spans[0].content, "foo");
    assert_eq!(display.spans[1].content, "  ");
    assert_eq!(display.spans[2].content, "bar");
    assert_eq!(display.spans[2].style.fg, Some(DARK_GRAY));
    assert_eq!(display.spans[3].content, " ยท ");
    assert_eq!(display.spans[3].style.fg, Some(DARK_GRAY));

    let item = SessionItem::new(&storage, &session, false);

    let display = item.display(DisplayContext::default());

    assert_eq!(display.spans[0].content, "foo");
  }

  #[test]
  fn renders_metadata() {
    let item = SessionItem::new(
      &Storage::new("/tmp/foo.db".into()).unwrap(),
      &session(),
      false,
    );

    let display = item.display(DisplayContext {
      container_width: 100,
      ..DisplayContext::default()
    });

    let text = display
      .spans
      .iter()
      .map(|span| span.content.as_ref())
      .collect::<String>();

    assert!(text.contains("now"));
    assert!(text.contains("model-foo"));
    assert!(text.contains("$0.12"));
    assert!(text.contains("12.3k"));
  }
}