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

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>