diff --git a/backend/src/routes/shellPrompt.ts b/backend/src/routes/shellPrompt.ts
new file mode 100644
index 0000000..bbd1a0a
--- /dev/null
+++ b/backend/src/routes/shellPrompt.ts
@@ -0,0 +1,31 @@
+import type { FastifyInstance } from 'fastify'
+import { z } from 'zod'
+import { withSshClient } from '../ssh/docker.js'
+import { PROMPT_PRESETS, checkPromptStatus, installPrompt, isValidPresetId } from '../ssh/shellPrompt.js'
+
+/**
+ * Shell prompt setup for SSH-managed hosts: lets the Terminal page show a
+ * preset picker and install Starship + a Nerd Font on demand, instead of
+ * requiring the user to run a script by hand on every host.
+ */
+export async function shellPromptRoutes(app: FastifyInstance) {
+ app.addHook('onRequest', app.authenticate)
+
+ app.get('/api/ssh/:integrationId/shell-prompt', async (req, reply) => {
+ const integrationId = Number((req.params as { integrationId: string }).integrationId)
+ const result = await withSshClient(integrationId, (client) => checkPromptStatus(client))
+ if (!result.ok) return reply.code(502).send({ error: result.error })
+ return { presets: PROMPT_PRESETS, status: result.value }
+ })
+
+ app.post('/api/ssh/:integrationId/shell-prompt', async (req, reply) => {
+ const integrationId = Number((req.params as { integrationId: string }).integrationId)
+ const parsed = z.object({ presetId: z.string() }).safeParse(req.body ?? {})
+ if (!parsed.success || !isValidPresetId(parsed.data.presetId)) {
+ return reply.code(400).send({ error: 'Invalid preset' })
+ }
+ const result = await withSshClient(integrationId, (client) => installPrompt(client, parsed.data.presetId))
+ if (!result.ok) return reply.code(502).send({ error: result.error })
+ return { ok: true, log: result.value.log }
+ })
+}
diff --git a/backend/src/server.ts b/backend/src/server.ts
index f20d074..9b7d9d9 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -13,6 +13,7 @@ import { tunnelRoutes } from './routes/tunnels.js'
import { fileRoutes } from './routes/files.js'
import { dockerRoutes, dockerExecRoutes } from './routes/docker.js'
import { dockerSshRoutes, dockerSshExecRoutes } from './routes/dockerSsh.js'
+import { shellPromptRoutes } from './routes/shellPrompt.js'
import { agentIngestRoutes, agentRoutes } from './routes/agents.js'
import { guacamoleRoutes } from './routes/guacamole.js'
import { metricsRoutes } from './routes/metrics.js'
@@ -94,6 +95,7 @@ await app.register(dockerRoutes)
await app.register(dockerExecRoutes)
await app.register(dockerSshRoutes)
await app.register(dockerSshExecRoutes)
+await app.register(shellPromptRoutes)
await app.register(agentIngestRoutes)
await app.register(agentRoutes)
await app.register(guacamoleRoutes)
diff --git a/backend/src/ssh/shellPrompt.ts b/backend/src/ssh/shellPrompt.ts
new file mode 100644
index 0000000..c14df6d
--- /dev/null
+++ b/backend/src/ssh/shellPrompt.ts
@@ -0,0 +1,131 @@
+import type { Client } from 'ssh2'
+import { execCommand } from './metrics/common.js'
+
+/**
+ * Installs/configures a Starship shell prompt + the Nerd Font icons it needs
+ * on a remote SSH host, on demand from the Terminal page. Idempotent: each
+ * step checks first and skips work that's already done, so re-running (e.g.
+ * switching presets) doesn't re-download anything unnecessary.
+ */
+export interface PromptPreset {
+ id: string
+ name: string
+ description: string
+ /** Name passed to `starship preset `. */
+ starshipPreset: string
+}
+
+export const PROMPT_PRESETS: PromptPreset[] = [
+ {
+ id: 'nerd-font-symbols',
+ name: 'Nerd Font Symbols',
+ description: 'Classic Starship look — OS icon, git branch/status, language runtime icons.',
+ starshipPreset: 'nerd-font-symbols',
+ },
+ {
+ id: 'pastel-powerline',
+ name: 'Pastel Powerline',
+ description: 'Powerline-style segments with soft pastel colors and icons.',
+ starshipPreset: 'pastel-powerline',
+ },
+ {
+ id: 'tokyo-night',
+ name: 'Tokyo Night',
+ description: 'Tokyo Night colorway with a clean icon-led layout.',
+ starshipPreset: 'tokyo-night',
+ },
+]
+
+export function isValidPresetId(id: string): boolean {
+ return PROMPT_PRESETS.some((p) => p.id === id)
+}
+
+const NERD_FONT_VERSION = 'v3.4.0'
+const NERD_FONT_NAME = 'JetBrainsMono'
+const NERD_FONT_DIR = '.local/share/fonts/ArchNestNerdFont'
+
+export interface PromptStatus {
+ starshipInstalled: boolean
+ starshipVersion: string | null
+ fontInstalled: boolean
+ configuredPreset: string | null
+}
+
+export async function checkPromptStatus(client: Client): Promise {
+ const { stdout: versionOut, code: versionCode } = await execCommand(
+ client,
+ 'starship --version 2>/dev/null',
+ )
+ const starshipInstalled = versionCode === 0
+ const starshipVersion = starshipInstalled ? versionOut.split('\n')[0]?.trim() || null : null
+
+ const { code: fontCode } = await execCommand(
+ client,
+ `test -d "\${HOME}/${NERD_FONT_DIR}" && [ -n "$(ls -A "\${HOME}/${NERD_FONT_DIR}" 2>/dev/null)" ]`,
+ )
+ const fontInstalled = fontCode === 0
+
+ const { stdout: presetOut, code: presetCode } = await execCommand(
+ client,
+ 'grep -o "ArchNest preset: [a-z-]*" "${HOME}/.config/starship.toml" 2>/dev/null | head -1',
+ )
+ const configuredPreset = presetCode === 0 ? presetOut.replace('ArchNest preset:', '').trim() || null : null
+
+ return { starshipInstalled, starshipVersion, fontInstalled, configuredPreset }
+}
+
+/**
+ * Idempotently installs Starship + the Nerd Font (if either is missing), writes
+ * a starship.toml for the chosen preset, and wires the init line into
+ * .bashrc/.zshrc if either rc file exists and doesn't already have it.
+ */
+export async function installPrompt(client: Client, presetId: string): Promise<{ log: string }> {
+ const preset = PROMPT_PRESETS.find((p) => p.id === presetId)
+ if (!preset) throw new Error('Unknown preset')
+
+ const script = `
+set -e
+LOG() { printf '%s\\n' "$1"; }
+
+if command -v starship >/dev/null 2>&1; then
+ LOG "starship already installed, skipping install"
+else
+ LOG "installing starship..."
+ curl -fsSL https://starship.rs/install.sh | sh -s -- -y >/dev/null 2>&1
+fi
+
+FONT_DIR="\${HOME}/${NERD_FONT_DIR}"
+if [ -d "$FONT_DIR" ] && [ -n "$(ls -A "$FONT_DIR" 2>/dev/null)" ]; then
+ LOG "nerd font already installed, skipping download"
+else
+ LOG "installing ${NERD_FONT_NAME} Nerd Font..."
+ mkdir -p "$FONT_DIR"
+ TMP_TAR="$(mktemp)"
+ curl -fsSL -o "$TMP_TAR" "https://github.com/ryanoasis/nerd-fonts/releases/download/${NERD_FONT_VERSION}/${NERD_FONT_NAME}.tar.xz"
+ tar -xJf "$TMP_TAR" -C "$FONT_DIR"
+ rm -f "$TMP_TAR"
+ command -v fc-cache >/dev/null 2>&1 && fc-cache -f "$FONT_DIR" >/dev/null 2>&1 || true
+fi
+
+LOG "writing starship config for preset: ${preset.starshipPreset}"
+mkdir -p "\${HOME}/.config"
+starship preset ${preset.starshipPreset} -o "\${HOME}/.config/starship.toml"
+printf '\\n# ArchNest preset: ${preset.id}\\n' >> "\${HOME}/.config/starship.toml"
+
+add_init_line() {
+ local rcfile="$1" line="$2"
+ [ -f "$rcfile" ] || return 0
+ grep -qF "$line" "$rcfile" || printf '\\n# Added by ArchNest\\n%s\\n' "$line" >> "$rcfile"
+}
+add_init_line "\${HOME}/.bashrc" 'eval "$(starship init bash)"'
+add_init_line "\${HOME}/.zshrc" 'eval "$(starship init zsh)"'
+
+LOG "done"
+`.trim()
+
+ const { stdout, stderr, code } = await execCommand(client, script, 180000)
+ if (code !== 0) {
+ throw new Error(stderr.trim() || stdout.trim() || `Install script exited with code ${code}`)
+ }
+ return { log: stdout.trim() }
+}
diff --git a/scripts/install-starship-nerdfont.sh b/scripts/install-starship-nerdfont.sh
new file mode 100755
index 0000000..d1419d2
--- /dev/null
+++ b/scripts/install-starship-nerdfont.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+# Installs Starship (the prompt) + JetBrainsMono Nerd Font (the icons) on a
+# host you SSH into from ArchNest's Terminal page, and wires Starship into
+# bash/zsh if either is present. Safe to re-run.
+#
+# This repo is private, so there's no public raw-URL one-liner. Run it directly
+# on the target VM instead, as the user whose shell you want themed:
+# scp scripts/install-starship-nerdfont.sh youruser@host:/tmp/
+# ssh youruser@host 'bash /tmp/install-starship-nerdfont.sh'
+# or paste its contents into a Terminal/SSH session and run it there.
+
+set -euo pipefail
+
+NERD_FONT_VERSION="v3.4.0"
+NERD_FONT_NAME="JetBrainsMono"
+FONT_DIR="${HOME}/.local/share/fonts/NerdFonts"
+
+echo "==> Installing Starship"
+if command -v starship >/dev/null 2>&1; then
+ echo " starship already installed ($(starship --version | head -1)), skipping"
+else
+ curl -fsSL https://starship.rs/install.sh | sh -s -- -y
+fi
+
+echo "==> Installing ${NERD_FONT_NAME} Nerd Font into ${FONT_DIR}"
+mkdir -p "${FONT_DIR}"
+TMP_TAR="$(mktemp)"
+curl -fsSL -o "${TMP_TAR}" \
+ "https://github.com/ryanoasis/nerd-fonts/releases/download/${NERD_FONT_VERSION}/${NERD_FONT_NAME}.tar.xz"
+tar -xJf "${TMP_TAR}" -C "${FONT_DIR}"
+rm -f "${TMP_TAR}"
+
+if command -v fc-cache >/dev/null 2>&1; then
+ fc-cache -f "${FONT_DIR}" >/dev/null
+fi
+
+STARSHIP_INIT_BASH='eval "$(starship init bash)"'
+STARSHIP_INIT_ZSH='eval "$(starship init zsh)"'
+
+add_init_line() {
+ local rcfile="$1" line="$2"
+ [ -f "${rcfile}" ] || return 0
+ if ! grep -qF "${line}" "${rcfile}"; then
+ printf '\n# Added by ArchNest install-starship-nerdfont.sh\n%s\n' "${line}" >> "${rcfile}"
+ echo " added Starship init to ${rcfile}"
+ else
+ echo " ${rcfile} already initializes Starship, skipping"
+ fi
+}
+
+echo "==> Wiring Starship into shell rc files"
+add_init_line "${HOME}/.bashrc" "${STARSHIP_INIT_BASH}"
+add_init_line "${HOME}/.zshrc" "${STARSHIP_INIT_ZSH}"
+
+mkdir -p "${HOME}/.config"
+if [ ! -f "${HOME}/.config/starship.toml" ]; then
+ starship preset nerd-font-symbols -o "${HOME}/.config/starship.toml" 2>/dev/null \
+ || echo " (preset command unavailable on this Starship version — using its built-in default prompt, which already includes icons)"
+fi
+
+cat <No integrations added yet — add one in Settings.
) : (
-
+
{integrations.map((i) => (
diff --git a/src/components/ProgressRing.tsx b/src/components/ProgressRing.tsx
index bca4d76..c0c8a17 100644
--- a/src/components/ProgressRing.tsx
+++ b/src/components/ProgressRing.tsx
@@ -49,3 +49,53 @@ export default function ProgressRing({ percentage, size = 56, strokeWidth = 4 }:
)
}
+
+interface RingSegment {
+ color: string
+ value: number
+}
+
+interface MultiSegmentRingProps {
+ segments: RingSegment[]
+ total: number
+ size?: number
+ strokeWidth?: number
+ centerLabel?: string
+}
+
+export function MultiSegmentRing({ segments, total, size = 64, strokeWidth = 10, centerLabel }: MultiSegmentRingProps) {
+ const radius = (size - strokeWidth) / 2
+ const circumference = 2 * Math.PI * radius
+
+ let cumulative = 0
+ const drawn = segments.filter((s) => s.value > 0)
+
+ return (
+
+
+ {centerLabel && {centerLabel}}
+
+ )
+}
diff --git a/src/components/StatusCards.tsx b/src/components/StatusCards.tsx
index 302288c..18c957a 100644
--- a/src/components/StatusCards.tsx
+++ b/src/components/StatusCards.tsx
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'
import { Server, Plug, BookMarked } from 'lucide-react'
-import ProgressRing from './ProgressRing'
+import { MultiSegmentRing } from './ProgressRing'
import { api, type Integration, type Resource, type Bookmark } from '../lib/api'
+import { getIntegrationTypeColors } from '../lib/integrationColors'
const cardStyle: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.55)',
@@ -47,17 +48,34 @@ export default function StatusCards() {
const systemLabel = errored > 0 ? 'Issues Detected' : total === 0 ? 'Not Configured' : 'All Systems Operational'
const systemPercent = total === 0 ? 0 : Math.round((connected / total) * 100)
+ const typeColors = getIntegrationTypeColors(integrations ?? [])
+ const typeCounts = new Map
()
+ for (const i of integrations ?? []) {
+ typeCounts.set(i.type, (typeCounts.get(i.type) ?? 0) + 1)
+ }
+ const typeSegments = [...typeCounts.entries()].map(([type, value]) => ({ type, value, color: typeColors[type] }))
+
return (
{/* System Status */}
-
+
System Status
0 ? '#E74C3C' : '#2ECC71', lineHeight: 1.3 }}>{systemLabel}
{connected} of {total} integrations connected
+ {typeSegments.length > 0 && (
+
+ {typeSegments.map((s) => (
+
+
+ {s.type} ({s.value})
+
+ ))}
+
+ )}
-
+
diff --git a/src/lib/api.ts b/src/lib/api.ts
index d945e03..dc76c9c 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -193,6 +193,16 @@ export const api = {
`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers/${encodeURIComponent(containerId)}`,
),
+ // Shell prompt setup on an SSH host: install Starship + a Nerd Font on demand
+ // from the Terminal page, instead of requiring a manual script run.
+ getShellPromptStatus: (integrationId: number) =>
+ apiFetch<{ presets: PromptPreset[]; status: PromptStatus }>(`/ssh/${integrationId}/shell-prompt`),
+ installShellPrompt: (integrationId: number, presetId: string) =>
+ apiFetch<{ ok: boolean; log: string }>(`/ssh/${integrationId}/shell-prompt`, {
+ method: 'POST',
+ body: JSON.stringify({ presetId }),
+ }),
+
getHostMetrics: (integrationId: number) => apiFetch
(`/integrations/${integrationId}/metrics`),
startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) =>
@@ -209,6 +219,20 @@ export const api = {
}),
}
+export interface PromptPreset {
+ id: string
+ name: string
+ description: string
+ starshipPreset: string
+}
+
+export interface PromptStatus {
+ starshipInstalled: boolean
+ starshipVersion: string | null
+ fontInstalled: boolean
+ configuredPreset: string | null
+}
+
export interface DataExport {
version: number
exportedAt?: string
diff --git a/src/lib/integrationColors.ts b/src/lib/integrationColors.ts
new file mode 100644
index 0000000..2dc2404
--- /dev/null
+++ b/src/lib/integrationColors.ts
@@ -0,0 +1,24 @@
+import type { Integration } from './api'
+
+// Color-blind-friendly palette (Blue/Orange/Green/Brown/Purple/Red/Teal/Yellow),
+// assigned to integration *types* in first-come-first-served order (by integration
+// id, i.e. whichever type was connected to ArchNest first). Once a type has a
+// color it keeps it everywhere — System Status breakdown, Infrastructure, etc.
+const PALETTE = ['#4A90E2', '#E67E22', '#2ECC71', '#8B5E3C', '#9B59B6', '#E74C3C', '#1ABC9C', '#F1C40F']
+
+export function getIntegrationTypeColors(integrations: Integration[]): Record {
+ const sorted = [...integrations].sort((a, b) => a.id - b.id)
+ const map: Record = {}
+ let next = 0
+ for (const integ of sorted) {
+ if (!(integ.type in map)) {
+ map[integ.type] = PALETTE[next % PALETTE.length]
+ next++
+ }
+ }
+ return map
+}
+
+export function getIntegrationColor(integrations: Integration[], type: string): string {
+ return getIntegrationTypeColors(integrations)[type] ?? '#7A7D85'
+}
diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx
index 9d72b0a..e82d00e 100644
--- a/src/pages/Help.tsx
+++ b/src/pages/Help.tsx
@@ -160,7 +160,7 @@ const quickStartSteps = [
export default function Help() {
return (
-
+
How ArchNest works
diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx
index 46c0026..408f663 100644
--- a/src/pages/Terminal.tsx
+++ b/src/pages/Terminal.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
-import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react'
-import { api, type Integration } from '../lib/api'
+import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash, Sparkles } from 'lucide-react'
+import { api, type Integration, type PromptPreset, type PromptStatus } from '../lib/api'
import { useTerminalSessions } from '../lib/TerminalSessionContext'
import { TERM_THEMES } from '../lib/terminalPrefs'
@@ -174,6 +174,9 @@ export default function Terminal() {
))}
+
+
+
)}
@@ -260,3 +263,83 @@ function TerminalPane({
)
}
+
+/**
+ * Lets a user pick one of a few Starship prompt looks and install it (Starship
+ * + a Nerd Font, if not already present) on the active pane's SSH host with
+ * one click, instead of running a script by hand.
+ */
+function ShellPromptControl({ hostId }: { hostId: number | null }) {
+ const [presets, setPresets] = useState
([])
+ const [status, setStatus] = useState(null)
+ const [selected, setSelected] = useState('')
+ const [installing, setInstalling] = useState(false)
+ const [message, setMessage] = useState(null)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ setStatus(null)
+ setMessage(null)
+ setError(null)
+ if (hostId === null) return
+ api
+ .getShellPromptStatus(hostId)
+ .then(({ presets, status }) => {
+ setPresets(presets)
+ setStatus(status)
+ setSelected(status.configuredPreset ?? presets[0]?.id ?? '')
+ })
+ .catch((err) => setError(err instanceof Error ? err.message : 'Failed to check host'))
+ }, [hostId])
+
+ if (hostId === null) {
+ return Connect a host to set up its shell prompt
+ }
+
+ async function handleInstall() {
+ if (!selected) return
+ setInstalling(true)
+ setError(null)
+ setMessage(null)
+ try {
+ await api.installShellPrompt(hostId!, selected)
+ setMessage('Installed — open a new terminal session to this host to see it.')
+ const { status } = await api.getShellPromptStatus(hostId!)
+ setStatus(status)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Install failed')
+ } finally {
+ setInstalling(false)
+ }
+ }
+
+ return (
+
+
+
+
+ {message && {message}}
+ {error && {error}}
+
+ )
+}