sofka
A Kubernetes TUI, reimagined in Rust - built on kube-rs and
ratatui, async-first from the ground up.
Why "sofka"
That's Sophie. She sits behind the monitor and watches it - not occasionally,
constantly, with the specific narrowed-eye expression of someone who has
noticed a pod in CrashLoopBackOff and is judging you for it. She doesn't
miss a state change. She doesn't get distracted. She is, functionally, a
cluster watchman who happens to be a cat.
sofka is the Serbian diminutive of Sophia - "wisdom," fittingly, since
watching things closely and knowing when something's wrong is more or less
the whole job description of both a good cluster TUI and a good cat.
This is a from-scratch reimagining of k9s (originally ~51k lines of Go), not a line-for-line port. It keeps the spirit - a fast, keyboard-driven cluster navigator - but rethinks the architecture around a single generic object pipeline instead of one hand-written renderer per resource kind.
How it differs from k9s
- One generic render pipeline, not one file per kind. k9s ships a
dedicated Go file (struct +
ColorerFunc) per resource type it knows about. sofka has oneDynamicObject → cellsfunction with curated columns for the common kinds and a NAME/AGE fallback for everything else - so a CRD nobody's written a renderer for still lists, sorts, and filters correctly on day one. - Flux CD is a first-class citizen, not a plugin.
topens a Suspend/Resume/Reconcile-now menu for Kustomizations, HelmReleases, git/helm/oci repositories, buckets, image automation, and notification alerts/receivers - patchingspec.suspendand thereconcile.fluxcd.io/requestedAtannotation directly via the k8s API. Nofluxbinary required, and it composes with bulk multiselect. - Port-forwards run in the background. Starting one doesn't freeze the
TUI for its whole lifetime;
:pflists active forwards and stops them individually while others keep running. They're killed automatically on quit rather than left orphaned. - Bulk actions via multiselect.
spacemarks rows for delete, kill, or Flux suspend/resume/reconcile across many resources at once - not one-row-at-a-time. - CRD rows drill into their custom resources, not their YAML -
enteron a CustomResourceDefinition resolves its served version and lists the actual objects. - Skins, not a single fixed palette. Built-in Catppuccin/Gruvbox/Nord/ Dracula palettes selectable in config, with per-swatch hex overrides. Every semantic color (row status, severity badges, headers, borders) is derived from the active palette, so a skin change is consistent everywhere at once.
- A combined row colorer. Whole-row status tinting like k9s (healthy rows, errors, pending, completed all read as one color), plus a distinct STATUS badge and outlier coloring on RESTARTS/CPU/MEM so a crash-looping or resource-hungry pod still pops out of an otherwise uniform row.
Why it's faster
Not a marketing number - these are specific, checkable design choices:
- No GC. Rust's ownership model means zero garbage-collector pauses. Watching thousands of pods/CRs across a large cluster grows the in-memory store, but redraw latency doesn't get jittery as that store grows the way a GC'd runtime's can under sustained allocation pressure.
- Batched redraws. The event loop drains every pending watch message
before triggering one redraw (
while let Ok(m) = rx.try_recv()). A rollout touching 50 pods costs one render pass, not fifty. - Cached row computation. Sorting and fuzzy-filtering the visible rows only reruns when the underlying data or the filter text actually changed (a dirty-flag-guarded cache), not on every frame or every keystroke against the full object set.
- No subprocess overhead for the hot paths. Delete, scale, suspend/
resume/reconcile, and CRD drill-down are direct kube API calls (JSON
merge-patches over the existing client), not a
kubectl/fluxprocess fork+exec per action. - Generation-tagged streams. Switching views doesn't wait for an old watcher to tear down - stale messages are dropped by generation tag the instant a newer watch takes over, so navigation never stalls behind a slow-to-cancel stream.
Features
- Connect to the current kubeconfig context, including exec credential plugins (e.g. GKE).
- API discovery of every resource type on the cluster, with k9s-style
short aliases (
po,dp,svc,no,cm,sts,ds,ks,hr, …) and correct precedence (corepodsbeatspods.metrics.k8s.io). - Live watch of any kind via
kube::runtime::watcher, streamed into an in-memory store. - Curated columns for common kinds (pods, deployments, replicasets, statefulsets, daemonsets, services, nodes, namespaces, configmaps, secrets, jobs, cronjobs, PVC/PV, ingresses, endpoints, CustomResourceDefinitions) with a NAME/AGE fallback for everything else.
- Live CPU/MEM columns for pods and nodes from the metrics API, with outlier coloring; degrades gracefully when metrics-server is absent.
- Drill-down navigation with a breadcrumb stack: workload/service →
pods, node → its pods, pod → containers, namespace → re-scope, CRD → its
custom resources.
escpops back. - Command palette (
:) - fuzzy over the full resource catalog and built-in commands (ctx,pulse,xray,diff,pf) together, plus fuzzy row filtering (/) with matched-character highlighting. - Multiselect (
space) for bulk delete/kill/suspend/resume/reconcile. - Pulse dashboard (
:pulse) - cluster-health tiles, refreshed every 5s. - Xray tree (
:xray) - hierarchical view from the current kind down through owner references to pods and containers. - Flux CD controls (
t) - suspend/resume/reconcile menu, native k8s API patches. - Background port-forwards (
f/Fto start,:pfto manage). - Plugins - config-defined shell-out commands bound to keys, scoped per resource.
- Diff (
:diff) - unified diff of the live object vs itslast-applied-configuration. - RBAC-aware palette - hides resource kinds you can't
list. - Namespace switcher (
n) and context switcher (:ctx). - YAML view (
y) and describe (d, viakubectl). - Logs (
l) - per-container on a pod, aggregated across all matching pods on a workload/service. In-logs search (/) with highlighting;pfor previous-container logs. ANSI color codes from the source app are parsed and mapped onto the active skin, not printed as literal escapes. - Skinnable - built-in Catppuccin/Gruvbox/Nord/Dracula palettes plus per-swatch overrides in config.
- Config file (TOML): aliases, default namespace/resource, plugins, skin.
Installation
Prebuilt binaries for macOS (aarch64/x86_64) and Linux (aarch64/x86_64) are attached to each GitHub release. Nix users can run it directly without installing anything:
nix run github:nklmilojevic/sofka
or build from source (see Development).
macOS: "cannot be opened because the developer cannot be verified"
The release binaries aren't signed/notarized yet, so if you download a tarball through a browser and extract it, Gatekeeper will refuse to run it - this is expected, not a broken build. Clear the quarantine flag once:
xattr -d com.apple.quarantine sofka
(or right-click the binary in Finder → Open, and confirm through the dialog once). Signing and notarization are planned for the next release, at which point this step won't be necessary.
Configuration
$XDG_CONFIG_HOME/sofka/config.toml (or ~/.config/sofka/config.toml):
= "kube-system"
= "deployments"
[]
= "deployments"
[]
= "gruvbox" # catppuccin-mocha (default), -latte, -frappe,
# -macchiato, gruvbox, nord, dracula
[] # optional per-swatch overrides
= "#fb4934"
[[]]
= "g"
= "argocd-sync"
= "argocd"
= ["app", "sync", "$NAME"]
= ["deployments"] # omit for all resources
Headless modes (no TTY required)
sofka --check # connect, run discovery, print a summary, exit
sofka pods --snapshot # render one frame of a resource view to stdout
sofka dp -A --snapshot # deployments, all namespaces
These double as CI smoke tests.
Usage
sofka [RESOURCE] [-n NAMESPACE] [-A]
RESOURCE resource to open (alias/plural/kind), default: pods
-n, --namespace namespace to start in
-A, --all-namespaces
Keys
| Key | Action |
|---|---|
:<resource> |
command palette - fuzzy over kinds and built-in commands |
enter |
drill down (workload/svc → pods, node → its pods, pod → containers, ns → re-scope, CRD → its resources) |
esc |
go back / pop the view stack / clear filter / clear marks |
j/k, ↓/↑, g/G |
navigate |
S / I |
cycle sort column / invert sort direction |
space |
mark/unmark row for bulk actions |
/ |
fuzzy filter |
n / 0 |
namespace switcher / all namespaces |
shift-j |
jump to owner/controller |
o |
show the node hosting the selected pod |
ctrl-r |
refresh the watch |
y / d |
view YAML / describe (kubectl) |
l / p |
logs (workload = all matching pods) / previous-container logs |
c |
copy resource name to clipboard |
e |
edit in $EDITOR (kubectl edit) |
s |
shell into pod / scale a workload (context-dependent) |
a |
attach to pod |
i |
set container image |
r |
rollout restart (workloads) / refresh (elsewhere) |
f / shift-f |
port-forward (pods/services) - runs in the background |
t |
Flux: suspend/resume/reconcile menu |
ctrl-d / ctrl-k |
delete / kill (marked rows, or current) |
:q, ctrl-c |
quit |
? |
help |
Logs view: / search+highlight · s autoscroll · w wrap · t
timestamps · x stop/resume stream · c copy buffer · ctrl-s save to file
· esc back. The newest line anchors to the bottom of the viewport.
Interactive actions (e, s-shell, a) suspend the TUI and shell out to
kubectl; delete/scale/restart/set-image/suspend/resume/reconcile/
port-forward go through the kube API (or a backgrounded process) directly.
Architecture
main.rs CLI (clap), terminal lifecycle, the async select! event loop,
and the --check / --snapshot headless modes.
app.rs All application state + input handling (a mode state machine:
Table / Command / Filter / Detail / Logs / FluxMenu /
PortForwards / Help / Namespaces / …). Spawns watch/log/
port-forward tasks.
k8s.rs Cluster connect, API discovery, alias registry + group-priority
resolution, watch-task spawning, namespace listing.
store.rs In-memory resource store + the Msg enum that watch tasks send to
the UI (generation-tagged so stale streams are dropped).
columns.rs Per-kind column definitions and cell extraction from
DynamicObjects (the "render" layer), with unit tests.
ui.rs All ratatui rendering: header, table, scrollable views, popups,
status bar.
theme.rs Palette + semantic styles, skin resolution.
Data flow: watcher tasks push generation-tagged Msgs over an
mpsc::UnboundedSender; the main tokio::select! loop folds them into the
Store, batches any other queued updates before redrawing, and shares that
same loop with terminal input and a 1s tick (age columns, dead port-forward
reaping) - so the UI never blocks on the network.
Development
cargo run -- pods # run against current context
cargo test # unit tests (no cluster required)
cargo clippy --all-targets # lints (clean)
License
Dual-licensed under MIT or Apache-2.0, at your option - the Rust ecosystem standard.