A calm, fast editor with a real terminal inside.

Luna is a macOS code editor built on Zed. It runs Ghostty's terminal engine, keeps your Claude Code and Codex limits in view, learns which completions you pick, and keeps language servers lean.

Windows version coming soon
luna main/ main
1use anyhow::{Context as _, Result, anyhow, bail};
2use chrono::{DateTime, Utc};
3use futures::AsyncReadExt as _;
4use http_client::{AsyncBody, HttpClient, Request, StatusCode};
5use serde::Deserialize;
6use std::sync::Arc;
7
8use crate::{RateLimited, UsageReport, UsageWindow};
9
10/// The endpoint Claude Code's `/usage` reads. It isn't a public API, so it may change.
11const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
12const OAUTH_BETA: &str = "oauth-2025-04-20";
13
14/// Windows Claude reports, in the order they're shown, with their short labels.
15const KNOWN_WINDOWS: [(&str, &str); 4] = [
16("five_hour", "5h"),
17("seven_day", "wk"),
18("seven_day_opus", "Opus"),
19("seven_day_sonnet", "Sonnet"),
20];
21
22#[derive(Deserialize)]
23struct StoredCredentials {
24#[serde(rename = "claudeAiOauth")]
25oauth: Option<OAuthCredentials>,
26}
27
28#[derive(Deserialize)]
29struct OAuthCredentials {
30#[serde(rename = "accessToken")]
31access_token: String,
32/// Milliseconds since the epoch.
33#[serde(rename = "expiresAt")]
34expires_at: Option<i64>,
35#[serde(rename = "subscriptionType")]
36subscription_type: Option<String>,
37}
38
39/// Fetches usage with a Claude Code sign-in, as stored in its keychain item.
1 reference
40pub(crate) async fn fetch_usage(
41stored_credentials: &str,
42http_client: Arc<dyn HttpClient>,
43) -> Result<UsageReport> {
44let stored = serde_json::from_str::<StoredCredentials>(stored_credentials)
45.context("couldn't read Claude Code's sign-in")?;
46let credentials = stored
47.oauth
48.ok_or_else(|| anyhow!("Claude Code uses an API key, which has no usage limits to show"))?;
49// Refreshing the token here would rotate the one Claude Code stored and sign it out.
50if credentials
51.expires_at
52.is_some_and(|expires_at| expires_at <= Utc::now().timestamp_millis())
53{
54bail!("sign-in expired; run claude once with this account to renew it");
55}
56
57let request = Request::get(USAGE_URL)
58.header(
59"Authorization",
60format!("Bearer {}", credentials.access_token),
61)
62.header("anthropic-beta", OAUTH_BETA)gk7734, 18 hours ago • Show Claude and Codex usage and switch their accounts from the status bar
63.body(AsyncBody::empty())?;
64let mut response = http_client.send(request).await?;
65let mut body = String::new();
66response.body_mut().read_to_string(&mut body).await?;
67match response.status() {
68StatusCode::OK => {}
69StatusCode::UNAUTHORIZED => bail!("sign-in expired; run claude once to renew it"),
70StatusCode::TOO_MANY_REQUESTS => return Err(RateLimited.into()),
71status => bail!("usage request failed with {status}"),
72}
73let mut report = parse_usage(&body)?;
74report.plan = credentials.subscription_type.map(Into::into);
75Ok(report)
76}
77
7 references
78fn parse_usage(body: &str) -> Result<UsageReport> {
79let usage = serde_json::from_str::<serde_json::Value>(body)?;
80let object = usage
81.as_object()
82.ok_or_else(|| anyhow!("unexpected usage response"))?;
83let window = |key: &str, label: &str| -> Option<UsageWindow> {
84let window = object.get(key)?.as_object()?;
85Some(UsageWindow {
86label: label.to_string().into(),
87used_percent: window.get("utilization")?.as_f64()? as f32,
88resets_at: window
89.get("resets_at")
90.and_then(|resets_at| resets_at.as_str())
91.and_then(|resets_at| DateTime::parse_from_rfc3339(resets_at).ok())
92.map(|resets_at| resets_at.with_timezone(&Utc)),
93})
94};
95let windows = KNOWN_WINDOWS
96.iter()
97.filter_map(|(key, label)| window(key, label))
98.collect::<Vec<_>>();
99if windows.is_empty() {
100bail!("the usage response had no usage windows");
101}
102Ok(UsageReport {
103windows,
104plan: None,
105})
106}
107
108#[cfg(test)]
109mod tests {
110use super::*;
111
112#[test]
113fn test_parse_usage() {
114let report = parse_usage(
115r#"{
116"five_hour": { "utilization": 12.0, "resets_at": "2026-10-21T15:00:00+00:00" },
117"seven_day": { "utilization": 28.5, "resets_at": "2026-10-25T09:00:00Z" },
118"seven_day_opus": null,
119"seven_day_sonnet": { "utilization": 0, "resets_at": null }
120}"#,
121)
122.unwrap();
123let summary = report
124.windows
125.iter()
126.map(|window| {
127(
128window.label.to_string(),
129window.used_percent,
130window.resets_at.map(|resets_at| resets_at.to_rfc3339()),
131)
132})
133.collect::<Vec<_>>();
134assert_eq!(
135summary,
136vec![
137(
138"5h".to_string(),
13912.0,
140Some("2026-10-21T15:00:00+00:00".to_string())
141),
142(
143"wk".to_string(),
14428.5,
145Some("2026-10-25T09:00:00+00:00".to_string())
146),
147("Sonnet".to_string(), 0.0, None),
148]
149);
150assert!(parse_usage("{}").is_err());
151}
152
153#[test]
154fn test_reads_stored_credentials() {
155let stored = serde_json::from_str::<StoredCredentials>(
156r#"{ "claudeAiOauth": { "accessToken": "test-token", "expiresAt": 1, "subscriptionType": "max" } }"#,
157)
158.unwrap();
159let oauth = stored.oauth.unwrap();
160assert_eq!(oauth.access_token, "test-token");
161assert_eq!(oauth.subscription_type.as_deref(), Some("max"));
162}
163}
❯ git log --oneline -3
9f9368e Update Luna from its GitHub releases
5683076 Pack Luna into a DMG with a drag-to-install window
c9327da Rainbow indent guides in the theme settings, and pin JSX elements with sticky scroll
162:1Rust

