Files
patterm/internal/app/app.go
Harry Bayliss 55c6c93086 Sync MCP surface to SPEC §7 process model
Rename list_children/read_output/kill/send_message_to to their SPEC §7
process_id-shaped names; drop report_to_parent (direction inferred by
send_message) and policy_check (replaced by per-project trust gating).
Add the SPEC's missing tools: start_process, restart_process,
close_process, rename_process, select_process, get_process_status,
get_project_status, get_process_raw_output, search_output,
get_process_ports, whoami, help.

Process model now distinguishes agent/terminal/command kinds with
opaque p_<6hex> IDs. Command entries are session-persistent so they
survive PTY exit and can be Restart'd. Status enum gains starting and
stopped. screen_version, port detection, and bracketed-paste send_input
land alongside.

Trust gating (internal/trust) replaces the regex policy: command-preset
spawns return needs_trust on first use; the user confirms in a
status-line modal and the grant persists to
\$XDG_DATA_HOME/patterm/projects/<key>/trust.json.

Tests cover send_message direction inference (parent↔child, sibling
rejection, nil caller paths) and trust grant persistence across reopen.
2026-05-14 14:29:45 +01:00

836 lines
21 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"sync"
"sync/atomic"
"syscall"
cpty "github.com/creack/pty"
"golang.org/x/term"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/harrybrwn/patterm/internal/scratchpad"
"github.com/harrybrwn/patterm/internal/trust"
)
// Options configures a patterm run.
type Options struct {
ProjectDir string
ProjectKey string
}
const keyCtrlK byte = 0x0b
// Run is patterm's single-process entry point. SPEC §2: one Go process
// owns everything; no daemon, no detach, no socket-based reattachment.
func Run(ctx context.Context, opts Options) error {
if opts.ProjectDir == "" {
return errors.New("app: ProjectDir required")
}
presets, err := preset.Load()
if err != nil {
return fmt.Errorf("app: load presets: %w", err)
}
// Ensure the per-project scratchpad dir exists so MCP and the UI
// can read/write into it. SPEC §3.
pads, err := scratchpad.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: scratchpad init: %w", err)
}
// Per-project trust store for command-preset trust gating (SPEC §7).
trustStore, err := trust.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: trust init: %w", err)
}
// In-process MCP server bound to the per-PID socket. Children that
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
// SPEC §10.
mcpSrv, err := mcp.Start()
if err != nil {
return fmt.Errorf("app: mcp start: %w", err)
}
defer mcpSrv.Close()
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
defer sess.Shutdown()
cols, rows := hostSize()
layout := newTerminalLayout(cols, rows)
// Launcher handles preset → child translation, including MCP
// config injection for agent presets.
launcher := NewLauncher(sess, mcpSrv.Socket(), layout.childCols(), layout.childRows())
// Wire the tool host into MCP. Spawns through MCP use the host
// terminal's viewport grid for their initial PTY size; SIGWINCH paths
// resize them later.
host := newToolHost(sess, pads, launcher, presets, trustStore, layout.childCols(), layout.childRows())
mcpSrv.SetHost(host)
var restoreState *term.State
if term.IsTerminal(int(os.Stdin.Fd())) {
st, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("app: stdin raw: %w", err)
}
restoreState = st
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
st := &uiState{
sess: sess,
presets: presets,
launcher: launcher,
pads: pads,
trust: trustStore,
hostCols: cols,
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
}
host.attention = st
host.focus = st
host.prompter = st
st.lastExit.Store(-1)
sess.Subscribe(st)
st.enterScreen()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
// Set initial PTY grid for any future child. The child gets the
// computed main viewport, excluding tab bar, sidebar, and status.
sess.ResizeAll(layout.childCols(), layout.childRows())
launcher.SetSize(layout.childCols(), layout.childRows())
host.SetSize(layout.childCols(), layout.childRows())
var wg sync.WaitGroup
// SIGWINCH.
wg.Add(1)
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
go func() {
defer wg.Done()
defer signal.Stop(winch)
for {
select {
case <-ctx.Done():
return
case <-winch:
c, r := hostSize()
if c == 0 || r == 0 {
continue
}
st.dimsMu.Lock()
st.hostCols, st.hostRows = c, r
l := st.layoutLocked()
st.dimsMu.Unlock()
st.mu.Lock()
if st.renderer != nil {
st.renderer.SetLayout(l)
}
st.mu.Unlock()
sess.ResizeAll(l.childCols(), l.childRows())
launcher.SetSize(l.childCols(), l.childRows())
host.SetSize(l.childCols(), l.childRows())
st.clearScreen()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
}
}()
// External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit).
wg.Add(1)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
go func() {
defer wg.Done()
defer signal.Stop(sigCh)
select {
case <-ctx.Done():
return
case sig := <-sigCh:
st.dbgf("signal %s; tearing down", sig)
cancel()
}
}()
// Stdin loop.
go func() {
if err := st.stdinLoop(); err != nil {
st.dbgf("stdinLoop: %v", err)
}
cancel()
}()
<-ctx.Done()
wg.Wait()
st.leaveScreen()
if restoreState != nil {
_ = term.Restore(int(os.Stdin.Fd()), restoreState)
}
if st.lastExit.Load() >= 0 {
fmt.Fprintf(os.Stderr, "patterm: last child exited (%d).\n", st.lastExit.Load())
}
return nil
}
// uiState is the shared state between the SIGWINCH loop, the stdin
// loop, and the session listener callbacks.
type uiState struct {
sess *Session
presets preset.Set
launcher *Launcher
pads *scratchpad.Store
trust *trust.Store
outMu sync.Mutex
mu sync.Mutex
palette *paletteState
focusedID string
focusedName string
// renderer confines focused-child live output to the main viewport.
// A fresh renderer is allocated per focused child so partial-escape
// state cannot bleed between panes.
renderer *viewportRenderer
// passthrough: when true, the next keystroke is forwarded to the
// focused PTY untouched (SPEC §4 Ctrl-K Ctrl-K).
passthroughArmed bool
// attention is the latest request_human_attention surfaced via MCP;
// rendered in the status line until cleared.
attentionText string
attentionAt string
// pendingTrust is the most recent trust prompt — surfaced in the
// status line until the user resolves it with Ctrl-K. v1 keeps the
// confirmation modal minimal: the user opens the palette and picks
// "Trust preset <name>" / "Deny preset <name>". A future iteration
// can promote this to a dedicated inline modal.
pendingTrust *trustRequest
dimsMu sync.Mutex
hostCols, hostRows uint16
stdinTTY bool
lastExit atomic.Int32
}
func (st *uiState) dbgf(format string, args ...any) {
logf(format, args...)
}
// trustRequest is one outstanding SPEC §7 trust prompt: an agent tried
// to spawn / start / restart against an untrusted command preset and
// the host wants user confirmation before the next attempt succeeds.
type trustRequest struct {
processID string
presetName string
reason string
}
// promptTrust is the SPEC §7 trust gate UI hook. Replaces any prior
// pending request — the most recent prompt wins.
func (st *uiState) promptTrust(processID, presetName, reason string) {
st.mu.Lock()
st.pendingTrust = &trustRequest{processID: processID, presetName: presetName, reason: reason}
st.mu.Unlock()
st.drawStatusLine()
}
// focusProcess is the SPEC §7 select_process hook. Routes through the
// normal focus-change path; only takes effect if the process exists.
func (st *uiState) focusProcess(processID string) {
c := st.sess.FindChild(processID)
if c == nil {
return
}
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.renderer = newViewportRenderer(st.layoutSnapshot())
st.mu.Unlock()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most
// recent ask so the status line keeps showing it. The sidebar-blink is
// deferred until the §4 chrome lands.
func (st *uiState) notifyAttention(childID, reason string) {
c := st.sess.FindChild(childID)
name := childID
if c != nil {
name = c.Name
}
st.mu.Lock()
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
st.attentionAt = childID
st.mu.Unlock()
st.drawStatusLine()
}
// OnChildSpawned auto-focuses the new child.
func (st *uiState) OnChildSpawned(c *Child) {
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.rebuild()
st.renderPaletteLocked()
}
st.mu.Unlock()
st.moveToViewportOrigin()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// OnChildExited drops focus and shows the empty state if it was the
// focused child.
func (st *uiState) OnChildExited(c *Child) {
st.lastExit.Store(int32(c.ExitCode()))
st.mu.Lock()
if c.ID == st.focusedID {
next := firstRunningTopLevel(st.sess.Children())
if next == nil {
st.focusedID = ""
st.focusedName = ""
st.renderEmptyStateLocked()
} else {
st.focusedID = next.ID
st.focusedName = next.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
}
}
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.rebuild()
st.renderPaletteLocked()
}
st.mu.Unlock()
if st.focusedID != "" {
st.repaintFocused()
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// OnPTYOut writes live output for the focused child when the palette is
// not covering the screen. The viewport renderer shifts cursor movement
// into the main pane and rewrites destructive clears. Host autowrap is
// disabled only around the replay so long styled runs cannot wrap into
// the right rail.
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.mu.Lock()
focus := st.focusedID
palOpen := st.palette != nil
renderer := st.renderer
st.mu.Unlock()
if palOpen || focus != childID || renderer == nil {
return
}
out := renderer.Render(chunk)
st.outMu.Lock()
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
_, _ = os.Stdout.Write(out)
_, _ = os.Stdout.Write([]byte("\x1b[?7h"))
st.outMu.Unlock()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) enterScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h"))
}
func (st *uiState) leaveScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[?1049l"))
}
func (st *uiState) clearScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
}
func (st *uiState) moveToViewportOrigin() {
layout := st.layoutSnapshot()
st.outMu.Lock()
defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b[%d;%dH", int(layout.mainTop), int(layout.mainLeft))
}
func (st *uiState) renderPaletteLocked() {
if st.palette == nil {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
cols, rows := st.hostSizeSnapshot()
st.palette.render(wrapWriter(os.Stdout), int(cols), int(rows))
}
// drawStatusLine renders SPEC §4's bottom status line. Left side: input
// ownership toast ("orchestrator driving" / "you have control") and any
// attention ask. Right side: palette hint. The PTY child occupies
// host_rows-1 rows so this row is exclusively ours.
func (st *uiState) drawStatusLine() {
st.mu.Lock()
palOpen := st.palette != nil
focusID := st.focusedID
focusName := st.focusedName
attention := st.attentionText
attentionAt := st.attentionAt
var trustMsg string
if st.pendingTrust != nil {
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
}
st.mu.Unlock()
if palOpen {
return
}
cols, rows := st.hostSizeSnapshot()
if cols == 0 || rows == 0 {
return
}
owner := ""
if focusID != "" {
if c := st.sess.FindChild(focusID); c != nil {
switch c.Owner() {
case OwnerOrchestrator:
owner = "orchestrator driving"
case OwnerUser:
owner = "you have control"
}
}
}
left := ""
if focusName != "" {
left = focusName
}
if owner != "" {
if left != "" {
left = left + " · " + owner
} else {
left = owner
}
}
if attention != "" && attentionAt == focusID {
left = "[!] " + attention
}
if attention != "" && attentionAt == "" {
// Sticky attention/flash from somewhere outside the focused pane.
left = "[!] " + attention
}
if trustMsg != "" {
left = "[trust] " + trustMsg
}
right := "Ctrl-K · palette"
pad := int(cols) - len(left) - len(right)
if pad < 1 {
pad = 1
}
line := left + strings.Repeat(" ", pad) + right
if len(line) > int(cols) {
line = line[:int(cols)]
}
st.outMu.Lock()
defer st.outMu.Unlock()
// Save cursor, move to last row col 1, write, restore.
fmt.Fprintf(os.Stdout, "\x1b7\x1b[999;1H\x1b[2m\x1b[7m%s\x1b[0m\x1b8", line)
}
// renderEmptyState is the SPEC §4 blank-canvas hint. Drawn whenever no
// child is focused.
func (st *uiState) renderEmptyState() {
st.mu.Lock()
defer st.mu.Unlock()
st.renderEmptyStateLocked()
}
func (st *uiState) renderEmptyStateLocked() {
st.outMu.Lock()
defer st.outMu.Unlock()
layout := st.layoutSnapshot()
line := "Press Ctrl-K to spawn an agent or process"
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
if row < int(layout.mainTop) {
row = int(layout.mainTop)
}
if col < int(layout.mainLeft) {
col = int(layout.mainLeft)
}
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
}
func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
st.dimsMu.Lock()
defer st.dimsMu.Unlock()
return st.hostCols, st.hostRows
}
func (st *uiState) layoutSnapshot() terminalLayout {
st.dimsMu.Lock()
defer st.dimsMu.Unlock()
return st.layoutLocked()
}
func (st *uiState) layoutLocked() terminalLayout {
return newTerminalLayout(st.hostCols, st.hostRows)
}
func (st *uiState) stdinLoop() error {
buf := make([]byte, 4096)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
st.processStdin(buf[:n])
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("read error %w (n=%d)", err, n)
}
}
}
// processStdin walks one read of stdin byte by byte. The palette
// intercepts everything when it's open. Otherwise Ctrl-K opens it and
// every other byte forwards to the focused PTY. The Ctrl-K Ctrl-K chord
// is SPEC §4's passthrough prefix: after the first Ctrl-K, if the very
// next byte is another Ctrl-K, both are sent to the PTY literally.
//
// NOTE on locking: a palette-close action (spawn / switch / kill /
// quit) may fire session listeners (OnChildSpawned, OnChildExited)
// synchronously. Those listeners need st.mu. We must NOT hold st.mu
// when calling closePalette — bytes after the action in the same chunk
// are dropped on the floor, which is the right behavior anyway (the
// user just decided the prior pane is gone).
func (st *uiState) processStdin(chunk []byte) {
st.mu.Lock()
// Trust modal is modal: y/Y accepts, n/N or ESC denies. Everything
// else is ignored so a typo doesn't leak into the focused PTY while
// the prompt is up. SPEC §7 trust gate.
if st.pendingTrust != nil {
req := *st.pendingTrust
consumed := 0
var resolved string
for _, b := range chunk {
consumed++
switch b {
case 'y', 'Y':
resolved = "accept"
case 'n', 'N', 0x1b: // ESC
resolved = "deny"
default:
continue
}
break
}
if resolved != "" {
st.pendingTrust = nil
st.mu.Unlock()
if resolved == "accept" {
if err := st.trust.Grant(req.presetName); err != nil {
st.flashError(fmt.Sprintf("trust grant: %v", err))
} else {
st.flashTransient(fmt.Sprintf("trusted preset %q (retry the call)", req.presetName))
}
} else {
st.flashTransient(fmt.Sprintf("denied trust for preset %q", req.presetName))
}
st.drawStatusLine()
// Discard the rest of the chunk; we intentionally don't
// recurse into the regular handler so a stray Enter doesn't
// submit anything to the focused PTY.
_ = consumed
return
}
st.mu.Unlock()
return
}
forward := make([]byte, 0, len(chunk))
flushForward := func() {
if len(forward) == 0 {
return
}
if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner()
_ = c.InjectAsUser(forward)
if prev != OwnerUser {
go st.drawStatusLine()
}
}
}
forward = forward[:0]
}
var pendingAction *paletteAction
i := 0
for i < len(chunk) {
b := chunk[i]
// Passthrough armed: forward this byte literally regardless of
// what it is, then disarm.
if st.passthroughArmed {
forward = append(forward, b)
st.passthroughArmed = false
i++
continue
}
// Palette mode swallows all bytes.
if st.palette != nil {
var peek []byte
if i+1 < len(chunk) {
peek = chunk[i+1:]
}
action, done := st.palette.handleKey(b, peek)
if b == 0x1b && len(peek) >= 2 && peek[0] == '[' {
if peek[1] == 'A' || peek[1] == 'B' {
i += 3
} else {
i++
}
} else {
i++
}
if done {
a := action
pendingAction = &a
break
}
st.renderPaletteLocked()
continue
}
// Ctrl-K is the reserved app-level binding. Two cases:
// - Ctrl-K then anything except Ctrl-K → open palette.
// - Ctrl-K Ctrl-K → arm passthrough; the next byte goes raw.
if b == keyCtrlK {
// Peek at the next byte if we have it.
next := byte(0)
haveNext := i+1 < len(chunk)
if haveNext {
next = chunk[i+1]
}
if haveNext && next == keyCtrlK {
// Chord: forward both Ctrl-K bytes literally. (Some
// nested TUIs expect Ctrl-K itself.)
flushForward()
forward = append(forward, keyCtrlK, keyCtrlK)
flushForward()
i += 2
continue
}
if !haveNext {
// Could be the first byte of a chord — arm and wait.
st.passthroughArmed = true
// But we also want palette-open on a lone Ctrl-K. Resolve
// by treating "Ctrl-K at end of read" as palette open;
// any subsequent Ctrl-K in the next read still has the
// chord semantics because passthroughArmed got set first.
// To match the spec's reading, simpler model: lone Ctrl-K
// in this read opens the palette.
st.passthroughArmed = false
flushForward()
st.openPaletteLocked()
i++
continue
}
// Ctrl-K followed by something that's not Ctrl-K → palette open.
flushForward()
st.openPaletteLocked()
i++
continue
}
forward = append(forward, b)
i++
}
flushForward()
st.mu.Unlock()
if pendingAction != nil {
st.closePalette(*pendingAction)
}
}
func (st *uiState) openPaletteLocked() {
st.palette = newPalette(st.sess.Children(), st.focusedID, st.presets)
st.renderPaletteLocked()
}
// closePalette is invoked with st.mu UNLOCKED. The session-mutating
// actions below (spawn / kill) fire listeners that take st.mu, so
// holding it here would deadlock. Each helper this calls takes its own
// brief mu acquisitions as needed.
func (st *uiState) closePalette(action paletteAction) {
st.mu.Lock()
st.palette = nil
st.mu.Unlock()
st.clearScreen()
switch action.kind {
case "", "cancel":
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "spawn-agent":
if action.preset == nil {
st.repaintFocused()
return
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
// LaunchAgent fires OnChildSpawned synchronously; it will draw
// chrome and set focus.
if _, err := st.launcher.LaunchAgent(action.preset, action.preset.Name, "", ""); err != nil {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
case "spawn-process":
if action.preset == nil {
st.repaintFocused()
return
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, ""); err != nil {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
case "switch":
c := st.sess.FindChild(action.childID)
if c == nil || c.Status() != StatusRunning {
st.repaintFocused()
return
}
st.mu.Lock()
st.focusedID = action.childID
st.focusedName = c.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
st.mu.Unlock()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "kill":
_ = st.sess.Kill(action.childID, syscall.SIGTERM)
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "quit":
st.requestExit()
}
}
// flashError surfaces a spawn/etc. failure in the status line until the
// next attention update overwrites it. stderr is hidden under the alt
// screen so we can't rely on Fprintln(os.Stderr).
func (st *uiState) flashError(msg string) {
st.mu.Lock()
st.attentionText = msg
st.attentionAt = "" // shows on every focus until cleared
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// flashTransient is the softer cousin of flashError used for
// trust-prompt resolutions. Same status-line surface; the prefix differs.
func (st *uiState) flashTransient(msg string) {
st.mu.Lock()
st.attentionText = msg
st.attentionAt = ""
st.mu.Unlock()
st.drawStatusLine()
}
// repaintFocused redraws the current focused child's screen snapshot.
// Callers must NOT hold st.mu — repaintFocused takes it
// briefly itself.
func (st *uiState) repaintFocused() {
st.mu.Lock()
id := st.focusedID
st.mu.Unlock()
if id == "" {
st.renderEmptyState()
return
}
text, cursor, err := st.sess.SnapshotChild(id)
if err != nil {
return
}
out := renderScreenSnapshot(text, cursor, st.layoutSnapshot())
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
}
func (st *uiState) requestExit() {
// Reuse SIGTERM-to-self as the cleanest way to unwind: the signal
// handler in Run() calls cancel() which exits the loop and runs
// Shutdown.
_ = syscall.Kill(os.Getpid(), syscall.SIGTERM)
}
func hostSize() (cols, rows uint16) {
ws, err := cpty.GetsizeFull(os.Stdin)
if err != nil || ws.Cols == 0 || ws.Rows == 0 {
return 120, 40
}
return ws.Cols, ws.Rows
}