Scaffolda tunn vertikal slice: pnpm-monorepo (shared/daemon/server/web)

- shared: typat WebSocket-protokoll (daemon/server/web som enda sanning)
- daemon: node-pty kör agenten (default gemini), strömmar pty över WS,
  auto-reconnect; fast pty-storlek
- server: WS-mux + token-auth + session-registry + headless-xterm scrollback
  (SerializeAddon) + routing av input till rätt session
- web: Vue 3 + Vite + xterm.js, session-lista, mobil-layout och
  fallback-tangentbord (piltangenter/Enter/Esc/Tab/Ctrl-C) som skickar
  escape-sekvenser rakt till pty:n -> styr sessionen även utan hooks
- workspace: pnpm-workspace.yaml (onlyBuiltDependencies: node-pty, esbuild),
  tsconfig.base, .npmrc (retry-inställningar), .env.example

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:28:04 +02:00
parent 0c75d94812
commit c58f73626e
24 changed files with 1173 additions and 0 deletions

12
packages/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="sv">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>agent-helm</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
packages/web/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@agent-helm/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@agent-helm/shared": "workspace:*",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"vue-tsc": "^2.1.0"
}
}

209
packages/web/src/App.vue Normal file
View File

@@ -0,0 +1,209 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { createHelmClient } from "./lib/helm";
import TerminalView from "./components/TerminalView.vue";
const defaultUrl = import.meta.env.VITE_SERVER_WS ?? "ws://localhost:8787";
const defaultToken = import.meta.env.VITE_AGENT_HELM_TOKEN ?? "";
const url = ref(localStorage.getItem("helm.url") ?? defaultUrl);
const token = ref(localStorage.getItem("helm.token") ?? defaultToken);
const client = createHelmClient();
const { connected, connecting, error, sessions } = client;
const selected = ref<string | null>(null);
const sidebarOpen = ref(true);
const activeMeta = computed(
() => sessions.value.find((s) => s.sessionId === selected.value) ?? null,
);
function connect(): void {
localStorage.setItem("helm.url", url.value);
localStorage.setItem("helm.token", token.value);
client.connect(url.value, token.value);
}
function select(id: string): void {
selected.value = id;
if (window.matchMedia("(max-width: 720px)").matches) sidebarOpen.value = false;
}
</script>
<template>
<!-- Inloggning -->
<div v-if="!connected" class="login">
<div class="card">
<h1>agent-helm</h1>
<p class="sub">Fjärrstyr dina agent-sessioner</p>
<label>
Server (WebSocket)
<input v-model="url" autocapitalize="off" autocorrect="off" spellcheck="false" />
</label>
<label>
Token
<input v-model="token" type="password" placeholder="delad token" @keyup.enter="connect" />
</label>
<button class="primary" :disabled="connecting" @click="connect">
{{ connecting ? "Ansluter" : "Anslut" }}
</button>
<p v-if="error" class="err">{{ error }}</p>
</div>
</div>
<!-- Huvudvy -->
<div v-else class="app">
<aside class="sidebar" :class="{ open: sidebarOpen }">
<header>
<span class="brand">agent-helm</span>
<span class="count">{{ sessions.length }}</span>
</header>
<ul>
<li
v-for="s in sessions"
:key="s.sessionId"
:class="{ active: s.sessionId === selected, exited: s.status === 'exited' }"
@click="select(s.sessionId)"
>
<span class="dot" :class="s.status"></span>
<span class="title">{{ s.title }}</span>
<span v-if="s.status === 'exited'" class="badge">exit {{ s.exitCode }}</span>
</li>
<li v-if="sessions.length === 0" class="empty">Inga anslutna sessioner än</li>
</ul>
</aside>
<main class="stage">
<button class="toggle" @click="sidebarOpen = !sidebarOpen" aria-label="Sessioner"></button>
<TerminalView
v-if="selected && activeMeta"
:key="selected"
:client="client"
:session-id="selected"
:meta="activeMeta"
/>
<div v-else class="placeholder">Välj en session till vänster</div>
</main>
</div>
</template>
<style scoped>
/* Inloggning */
.login {
display: grid;
place-items: center;
height: 100%;
padding: 1rem;
}
.card {
width: min(380px, 100%);
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1.5rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.9rem;
}
.card h1 { margin: 0; font-size: 1.4rem; }
.card .sub { margin: -0.6rem 0 0.4rem; color: var(--muted); font-size: 0.9rem; }
.card label { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.8rem; color: var(--muted); }
.card input {
padding: 0.6rem 0.7rem;
border-radius: 0.55rem;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font: inherit;
}
.primary {
margin-top: 0.4rem;
padding: 0.7rem;
border: 0;
border-radius: 0.55rem;
background: var(--accent);
color: #06121f;
font-weight: 700;
}
.primary:disabled { opacity: 0.6; }
.err { color: var(--red); font-size: 0.85rem; margin: 0; }
/* Layout */
.app { display: flex; height: 100%; }
.sidebar {
width: 260px;
flex: 0 0 260px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar .brand { font-weight: 700; }
.sidebar .count {
font-size: 0.75rem;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.05rem 0.5rem;
}
.sidebar ul { list-style: none; margin: 0; padding: 0.5rem; overflow-y: auto; }
.sidebar li {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.6rem 0.65rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.88rem;
}
.sidebar li:hover { background: var(--panel-2); }
.sidebar li.active { background: color-mix(in srgb, var(--accent) 22%, transparent); }
.sidebar li.exited .title { color: var(--muted); }
.sidebar li .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar li .badge { font-size: 0.68rem; color: var(--red); }
.sidebar li.empty { color: var(--muted); cursor: default; justify-content: center; padding: 1.2rem 0; }
.sidebar li.empty:hover { background: none; }
.dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--muted); flex: none; }
.dot.running { background: var(--green); }
.dot.exited { background: var(--red); }
.stage { flex: 1; min-width: 0; position: relative; display: flex; }
.stage > .terminal-view { flex: 1; }
.toggle {
display: none;
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 5;
width: 2.2rem;
height: 2.2rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
}
.placeholder { margin: auto; color: var(--muted); }
@media (max-width: 720px) {
.sidebar {
position: absolute;
z-index: 10;
height: 100%;
transform: translateX(-100%);
transition: transform 0.18s ease;
}
.sidebar.open { transform: translateX(0); }
.toggle { display: block; }
}
</style>

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, useTemplateRef } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import type { SessionMeta } from "@agent-helm/shared";
import type { HelmClient } from "../lib/helm";
const props = defineProps<{ client: HelmClient; sessionId: string; meta: SessionMeta }>();
const host = useTemplateRef<HTMLDivElement>("host");
let term: Terminal | null = null;
let fit: FitAddon | null = null;
// Fallback-navigering: skickar escape-sekvenser rakt till pty:n så att agentens
// egna TUI-prompts kan besvaras helt utan hooks.
const KEYS: Record<string, string> = {
up: "\x1b[A",
down: "\x1b[B",
right: "\x1b[C",
left: "\x1b[D",
enter: "\r",
esc: "\x1b",
tab: "\t",
ctrlc: "\x03",
y: "y",
n: "n",
};
function key(name: keyof typeof KEYS): void {
props.client.sendInput(props.sessionId, KEYS[name]);
term?.focus();
}
function onResize(): void {
fit?.fit();
}
onMounted(() => {
term = new Terminal({
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
cursorBlink: true,
theme: { background: "#0b0e14", foreground: "#d6dcea" },
});
fit = new FitAddon();
term.loadAddon(fit);
term.open(host.value!);
fit.fit();
// Fysiskt tangentbord -> pty (samma kanal som fallback-knapparna).
term.onData((d) => props.client.sendInput(props.sessionId, d));
props.client.handlers.onSnapshot = (sid, data) => {
if (sid !== props.sessionId || !term) return;
term.reset();
term.write(data);
};
props.client.handlers.onOutput = (sid, data) => {
if (sid === props.sessionId) term?.write(data);
};
props.client.subscribe(props.sessionId);
window.addEventListener("resize", onResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", onResize);
props.client.handlers.onSnapshot = undefined;
props.client.handlers.onOutput = undefined;
term?.dispose();
term = null;
});
</script>
<template>
<div class="terminal-view">
<div class="bar">
<span class="title">{{ meta.title }}</span>
<span class="status" :class="meta.status">{{ meta.status }}</span>
</div>
<div ref="host" class="xterm-host"></div>
<div class="toolbar">
<div class="group arrows">
<button @click="key('left')" aria-label="Vänster"></button>
<button @click="key('up')" aria-label="Upp"></button>
<button @click="key('down')" aria-label="Ner"></button>
<button @click="key('right')" aria-label="Höger"></button>
</div>
<div class="group">
<button @click="key('enter')"></button>
<button @click="key('esc')">Esc</button>
<button @click="key('tab')">Tab</button>
<button @click="key('ctrlc')">^C</button>
</div>
<div class="group">
<button class="yn" @click="key('y')">y</button>
<button class="yn" @click="key('n')">n</button>
</div>
</div>
</div>
</template>
<style scoped>
.terminal-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
background: var(--panel-2);
}
.bar .title {
font-size: 0.85rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar .status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
}
.bar .status.running { color: var(--green); border-color: color-mix(in srgb, var(--green) 50%, transparent); }
.bar .status.exited { color: var(--red); border-color: color-mix(in srgb, var(--red) 50%, transparent); }
.xterm-host {
flex: 1 1 auto;
min-height: 0;
padding: 0.5rem;
overflow: hidden;
background: #0b0e14;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
padding: 0.5rem;
border-top: 1px solid var(--border);
background: var(--panel-2);
}
.group { display: flex; gap: 0.3rem; }
.toolbar button {
min-width: 2.4rem;
height: 2.4rem;
padding: 0 0.6rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font-size: 0.95rem;
}
.toolbar button:active { background: var(--accent); color: #06121f; }
.toolbar .yn { font-weight: 700; }
@media (max-width: 600px) {
.toolbar { gap: 0.35rem 0.5rem; }
.toolbar button { min-width: 2.7rem; height: 2.7rem; }
}
</style>

View File

@@ -0,0 +1,90 @@
import { ref } from "vue";
import type { ServerToWeb, SessionMeta, WebMessage } from "@agent-helm/shared";
/** Callbacks som terminal-vyn registrerar för den session den visar. */
export interface HelmHandlers {
onSnapshot?: (sessionId: string, data: string, meta: SessionMeta) => void;
onOutput?: (sessionId: string, data: string) => void;
onExit?: (sessionId: string, code: number) => void;
}
export function createHelmClient() {
const connected = ref(false);
const connecting = ref(false);
const error = ref<string | null>(null);
const sessions = ref<SessionMeta[]>([]);
const handlers: HelmHandlers = {};
let ws: WebSocket | null = null;
function send(msg: WebMessage): void {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function connect(url: string, token: string): void {
error.value = null;
connecting.value = true;
ws = new WebSocket(url);
ws.onopen = () => send({ type: "web:hello", token });
ws.onmessage = (ev) => {
let msg: ServerToWeb;
try {
msg = JSON.parse(ev.data as string) as ServerToWeb;
} catch {
return;
}
switch (msg.type) {
case "server:welcome":
connected.value = true;
connecting.value = false;
break;
case "server:sessions":
sessions.value = msg.sessions;
break;
case "server:snapshot":
handlers.onSnapshot?.(msg.sessionId, msg.data, msg.meta);
break;
case "server:output":
handlers.onOutput?.(msg.sessionId, msg.data);
break;
case "server:exit":
sessions.value = sessions.value.map((s) =>
s.sessionId === msg.sessionId ? { ...s, status: "exited", exitCode: msg.code } : s,
);
handlers.onExit?.(msg.sessionId, msg.code);
break;
case "server:error":
error.value = msg.message;
connecting.value = false;
break;
}
};
ws.onclose = () => {
connected.value = false;
connecting.value = false;
};
ws.onerror = () => {
error.value = "Kunde inte ansluta till servern.";
connecting.value = false;
};
}
function subscribe(sessionId: string): void {
send({ type: "web:subscribe", sessionId });
}
function sendInput(sessionId: string, data: string): void {
send({ type: "web:input", sessionId, data });
}
function disconnect(): void {
ws?.close();
ws = null;
connected.value = false;
}
return { connected, connecting, error, sessions, handlers, connect, disconnect, subscribe, sendInput };
}
export type HelmClient = ReturnType<typeof createHelmClient>;

5
packages/web/src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");

View File

@@ -0,0 +1,31 @@
:root {
--bg: #0b0e14;
--panel: #11151f;
--panel-2: #161b29;
--border: #232a3b;
--text: #d6dcea;
--muted: #8a93a8;
--accent: #5aa6ff;
--green: #43d17a;
--red: #ff6b6b;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #app {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
button {
font: inherit;
cursor: pointer;
}

1
packages/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@agent-helm/shared": ["../shared/src/index.ts"]
},
"noEmit": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath } from "node:url";
// Pekar @agent-helm/shared direkt på TS-källan och låter Vite transpilera den
// som vanlig app-kod (slipper bygg-steg för shared i den tunna slicen).
const sharedSrc = fileURLToPath(new URL("../shared/src/index.ts", import.meta.url));
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
export default defineConfig({
plugins: [vue()],
envDir: repoRoot,
resolve: {
alias: {
"@agent-helm/shared": sharedSrc,
},
},
server: {
host: true,
fs: { allow: [repoRoot] },
},
});