This is a working mockup with Luna's own source. Open files, scroll the code, type in the terminal, or click the usage meter.

Only in Luna

What you won't find in another editor.

A terminal that behaves like a terminal.

The built-in terminal runs on libghostty-vt, the engine inside Ghostty. vim, tmux and claude get the same key handling, colors and mouse modes they get in a standalone terminal, and any folder opens in a Ghostty window from the project panel.

Terminal panel · Project panel → Open in Ghostty
❯ git status
On branch main
M crates/ai_usage/src/claude.rs
M crates/ai_usage/src/codex.rs
 
Type help, or try git log.

Your AI limits, one glance away.

The status bar shows how much of your Claude Code and Codex five-hour and weekly limits you've used. Add more than one account and switch from the menu; Luna swaps which account Claude Code is signed in to.

Status bar → usage menu
5h 17% 2h 30m · wk 30% 4d 7hwk 11% 29d 23h

Pick an account, add one, or remove one. Example accounts and numbers.

Completions that learn what you pick.

Luna indexes every symbol in your project in the background and offers them next to the language server's suggestions. Names you've picked before come first, weighted by how often and how recently. The index is cached, so reopening a project only reads the files that changed.

Any file → start typing
74pub(crate) async fn install_login(login: Option<&ClaudeLogin>) -> Result<()> {
75match login {
76Some(login) => {
77let account_name = claude_code_account_name().await?;
78
79write_password(CLAUDE_CODE_SERVICE, &account_name, &login.credentials).await?;
80write_oauth_account(login.oauth_account.clone())
81}
82None => {
83delete_all_passwords(CLAUDE_CODE_SERVICE).await?;
84write_oauth_account(None)
Nothing picked yet, so names are ranked by how well they match.

Pick a name further down the list. When the same letters are typed again, it comes first.

Language servers that use less memory.

ESLint runs inside TypeScript's server instead of starting its own, rust-analyzer stops analyzing every crate up front, and a server that grows past 6 GB restarts once it's been idle for five minutes.

Default settings, nothing to set up
Zed's defaultsLuna
TypeScript project−33%
Editor and language servers, on an Expo app
1.36 GB
0.91 GB
Rust project−45%
rust-analyzer after start-up, on Zed's own repository
7.3 GB
4.0 GB
Under the hood

Small changes that keep a long day light.

Servers only while they're useful

Prettier stops after ten minutes without formatting and comes back on the next format. Tailwind's server starts only in projects that use Tailwind.

Checks only what you saved

On save, rust-analyzer checks the package you edited instead of the whole workspace.

Backs off on battery

Symbol indexing waits while Low Power Mode is on or the Mac is running hot, then catches up.

Quiet when idle

Luna stops drawing frames when nothing on screen changes, and the cursor stops blinking after ten seconds, so an idle window lets the CPU sleep.

Memory goes back to macOS

Every 30 seconds Luna hands memory it has freed back to the system instead of keeping it reserved.

Pause a whole project

One toggle stops file scanning, file watching and language servers for a huge folder. Turn it back on and Luna catches up.

Install

Download Luna

Version 1.26.0 · Release notes

macOS

For Apple Silicon Macs (M1 or later). Signed and notarized by Apple.

Download .dmg85 MBor use Homebrew
brew install --cask gk7734/tap/luna

Windows

A Windows build of Luna is on the way.

Coming soon

Once installed, Luna checks its releases and updates itself.

Open a project tonight.

Download Luna for macOS