airloom 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +531 -252
  2. package/package.json +3 -1
package/dist/index.js CHANGED
@@ -609,7 +609,7 @@ import QRCode from "qrcode";
609
609
  // src/server.ts
610
610
  import express from "express";
611
611
  import { createServer } from "http";
612
- import { existsSync as existsSync2 } from "fs";
612
+ import { existsSync as existsSync3 } from "fs";
613
613
  import { WebSocketServer, WebSocket } from "ws";
614
614
 
615
615
  // src/adapters/anthropic.ts
@@ -1025,6 +1025,179 @@ function getConfigPath() {
1025
1025
  return CONFIG_PATH;
1026
1026
  }
1027
1027
 
1028
+ // src/terminal.ts
1029
+ import { basename, delimiter as delimiter2, isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "path";
1030
+ import { existsSync as existsSync2 } from "fs";
1031
+ import { spawn as spawn2 } from "node-pty";
1032
+ function resolveExecutable2(command, envPath = process.env.PATH ?? "") {
1033
+ if (!command) return null;
1034
+ if (isAbsolute2(command) && existsSync2(command)) return command;
1035
+ if (command.includes("/")) {
1036
+ const candidate = resolve2(process.cwd(), command);
1037
+ return existsSync2(candidate) ? candidate : null;
1038
+ }
1039
+ for (const dir of envPath.split(delimiter2)) {
1040
+ if (!dir) continue;
1041
+ const candidate = join3(dir.replace(/^~(?=$|\/)/, process.env.HOME ?? "~"), command);
1042
+ if (existsSync2(candidate)) return candidate;
1043
+ }
1044
+ return null;
1045
+ }
1046
+ function parseCommand(command) {
1047
+ const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [command];
1048
+ return {
1049
+ file: parts[0],
1050
+ args: parts.slice(1).map((part) => part.replace(/^"|"$/g, ""))
1051
+ };
1052
+ }
1053
+ function getDefaultTerminalCommand(explicitCommand) {
1054
+ const configured = explicitCommand?.trim() || process.env.AIRLOOM_TERMINAL_COMMAND?.trim();
1055
+ if (configured) return parseCommand(configured);
1056
+ if (process.platform === "win32") {
1057
+ const file = process.env.COMSPEC || "powershell.exe";
1058
+ return { file, args: [] };
1059
+ }
1060
+ const shell = process.env.SHELL || "/bin/bash";
1061
+ const name = basename(shell);
1062
+ if (name === "bash" || name === "zsh" || name === "sh") return { file: shell, args: ["-il"] };
1063
+ return { file: shell, args: ["-i"] };
1064
+ }
1065
+ var AdaptiveOutputBatcher = class {
1066
+ constructor(onFlush, fastInterval = 16, slowInterval = 80, interactiveWindow = 250, maxBytes = 4096) {
1067
+ this.onFlush = onFlush;
1068
+ this.fastInterval = fastInterval;
1069
+ this.slowInterval = slowInterval;
1070
+ this.interactiveWindow = interactiveWindow;
1071
+ this.maxBytes = maxBytes;
1072
+ }
1073
+ buffer = "";
1074
+ timer = null;
1075
+ lastInputAt = 0;
1076
+ noteInput() {
1077
+ this.lastInputAt = Date.now();
1078
+ if (this.timer) {
1079
+ clearTimeout(this.timer);
1080
+ this.timer = null;
1081
+ }
1082
+ if (this.buffer) this.schedule();
1083
+ }
1084
+ write(data) {
1085
+ this.buffer += data;
1086
+ if (this.buffer.length >= this.maxBytes) {
1087
+ this.flush();
1088
+ return;
1089
+ }
1090
+ this.schedule();
1091
+ }
1092
+ flush() {
1093
+ if (this.timer) {
1094
+ clearTimeout(this.timer);
1095
+ this.timer = null;
1096
+ }
1097
+ if (!this.buffer) return;
1098
+ const data = this.buffer;
1099
+ this.buffer = "";
1100
+ this.onFlush(data);
1101
+ }
1102
+ destroy() {
1103
+ this.flush();
1104
+ }
1105
+ schedule() {
1106
+ if (this.timer) return;
1107
+ const recentInput = Date.now() - this.lastInputAt <= this.interactiveWindow;
1108
+ this.timer = setTimeout(() => this.flush(), recentInput ? this.fastInterval : this.slowInterval);
1109
+ }
1110
+ };
1111
+ function getTerminalLaunchDisplay(explicitCommand) {
1112
+ const command = getDefaultTerminalCommand(explicitCommand);
1113
+ return [command.file, ...command.args].join(" ");
1114
+ }
1115
+ var TerminalSession = class {
1116
+ constructor(channel, getLaunchCommand) {
1117
+ this.channel = channel;
1118
+ this.getLaunchCommand = getLaunchCommand;
1119
+ }
1120
+ pty = null;
1121
+ stream = null;
1122
+ batcher = null;
1123
+ cols = 120;
1124
+ rows = 36;
1125
+ handleMessage(message) {
1126
+ switch (message.type) {
1127
+ case "terminal_open":
1128
+ this.open(message);
1129
+ break;
1130
+ case "terminal_input":
1131
+ this.writeInput(message);
1132
+ break;
1133
+ case "terminal_resize":
1134
+ this.resize(message);
1135
+ break;
1136
+ case "terminal_close":
1137
+ this.close();
1138
+ break;
1139
+ case "terminal_exit":
1140
+ break;
1141
+ }
1142
+ }
1143
+ close() {
1144
+ this.batcher?.destroy();
1145
+ this.batcher = null;
1146
+ if (this.stream && !this.stream.ended) this.stream.end();
1147
+ this.stream = null;
1148
+ try {
1149
+ this.pty?.kill();
1150
+ } catch {
1151
+ }
1152
+ this.pty = null;
1153
+ }
1154
+ open(message) {
1155
+ this.cols = Math.max(20, Math.floor(message.cols || this.cols));
1156
+ this.rows = Math.max(5, Math.floor(message.rows || this.rows));
1157
+ if (this.pty) {
1158
+ this.pty.resize(this.cols, this.rows);
1159
+ return;
1160
+ }
1161
+ const command = getDefaultTerminalCommand(this.getLaunchCommand?.());
1162
+ const file = resolveExecutable2(command.file) ?? command.file;
1163
+ const meta = { kind: "terminal", cols: this.cols, rows: this.rows };
1164
+ this.stream = this.channel.createStream(meta);
1165
+ this.batcher = new AdaptiveOutputBatcher((data) => this.stream?.write(data));
1166
+ const env = { ...process.env, TERM: "xterm-256color" };
1167
+ this.pty = spawn2(file, command.args, {
1168
+ name: "xterm-256color",
1169
+ cols: this.cols,
1170
+ rows: this.rows,
1171
+ cwd: process.cwd(),
1172
+ env
1173
+ });
1174
+ this.pty.onData((data) => this.batcher?.write(data));
1175
+ this.pty.onExit(({ exitCode, signal }) => {
1176
+ this.batcher?.flush();
1177
+ this.stream?.end();
1178
+ this.channel.send({ type: "terminal_exit", exitCode, signal });
1179
+ this.stream = null;
1180
+ this.batcher = null;
1181
+ this.pty = null;
1182
+ });
1183
+ }
1184
+ writeInput(message) {
1185
+ if (!this.pty) return;
1186
+ this.batcher?.noteInput();
1187
+ this.pty.write(message.data);
1188
+ }
1189
+ resize(message) {
1190
+ this.cols = Math.max(20, Math.floor(message.cols || this.cols));
1191
+ this.rows = Math.max(5, Math.floor(message.rows || this.rows));
1192
+ this.pty?.resize(this.cols, this.rows);
1193
+ }
1194
+ };
1195
+ function isTerminalMessage(data) {
1196
+ if (!data || typeof data !== "object" || !("type" in data)) return false;
1197
+ const type = data.type;
1198
+ return type === "terminal_open" || type === "terminal_input" || type === "terminal_resize" || type === "terminal_close" || type === "terminal_exit";
1199
+ }
1200
+
1028
1201
  // src/server.ts
1029
1202
  var MAX_MESSAGES = 200;
1030
1203
  function trimMessages(messages) {
@@ -1043,7 +1216,7 @@ function createHostServer(opts) {
1043
1216
  app.get("/", (_req, res) => {
1044
1217
  res.type("html").send(HOST_HTML);
1045
1218
  });
1046
- if (opts.viewerDir && existsSync2(opts.viewerDir)) {
1219
+ if (opts.viewerDir && existsSync3(opts.viewerDir)) {
1047
1220
  app.use("/viewer", express.static(opts.viewerDir));
1048
1221
  }
1049
1222
  app.get("/api/status", (_req, res) => {
@@ -1093,19 +1266,17 @@ function createHostServer(opts) {
1093
1266
  break;
1094
1267
  }
1095
1268
  case "cli": {
1096
- const cmd = command || process.env.AIRLOOM_CLI_COMMAND;
1097
- if (!cmd) {
1098
- res.status(400).json({ error: "CLI adapter requires a command (or set AIRLOOM_CLI_COMMAND env var)" });
1099
- return;
1100
- }
1101
- const presetInfo = preset ? CLI_PRESETS.find((p) => p.id === preset) : void 0;
1102
- opts.state.adapter = new CLIAdapter({
1103
- command: cmd,
1104
- model,
1105
- mode: presetInfo?.mode,
1106
- silenceTimeout: presetInfo?.silenceTimeout
1107
- });
1108
- break;
1269
+ const selectedPreset = typeof preset === "string" ? preset : "shell";
1270
+ const presetInfo = CLI_PRESETS.find((p) => p.id === selectedPreset);
1271
+ const cmd = selectedPreset === "shell" ? void 0 : typeof command === "string" && command.trim() ? command.trim() : presetInfo?.command;
1272
+ opts.state.terminalLaunchCommand = cmd;
1273
+ opts.state.terminalLaunch = getTerminalLaunchDisplay(cmd);
1274
+ opts.state.adapter = null;
1275
+ const cfg2 = { type: "terminal", preset: selectedPreset, command: cmd };
1276
+ saveConfig(cfg2);
1277
+ broadcast({ type: "terminal_configured", terminalLaunch: opts.state.terminalLaunch });
1278
+ res.json({ ok: true, terminalLaunch: opts.state.terminalLaunch });
1279
+ return;
1109
1280
  }
1110
1281
  default:
1111
1282
  res.status(400).json({ error: "Unknown adapter type" });
@@ -1118,7 +1289,7 @@ function createHostServer(opts) {
1118
1289
  cfg.preset = preset;
1119
1290
  }
1120
1291
  saveConfig(cfg);
1121
- broadcast({ type: "configured", adapter: { name: opts.state.adapter.name, model: opts.state.adapter.model } });
1292
+ broadcast({ type: "configured", adapter: { name: opts.state.adapter?.name ?? "none", model: opts.state.adapter?.model ?? "" } });
1122
1293
  res.json({ ok: true });
1123
1294
  } catch (err) {
1124
1295
  const message = err instanceof Error ? err.message : "Configuration failed";
@@ -1142,6 +1313,18 @@ function createHostServer(opts) {
1142
1313
  wss.on("connection", (ws) => {
1143
1314
  uiClients.add(ws);
1144
1315
  ws.on("close", () => uiClients.delete(ws));
1316
+ ws.on("message", (data) => {
1317
+ try {
1318
+ const message = JSON.parse(data.toString());
1319
+ if (message.type && message.type.startsWith("terminal_")) {
1320
+ if (opts.state.channel) {
1321
+ opts.state.channel.send(message);
1322
+ }
1323
+ }
1324
+ } catch (err) {
1325
+ console.error("[host] Invalid WebSocket message:", err);
1326
+ }
1327
+ });
1145
1328
  });
1146
1329
  function broadcast(data) {
1147
1330
  const msg = JSON.stringify(data);
@@ -1251,6 +1434,18 @@ var HOST_HTML = `<!DOCTYPE html>
1251
1434
  @keyframes dot-pulse{0%,80%,100%{opacity:.25;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}
1252
1435
  .input-area{display:flex;gap:8px}
1253
1436
  .input-area textarea{flex:1;resize:none;min-height:44px;max-height:120px;padding:10px 14px;border-radius:8px;border:1px solid #333;background:#111;color:#e0e0e0;font-family:inherit;font-size:.9rem}
1437
+
1438
+ /* Terminal mode styles */
1439
+ .terminal-mode .chat-only { display: none !important; }
1440
+ .terminal-mode .terminal-only { display: block; }
1441
+ .chat-mode .terminal-only { display: none !important; }
1442
+ .chat-mode .chat-only { display: block; }
1443
+
1444
+ .terminal-container{background:#05070c;border:1px solid #2a2a2a;border-radius:8px;height:400px;overflow:hidden;margin-bottom:12px}
1445
+ #terminal{width:100%;height:100%;padding:8px}
1446
+ .terminal-toolbar{display:flex;gap:8px;margin-bottom:12px}
1447
+ .tool-btn{padding:6px 12px;font-size:.85rem;background:#333;border:none;border-radius:6px;color:#e0e0e0;cursor:pointer}
1448
+ .tool-btn:hover{background:#444}
1254
1449
  </style>
1255
1450
  </head>
1256
1451
  <body>
@@ -1263,277 +1458,358 @@ var HOST_HTML = `<!DOCTYPE html>
1263
1458
  <div class="status"><div class="dot wait" id="dot"></div><span id="statusText">Initializing...</span></div>
1264
1459
  <p style="color:#888;font-size:.9rem;margin-top:8px" id="launchText">Launch: current shell</p>
1265
1460
  </div>
1266
- <div class="card" id="configCard">
1267
- <h2>AI Configuration</h2>
1268
- <div class="config-form">
1269
- <select id="adapterType"><option value="anthropic">Anthropic (Claude)</option><option value="openai">OpenAI (GPT)</option><option value="cli">CLI Tool</option></select>
1270
- <input type="password" id="apiKey" placeholder="API Key"/>
1271
- <input type="text" id="model" placeholder="Model (optional)"/>
1272
- <div id="cliConfig" style="display:none">
1273
- <select id="cliPreset" style="margin-bottom:8px"></select>
1274
- <input type="text" id="command" placeholder="CLI command (prompt appended as last arg)"/>
1461
+
1462
+ <!-- Chat mode UI -->
1463
+ <div id="chatMode" class="chat-mode">
1464
+ <div class="card" id="configCard">
1465
+ <h2>AI Configuration</h2>
1466
+ <div class="config-form">
1467
+ <select id="adapterType">
1468
+ <option value="anthropic">Anthropic Claude</option>
1469
+ <option value="openai">OpenAI GPT</option>
1470
+ <option value="cli">CLI Adapter</option>
1471
+ </select>
1472
+ <input type="text" id="model" placeholder="Model (optional)" />
1473
+ <input type="text" id="apiKey" placeholder="API Key (if not set in environment)" />
1474
+ <button onclick="configureAdapter()">Configure Adapter</button>
1475
+ </div>
1476
+ </div>
1477
+
1478
+ <div class="card chat-only" id="chatCard" style="display:none">
1479
+ <h2>Chat Messages</h2>
1480
+ <div class="messages" id="messages"></div>
1481
+ <div class="input-area chat-only">
1482
+ <textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
1483
+ <button onclick="sendMessage()">Send</button>
1484
+ </div>
1485
+ </div>
1486
+ </div>
1487
+
1488
+ <!-- Terminal mode UI -->
1489
+ <div id="terminalMode" class="terminal-mode" style="display:none">
1490
+ <div class="card">
1491
+ <h2>Terminal Configuration</h2>
1492
+ <div class="config-form">
1493
+ <select id="cliPreset"></select>
1494
+ <input type="text" id="command" placeholder="Custom launch command" style="display:none"/>
1275
1495
  <p style="color:#666;font-size:.8rem;margin-top:4px" id="presetDesc"></p>
1496
+ <button onclick="configureTerminal()">Apply Launch Target</button>
1497
+ </div>
1498
+ </div>
1499
+
1500
+ <div class="card terminal-only">
1501
+ <h2>Terminal Session</h2>
1502
+ <div class="terminal-toolbar terminal-only">
1503
+ <button class="tool-btn" onclick="focusTerminal()">Focus</button>
1504
+ <button class="tool-btn" onclick="sendCtrlC()">Ctrl+C</button>
1505
+ <button class="tool-btn" onclick="sendEsc()">Esc</button>
1506
+ <button class="tool-btn" onclick="sendTab()">Tab</button>
1507
+ </div>
1508
+ <div class="terminal-container terminal-only">
1509
+ <div id="terminal"></div>
1276
1510
  </div>
1277
- <button onclick="configure()">Configure</button>
1278
1511
  </div>
1279
1512
  </div>
1513
+
1280
1514
  <div class="card pairing" id="pairingCard" style="display:none">
1281
1515
  <h2>Connect Your Phone</h2>
1282
1516
  <img id="qrCode" alt="QR Code"/>
1283
1517
  <div class="pairing-code" id="pairingCode"></div>
1284
1518
  <p style="color:#888;font-size:.85rem">Scan QR or enter code in viewer</p>
1285
1519
  </div>
1286
- <div class="card" id="chatCard" style="display:none">
1287
- <h2>Conversation</h2>
1288
- <div class="messages" id="messages"></div>
1289
- <div class="input-area">
1290
- <textarea id="msgInput" placeholder="Type a message..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMsg()}"></textarea>
1291
- <button onclick="sendMsg()">Send</button>
1292
- </div>
1293
- </div>
1294
1520
  </div>
1295
- <script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
1296
1521
  <script>
1297
- if(typeof marked!=='undefined'){marked.setOptions({gfm:true,breaks:true})}
1298
- function renderMd(t){try{return typeof marked!=='undefined'?marked.parse(t):'<pre>'+t.replace(/</g,'&lt;')+'</pre>'}catch{return '<pre>'+t.replace(/</g,'&lt;')+'</pre>'}}
1522
+ // Import xterm dynamically to avoid bundling issues
1523
+ const loadTerminal = async () => {
1524
+ if (window.Terminal) return;
1525
+ const [{ Terminal }, { FitAddon }] = await Promise.all([
1526
+ import('@xterm/xterm'),
1527
+ import('@xterm/addon-fit')
1528
+ ]);
1529
+ window.Terminal = Terminal;
1530
+ window.FitAddon = FitAddon;
1531
+ };
1532
+
1299
1533
  const ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
1300
- let curResp='',streamEl=null,cliPresets=[];
1301
- // Load CLI presets
1534
+ let cliPresets=[];
1535
+ let currentMode = null; // 'chat' or 'terminal'
1536
+ let term = null;
1537
+ let fitAddon = null;
1538
+
1539
+ // Mode detection and UI switching
1540
+ function switchToMode(mode) {
1541
+ if (currentMode === mode) return;
1542
+ currentMode = mode;
1543
+
1544
+ const chatMode = document.getElementById('chatMode');
1545
+ const terminalMode = document.getElementById('terminalMode');
1546
+
1547
+ if (mode === 'terminal') {
1548
+ chatMode.style.display = 'none';
1549
+ terminalMode.style.display = 'block';
1550
+ document.body.classList.add('terminal-mode');
1551
+ document.body.classList.remove('chat-mode');
1552
+ initTerminal();
1553
+ } else {
1554
+ chatMode.style.display = 'block';
1555
+ terminalMode.style.display = 'none';
1556
+ document.body.classList.add('chat-mode');
1557
+ document.body.classList.remove('terminal-mode');
1558
+ }
1559
+ }
1560
+
1561
+ // Terminal functionality
1562
+ async function initTerminal() {
1563
+ if (term) return;
1564
+ await loadTerminal();
1565
+
1566
+ term = new window.Terminal({
1567
+ cursorBlink: true,
1568
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
1569
+ fontSize: 14,
1570
+ lineHeight: 1.25,
1571
+ allowTransparency: true,
1572
+ scrollback: 5000,
1573
+ theme: {
1574
+ background: '#05070c',
1575
+ foreground: '#e6edf3',
1576
+ cursor: '#7c8aff',
1577
+ cursorAccent: '#05070c',
1578
+ selectionBackground: 'rgba(124,138,255,0.28)',
1579
+ black: '#0a0d14',
1580
+ red: '#ff7b72',
1581
+ green: '#3fb950',
1582
+ yellow: '#d29922',
1583
+ blue: '#7c8aff',
1584
+ magenta: '#bc8cff',
1585
+ cyan: '#39c5cf',
1586
+ white: '#c9d1d9',
1587
+ brightBlack: '#6e7681',
1588
+ brightRed: '#ffa198',
1589
+ brightGreen: '#56d364',
1590
+ brightYellow: '#e3b341',
1591
+ brightBlue: '#a5b4ff',
1592
+ brightMagenta: '#d2a8ff',
1593
+ brightCyan: '#56d4dd',
1594
+ brightWhite: '#f0f6fc',
1595
+ },
1596
+ });
1597
+
1598
+ fitAddon = new window.FitAddon();
1599
+ term.loadAddon(fitAddon);
1600
+ term.open(document.getElementById('terminal'));
1601
+
1602
+ term.onData((data) => {
1603
+ if (ws.readyState === WebSocket.OPEN) {
1604
+ ws.send(JSON.stringify({ type: 'terminal_input', data }));
1605
+ }
1606
+ });
1607
+
1608
+ // Handle terminal resize
1609
+ const resizeObserver = new ResizeObserver(() => {
1610
+ if (fitAddon && term) {
1611
+ fitAddon.fit();
1612
+ if (ws.readyState === WebSocket.OPEN) {
1613
+ ws.send(JSON.stringify({
1614
+ type: 'terminal_resize',
1615
+ cols: term.cols,
1616
+ rows: term.rows
1617
+ }));
1618
+ }
1619
+ }
1620
+ });
1621
+ resizeObserver.observe(document.getElementById('terminalContainer'));
1622
+
1623
+ // Auto-focus terminal
1624
+ term.focus();
1625
+ }
1626
+
1627
+ function focusTerminal() {
1628
+ if (term) term.focus();
1629
+ }
1630
+
1631
+ function sendCtrlC() {
1632
+ if (ws.readyState === WebSocket.OPEN) {
1633
+ ws.send(JSON.stringify({ type: 'terminal_input', data: '' }));
1634
+ }
1635
+ if (term) term.focus();
1636
+ }
1637
+
1638
+ function sendEsc() {
1639
+ if (ws.readyState === WebSocket.OPEN) {
1640
+ ws.send(JSON.stringify({ type: 'terminal_input', data: '\x1B' }));
1641
+ }
1642
+ if (term) term.focus();
1643
+ }
1644
+
1645
+ function sendTab() {
1646
+ if (ws.readyState === WebSocket.OPEN) {
1647
+ ws.send(JSON.stringify({ type: 'terminal_input', data: ' ' }));
1648
+ }
1649
+ if (term) term.focus();
1650
+ }
1651
+
1652
+ // Load CLI presets for terminal mode
1302
1653
  fetch('/api/cli-presets').then(r=>r.json()).then(presets=>{
1303
1654
  cliPresets=presets;
1304
1655
  const sel=document.getElementById('cliPreset');
1656
+ const shellOpt=document.createElement('option');
1657
+ shellOpt.value='shell';
1658
+ shellOpt.textContent='Shell (default)';
1659
+ sel.appendChild(shellOpt);
1305
1660
  presets.forEach(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o)});
1306
1661
  sel.addEventListener('change',()=>{
1307
- const p=cliPresets.find(x=>x.id===sel.value);
1308
- if(p){document.getElementById('command').value=p.command;document.getElementById('presetDesc').textContent=p.description;document.getElementById('command').style.display=p.id==='custom'?'':''}
1662
+ const id=sel.value;
1663
+ const p=cliPresets.find(x=>x.id===id);
1664
+ const cmd=document.getElementById('command');
1665
+ if(id==='shell'){cmd.style.display='none';cmd.value='';document.getElementById('presetDesc').textContent='Start an interactive login shell. Run commands directly in the phone terminal.';return;}
1666
+ if(id==='custom'){cmd.style.display='';document.getElementById('presetDesc').textContent='Enter the exact launch command.';return;}
1667
+ cmd.style.display='none';
1668
+ document.getElementById('presetDesc').textContent=p?p.description:'';
1309
1669
  });
1310
- if(presets.length){sel.dispatchEvent(new Event('change'))}
1311
- // After presets are loaded, restore saved config
1670
+ sel.value='shell';
1671
+ sel.dispatchEvent(new Event('change'));
1312
1672
  return fetch('/api/config').then(r=>r.json());
1313
1673
  }).then(cfg=>{
1314
1674
  if(!cfg) return;
1315
- const {saved,envKeys}=cfg;
1316
- if(saved){
1317
- document.getElementById('adapterType').value=saved.type;
1318
- document.getElementById('adapterType').dispatchEvent(new Event('change'));
1319
- if(saved.model) document.getElementById('model').value=saved.model;
1320
- if(saved.type==='cli'){
1321
- if(saved.preset){document.getElementById('cliPreset').value=saved.preset;document.getElementById('cliPreset').dispatchEvent(new Event('change'))}
1322
- if(saved.command) document.getElementById('command').value=saved.command;
1323
- }
1675
+ const {saved}=cfg;
1676
+ if(saved && (saved.type==='terminal' || saved.type==='cli')){
1677
+ if(saved.preset) document.getElementById('cliPreset').value=saved.preset;
1678
+ if(saved.command) document.getElementById('command').value=saved.command;
1679
+ document.getElementById('cliPreset').dispatchEvent(new Event('change'));
1324
1680
  }
1325
- // Show hints for env-var API keys
1326
- if(envKeys.anthropic) document.getElementById('apiKey').placeholder='API Key (ANTHROPIC_API_KEY set)';
1327
- if(envKeys.openai&&(!saved||saved.type==='openai')) document.getElementById('apiKey').placeholder='API Key (OPENAI_API_KEY set)';
1328
1681
  }).catch(()=>{});
1329
- document.getElementById('adapterType').addEventListener('change',e=>{
1330
- const cli=e.target.value==='cli';
1331
- document.getElementById('apiKey').style.display=cli?'none':'';
1332
- document.getElementById('model').style.display=cli?'none':'';
1333
- document.getElementById('cliConfig').style.display=cli?'':'none';
1334
- });
1682
+
1683
+ // WebSocket message handling
1335
1684
  ws.onmessage=e=>{
1336
1685
  const d=JSON.parse(e.data);
1337
- if(d.type==='message'){addMsg(d.role,d.content);if(d.role==='user'){streamEl=addMsg('assistant','');streamEl.classList.add('typing');streamEl.innerHTML='<span class="dot"></span><span class="dot"></span><span class="dot"></span>';curResp=''}}
1338
- else if(d.type==='stream_chunk'){if(!streamEl){streamEl=addMsg('assistant','');streamEl.classList.add('typing');streamEl.innerHTML='<span class="dot"></span><span class="dot"></span><span class="dot"></span>'}curResp+=d.data;const trimmed=curResp.trimStart();if(trimmed){if(streamEl.classList.contains('typing'))streamEl.classList.remove('typing');streamEl.innerHTML=renderMd(trimmed)}streamEl.scrollIntoView({block:'end'})}
1339
- else if(d.type==='stream_end'){streamEl=null;curResp=''}
1340
- else if(d.type==='configured'){document.getElementById('statusText').textContent='Configured: '+d.adapter.name+' ('+d.adapter.model+')'}
1341
- else if(d.type==='peer_connected'){document.getElementById('dot').className='dot on';document.getElementById('statusText').textContent='Phone connected';document.getElementById('chatCard').style.display=''}
1342
- else if(d.type==='peer_disconnected'){document.getElementById('dot').className='dot wait';document.getElementById('statusText').textContent='Phone disconnected'}
1686
+ if(d.type==='peer_connected'){
1687
+ document.getElementById('dot').className='dot on';
1688
+ document.getElementById('statusText').textContent='Phone connected';
1689
+ }
1690
+ else if(d.type==='peer_disconnected'){
1691
+ document.getElementById('dot').className='dot wait';
1692
+ document.getElementById('statusText').textContent='Phone disconnected';
1693
+ }
1694
+ else if(d.type==='terminal_configured'){
1695
+ document.getElementById('launchText').textContent='Launch: '+d.terminalLaunch;
1696
+ document.getElementById('statusText').textContent='Launch target updated';
1697
+ }
1698
+ else if(d.type==='configured'){
1699
+ document.getElementById('statusText').textContent='AI adapter configured';
1700
+ }
1701
+ else if(d.type==='stream_chunk' && term){
1702
+ term.write(d.data);
1703
+ }
1704
+ else if(d.type==='stream_end' && term){
1705
+ // Terminal stream ended
1706
+ }
1707
+ else if(d.type==='terminal_exit' && term){
1708
+ const detail = typeof d.exitCode === 'number' ? \`exit \${d.exitCode}\` : 'terminated';
1709
+ term.writeln(\`\r
1710
+ [terminal \${detail}]\`);
1711
+ }
1712
+ else if(d.type==='message'){
1713
+ // Chat message - add to chat UI
1714
+ const messagesDiv = document.getElementById('messages');
1715
+ if (messagesDiv) {
1716
+ const msgDiv = document.createElement('div');
1717
+ msgDiv.className = \`msg \${d.role}\`;
1718
+ msgDiv.textContent = d.content;
1719
+ messagesDiv.appendChild(msgDiv);
1720
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
1721
+ }
1722
+ }
1343
1723
  };
1724
+
1725
+ // Load initial status and determine mode
1344
1726
  fetch('/api/status').then(r=>r.json()).then(d=>{
1345
1727
  if(d.terminalLaunch) document.getElementById('launchText').textContent='Launch: '+d.terminalLaunch;
1346
- if(d.pairingCode){document.getElementById('pairingCard').style.display='';document.getElementById('pairingCode').textContent=d.pairingCode;if(d.pairingQR)document.getElementById('qrCode').src=d.pairingQR}
1347
- if(d.connected){document.getElementById('dot').className='dot on';document.getElementById('statusText').textContent='Phone connected';document.getElementById('chatCard').style.display=''}
1348
- else{document.getElementById('dot').className='dot wait';document.getElementById('statusText').textContent='Waiting for phone...'}
1349
- if(d.adapter)document.getElementById('statusText').textContent+=' | '+d.adapter.name;
1350
- if(d.messages)d.messages.forEach(m=>addMsg(m.role,m.content));
1728
+ if(d.pairingCode){
1729
+ document.getElementById('pairingCard').style.display='';
1730
+ document.getElementById('pairingCode').textContent=d.pairingCode;
1731
+ if(d.pairingQR) document.getElementById('qrCode').src=d.pairingQR;
1732
+ }
1733
+ if(d.connected){
1734
+ document.getElementById('dot').className='dot on';
1735
+ document.getElementById('statusText').textContent='Phone connected';
1736
+ } else{
1737
+ document.getElementById('dot').className='dot wait';
1738
+ document.getElementById('statusText').textContent='Waiting for phone...';
1739
+ }
1740
+
1741
+ // Determine mode: terminal mode if no adapter but has terminal launch
1742
+ if (!d.adapter && d.terminalLaunch) {
1743
+ switchToMode('terminal');
1744
+ } else {
1745
+ switchToMode('chat');
1746
+ if (d.adapter) {
1747
+ document.getElementById('chatCard').style.display = 'block';
1748
+ }
1749
+ }
1351
1750
  });
1352
- async function configure(){
1353
- const r=await fetch('/api/configure',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:document.getElementById('adapterType').value,apiKey:document.getElementById('apiKey').value,model:document.getElementById('model').value,command:document.getElementById('command').value,preset:document.getElementById('cliPreset').value})});
1354
- const d=await r.json();if(d.error)alert(d.error);
1355
- }
1356
- async function sendMsg(){
1357
- const el=document.getElementById('msgInput'),c=el.value.trim();if(!c)return;el.value='';
1358
- await fetch('/api/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:c})});
1359
- }
1360
- function addMsg(role,content){const el=document.createElement('div');el.className='msg '+role;if(role==='user'||!content){el.textContent=content}else{el.innerHTML=renderMd(content)}document.getElementById('messages').appendChild(el);el.scrollIntoView({block:'end'});return el}
1361
- </script>
1362
- </body>
1363
- </html>`;
1364
1751
 
1365
- // src/terminal.ts
1366
- import { basename, delimiter as delimiter2, isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "path";
1367
- import { existsSync as existsSync3 } from "fs";
1368
- import { spawn as spawn2 } from "node-pty";
1369
- function resolveExecutable2(command, envPath = process.env.PATH ?? "") {
1370
- if (!command) return null;
1371
- if (isAbsolute2(command) && existsSync3(command)) return command;
1372
- if (command.includes("/")) {
1373
- const candidate = resolve2(process.cwd(), command);
1374
- return existsSync3(candidate) ? candidate : null;
1375
- }
1376
- for (const dir of envPath.split(delimiter2)) {
1377
- if (!dir) continue;
1378
- const candidate = join3(dir.replace(/^~(?=$|\/)/, process.env.HOME ?? "~"), command);
1379
- if (existsSync3(candidate)) return candidate;
1752
+ // Configuration functions
1753
+ async function configureAdapter() {
1754
+ const type = document.getElementById('adapterType').value;
1755
+ const model = document.getElementById('model').value;
1756
+ const apiKey = document.getElementById('apiKey').value;
1757
+
1758
+ const response = await fetch('/api/configure', {
1759
+ method: 'POST',
1760
+ headers: { 'Content-Type': 'application/json' },
1761
+ body: JSON.stringify({ type, model, apiKey })
1762
+ });
1763
+ const result = await response.json();
1764
+ if (result.error) {
1765
+ alert(result.error);
1766
+ } else {
1767
+ document.getElementById('chatCard').style.display = 'block';
1380
1768
  }
1381
- return null;
1382
- }
1383
- function parseCommand(command) {
1384
- const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [command];
1385
- return {
1386
- file: parts[0],
1387
- args: parts.slice(1).map((part) => part.replace(/^"|"$/g, ""))
1388
- };
1389
1769
  }
1390
- function getDefaultTerminalCommand(explicitCommand) {
1391
- const configured = explicitCommand?.trim() || process.env.AIRLOOM_TERMINAL_COMMAND?.trim();
1392
- if (configured) return parseCommand(configured);
1393
- if (process.platform === "win32") {
1394
- const file = process.env.COMSPEC || "powershell.exe";
1395
- return { file, args: [] };
1770
+
1771
+ async function configureTerminal() {
1772
+ const preset = document.getElementById('cliPreset').value;
1773
+ const command = document.getElementById('command').value;
1774
+
1775
+ const response = await fetch('/api/configure', {
1776
+ method: 'POST',
1777
+ headers: { 'Content-Type': 'application/json' },
1778
+ body: JSON.stringify({ type: 'cli', preset, command })
1779
+ });
1780
+ const result = await response.json();
1781
+ if (result.error) {
1782
+ alert(result.error);
1396
1783
  }
1397
- const shell = process.env.SHELL || "/bin/bash";
1398
- const name = basename(shell);
1399
- if (name === "bash" || name === "zsh" || name === "sh") return { file: shell, args: ["-il"] };
1400
- return { file: shell, args: ["-i"] };
1401
1784
  }
1402
- var AdaptiveOutputBatcher = class {
1403
- constructor(onFlush, fastInterval = 16, slowInterval = 80, interactiveWindow = 250, maxBytes = 4096) {
1404
- this.onFlush = onFlush;
1405
- this.fastInterval = fastInterval;
1406
- this.slowInterval = slowInterval;
1407
- this.interactiveWindow = interactiveWindow;
1408
- this.maxBytes = maxBytes;
1409
- }
1410
- buffer = "";
1411
- timer = null;
1412
- lastInputAt = 0;
1413
- noteInput() {
1414
- this.lastInputAt = Date.now();
1415
- if (this.timer) {
1416
- clearTimeout(this.timer);
1417
- this.timer = null;
1418
- }
1419
- if (this.buffer) this.schedule();
1420
- }
1421
- write(data) {
1422
- this.buffer += data;
1423
- if (this.buffer.length >= this.maxBytes) {
1424
- this.flush();
1425
- return;
1426
- }
1427
- this.schedule();
1428
- }
1429
- flush() {
1430
- if (this.timer) {
1431
- clearTimeout(this.timer);
1432
- this.timer = null;
1433
- }
1434
- if (!this.buffer) return;
1435
- const data = this.buffer;
1436
- this.buffer = "";
1437
- this.onFlush(data);
1438
- }
1439
- destroy() {
1440
- this.flush();
1441
- }
1442
- schedule() {
1443
- if (this.timer) return;
1444
- const recentInput = Date.now() - this.lastInputAt <= this.interactiveWindow;
1445
- this.timer = setTimeout(() => this.flush(), recentInput ? this.fastInterval : this.slowInterval);
1446
- }
1447
- };
1448
- function getTerminalLaunchDisplay(explicitCommand) {
1449
- const command = getDefaultTerminalCommand(explicitCommand);
1450
- return [command.file, ...command.args].join(" ");
1785
+
1786
+ // Chat functionality
1787
+ function sendMessage() {
1788
+ const input = document.getElementById('messageInput');
1789
+ const content = input.value.trim();
1790
+ if (!content) return;
1791
+
1792
+ fetch('/api/send', {
1793
+ method: 'POST',
1794
+ headers: { 'Content-Type': 'application/json' },
1795
+ body: JSON.stringify({ content })
1796
+ }).then(() => {
1797
+ input.value = '';
1798
+ }).catch(err => {
1799
+ console.error('Failed to send message:', err);
1800
+ });
1451
1801
  }
1452
- var TerminalSession = class {
1453
- constructor(channel, launchCommand) {
1454
- this.channel = channel;
1455
- this.launchCommand = launchCommand;
1456
- }
1457
- pty = null;
1458
- stream = null;
1459
- batcher = null;
1460
- cols = 120;
1461
- rows = 36;
1462
- handleMessage(message) {
1463
- switch (message.type) {
1464
- case "terminal_open":
1465
- this.open(message);
1466
- break;
1467
- case "terminal_input":
1468
- this.writeInput(message);
1469
- break;
1470
- case "terminal_resize":
1471
- this.resize(message);
1472
- break;
1473
- case "terminal_close":
1474
- this.close();
1475
- break;
1476
- case "terminal_exit":
1477
- break;
1478
- }
1479
- }
1480
- close() {
1481
- this.batcher?.destroy();
1482
- this.batcher = null;
1483
- if (this.stream && !this.stream.ended) this.stream.end();
1484
- this.stream = null;
1485
- try {
1486
- this.pty?.kill();
1487
- } catch {
1488
- }
1489
- this.pty = null;
1490
- }
1491
- open(message) {
1492
- this.cols = Math.max(20, Math.floor(message.cols || this.cols));
1493
- this.rows = Math.max(5, Math.floor(message.rows || this.rows));
1494
- if (this.pty) {
1495
- this.pty.resize(this.cols, this.rows);
1496
- return;
1497
- }
1498
- const command = getDefaultTerminalCommand(this.launchCommand);
1499
- const file = resolveExecutable2(command.file) ?? command.file;
1500
- const meta = { kind: "terminal", cols: this.cols, rows: this.rows };
1501
- this.stream = this.channel.createStream(meta);
1502
- this.batcher = new AdaptiveOutputBatcher((data) => this.stream?.write(data));
1503
- const env = { ...process.env, TERM: "xterm-256color" };
1504
- this.pty = spawn2(file, command.args, {
1505
- name: "xterm-256color",
1506
- cols: this.cols,
1507
- rows: this.rows,
1508
- cwd: process.cwd(),
1509
- env
1510
- });
1511
- this.pty.onData((data) => this.batcher?.write(data));
1512
- this.pty.onExit(({ exitCode, signal }) => {
1513
- this.batcher?.flush();
1514
- this.stream?.end();
1515
- this.channel.send({ type: "terminal_exit", exitCode, signal });
1516
- this.stream = null;
1517
- this.batcher = null;
1518
- this.pty = null;
1519
- });
1520
- }
1521
- writeInput(message) {
1522
- if (!this.pty) return;
1523
- this.batcher?.noteInput();
1524
- this.pty.write(message.data);
1525
- }
1526
- resize(message) {
1527
- this.cols = Math.max(20, Math.floor(message.cols || this.cols));
1528
- this.rows = Math.max(5, Math.floor(message.rows || this.rows));
1529
- this.pty?.resize(this.cols, this.rows);
1802
+
1803
+ // Handle Enter key in chat input
1804
+ document.getElementById('messageInput')?.addEventListener('keydown', (e) => {
1805
+ if (e.key === 'Enter' && !e.shiftKey) {
1806
+ e.preventDefault();
1807
+ sendMessage();
1530
1808
  }
1531
- };
1532
- function isTerminalMessage(data) {
1533
- if (!data || typeof data !== "object" || !("type" in data)) return false;
1534
- const type = data.type;
1535
- return type === "terminal_open" || type === "terminal_input" || type === "terminal_resize" || type === "terminal_close" || type === "terminal_exit";
1536
- }
1809
+ });
1810
+ </script>
1811
+ </body>
1812
+ </html>`;
1537
1813
 
1538
1814
  // src/index.ts
1539
1815
  function parseArgs(argv) {
@@ -1673,12 +1949,14 @@ async function main() {
1673
1949
  });
1674
1950
  await channel.connect(session.sessionToken);
1675
1951
  console.log("[host] Connected to relay, waiting for phone...");
1952
+ const savedConfig = loadConfig();
1676
1953
  const launchPreset = cliArgs.preset ? CLI_PRESETS.find((p) => p.id === cliArgs.preset) : void 0;
1677
1954
  if (cliArgs.preset && !launchPreset) {
1678
1955
  console.error(`[host] Unknown preset "${cliArgs.preset}". Available: ${CLI_PRESETS.map((p) => p.id).join(", ")}`);
1679
1956
  process.exit(1);
1680
1957
  }
1681
- const launchCommand = cliArgs.cli ?? launchPreset?.command;
1958
+ const savedTerminalCommand = !cliArgs.cli && !cliArgs.preset && savedConfig?.type === "terminal" ? savedConfig.command ?? (savedConfig.preset && savedConfig.preset !== "shell" ? CLI_PRESETS.find((p) => p.id === savedConfig.preset)?.command : void 0) : void 0;
1959
+ const launchCommand = cliArgs.cli ?? launchPreset?.command ?? savedTerminalCommand;
1682
1960
  const terminalLaunch = getTerminalLaunchDisplay(launchCommand);
1683
1961
  const state = {
1684
1962
  channel,
@@ -1689,6 +1967,7 @@ async function main() {
1689
1967
  relayUrl: useAbly ? "ably" : RELAY_URL,
1690
1968
  connected: false,
1691
1969
  terminalLaunch,
1970
+ terminalLaunchCommand: launchCommand,
1692
1971
  messages: []
1693
1972
  };
1694
1973
  console.log(`[host] Terminal launch: ${terminalLaunch}`);
@@ -1779,7 +2058,7 @@ async function main() {
1779
2058
  exec(`${cmd} ${localUrl}`);
1780
2059
  }).catch(() => {
1781
2060
  });
1782
- const terminal = new TerminalSession(channel, launchCommand);
2061
+ const terminal = new TerminalSession(channel, () => state.terminalLaunchCommand);
1783
2062
  channel.on("ready", () => {
1784
2063
  console.log("[host] Phone connected! Channel ready.");
1785
2064
  state.connected = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airloom",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Run AI on your computer, control it from your phone. E2E encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,8 @@
12
12
  ],
13
13
  "dependencies": {
14
14
  "@noble/hashes": "^1.7.0",
15
+ "@xterm/addon-fit": "^0.11.0",
16
+ "@xterm/xterm": "^6.0.0",
15
17
  "ably": "^2.20.0",
16
18
  "events": "^3.3.0",
17
19
  "express": "^5.1.0",