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:
176
packages/web/src/components/TerminalView.vue
Normal file
176
packages/web/src/components/TerminalView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user