@cordfuse/crosstalk 7.0.0-alpha.9 → 7.0.0-beta.1
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.
- package/GUIDE-CLI.md +322 -0
- package/GUIDE-PROMPTS.md +118 -0
- package/LICENSE +21 -0
- package/README.md +402 -61
- package/bin/crosstalk.js +88 -54
- package/commands/agent.js +69 -0
- package/commands/auth.js +273 -0
- package/commands/channel.js +54 -44
- package/commands/chat.js +107 -71
- package/commands/daemon.js +120 -0
- package/commands/logs.js +108 -19
- package/commands/message.js +125 -0
- package/commands/server.js +153 -0
- package/commands/settings.js +49 -0
- package/commands/status.js +37 -13
- package/commands/token.js +136 -0
- package/commands/transport.js +270 -0
- package/commands/version.js +3 -3
- package/commands/workflow.js +234 -0
- package/deploy/crosstalk@.service +62 -0
- package/deploy/install.sh +82 -0
- package/lib/api-client.js +77 -22
- package/lib/credentials.js +207 -0
- package/lib/nativeServer.js +173 -0
- package/lib/resolve.js +101 -34
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1716 -0
- package/src/auth/enforce.ts +68 -0
- package/src/auth/handlers.ts +266 -0
- package/src/auth/middleware.ts +132 -0
- package/src/auth/setup.ts +263 -0
- package/src/auth/tokens.ts +285 -0
- package/src/auth/users.ts +267 -0
- package/src/dispatch.ts +492 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +116 -0
- package/src/invoke.ts +201 -0
- package/src/log-buffer.ts +67 -0
- package/src/models.ts +283 -0
- package/src/resolve.ts +100 -0
- package/src/state.ts +190 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +243 -0
- package/src/web/auth-pages.ts +160 -0
- package/src/web/channels.ts +395 -0
- package/src/web/chat-page.ts +636 -0
- package/src/web/chat-pty.ts +254 -0
- package/src/web/dashboard.ts +129 -0
- package/src/web/layout.ts +237 -0
- package/src/web/stubs.ts +510 -0
- package/src/web/workflows.ts +490 -0
- package/src/workflow.ts +470 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +262 -0
- package/template/PROTOCOL.md +70 -0
- package/template/README.md +64 -0
- package/template/auth/.gitkeep +0 -0
- package/template/auth/README.md +224 -0
- package/template/data/crosstalk.yaml +196 -0
- package/template/gitignore +4 -0
- package/commands/down.js +0 -40
- package/commands/init.js +0 -243
- package/commands/pull.js +0 -22
- package/commands/replies.js +0 -40
- package/commands/restart.js +0 -29
- package/commands/rm.js +0 -109
- package/commands/run.js +0 -115
- package/commands/up.js +0 -135
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
// chat-page.ts — interactive chat panel.
|
|
2
|
+
//
|
|
3
|
+
// Layout mirrors llmux's session screen (CSS + structure copied
|
|
4
|
+
// verbatim where it touches the bar layout — Steve's phone proved that
|
|
5
|
+
// version works in practice, so don't redesign it):
|
|
6
|
+
//
|
|
7
|
+
// ┌──────────────────────────────────────┐
|
|
8
|
+
// │ ⌂ × · ● agent · CROSSTALK v… │ ← #chat-topbar (fixed)
|
|
9
|
+
// ├──────────────────────────────────────┤
|
|
10
|
+
// │ │
|
|
11
|
+
// │ xterm.js terminal │ ← #term-wrap (fills middle)
|
|
12
|
+
// │ │
|
|
13
|
+
// ├──────────────────────────────────────┤
|
|
14
|
+
// │ Home ▲ ▼ ◀ ▶ End │ ← #chat-bar (mobile-only)
|
|
15
|
+
// │ Esc Tab Ctrl Alt │
|
|
16
|
+
// └──────────────────────────────────────┘
|
|
17
|
+
//
|
|
18
|
+
// Two-button kill model:
|
|
19
|
+
// • ⌂ (back) / `/exit` typed in terminal → SIGTERM, SIGKILL 1s later
|
|
20
|
+
// • × (kill) → immediate SIGKILL (confirm prompt)
|
|
21
|
+
//
|
|
22
|
+
// Soft-keyboard handling: viewport meta carries
|
|
23
|
+
// `interactive-widget=resizes-content` so Android Chrome resizes the
|
|
24
|
+
// visual viewport when the keyboard pops up — the fixed-bottom bar
|
|
25
|
+
// then rides above the keyboard instead of being covered.
|
|
26
|
+
|
|
27
|
+
import { page, escapeHtml, TOAST_HELPER } from './layout.js';
|
|
28
|
+
|
|
29
|
+
export interface ChatPageData {
|
|
30
|
+
host: string;
|
|
31
|
+
alias: string;
|
|
32
|
+
/** Installed agent CLIs on the host (from /agents installed list). */
|
|
33
|
+
installedAgents: string[];
|
|
34
|
+
/** Engine version — rendered in the topbar brand strip. */
|
|
35
|
+
version?: string;
|
|
36
|
+
/** Engine-side $HOME — used as the default CWD in the picker. */
|
|
37
|
+
home: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function chatPage(d: ChatPageData): string {
|
|
41
|
+
const agentsJson = JSON.stringify(d.installedAgents);
|
|
42
|
+
const versionStr = d.version ?? '';
|
|
43
|
+
const homeJson = JSON.stringify(d.home);
|
|
44
|
+
return page({
|
|
45
|
+
title: `crosstalk on ${d.host} · Chat`,
|
|
46
|
+
host: d.host,
|
|
47
|
+
alias: d.alias,
|
|
48
|
+
activeNav: 'chat',
|
|
49
|
+
pageTitle: 'Chat',
|
|
50
|
+
// Critical: the chat session uses fixed-position layout +
|
|
51
|
+
// visualViewport math. Without `interactive-widget=resizes-content`
|
|
52
|
+
// the Android soft keyboard overlays the bar instead of pushing it
|
|
53
|
+
// up. Caught 2026-06-22 — the picker was unaffected because it
|
|
54
|
+
// doesn't have fixed-bottom UI.
|
|
55
|
+
viewport: 'width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content',
|
|
56
|
+
extraCss: `
|
|
57
|
+
/* Picker (default state) */
|
|
58
|
+
.picker-card .pick-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
|
|
59
|
+
.picker-card .pick-row label{font-size:11px;color:#9aa0a6;text-transform:uppercase;letter-spacing:.05em;min-width:48px}
|
|
60
|
+
.picker-card select,
|
|
61
|
+
.picker-card input[type="text"]{background:#0b0c10;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:7px 10px;font:13px ui-monospace,monospace;outline:none;flex:1 1 200px;min-width:160px}
|
|
62
|
+
.picker-card select:focus,
|
|
63
|
+
.picker-card input[type="text"]:focus{border-color:#2d4a66}
|
|
64
|
+
.picker-card .cwd-row{position:relative}
|
|
65
|
+
.picker-card #cwd-recent{background:#1c2128;color:#9aa0a6;border:1px solid #262c34;border-radius:6px;padding:7px 8px;font:11px ui-monospace,monospace;cursor:pointer;min-width:36px}
|
|
66
|
+
.picker-card #cwd-recent:hover{background:#252b34;color:#e6e8eb}
|
|
67
|
+
.picker-card #cwd-recent-menu{position:absolute;top:38px;right:0;background:#0e1116;border:1px solid #1f2329;border-radius:6px;padding:4px;display:none;z-index:5;max-width:90vw;min-width:240px}
|
|
68
|
+
.picker-card #cwd-recent-menu.open{display:block}
|
|
69
|
+
.picker-card #cwd-recent-menu button{display:block;width:100%;text-align:left;background:transparent;border:none;color:#c9d1d9;padding:7px 10px;font:12px ui-monospace,monospace;cursor:pointer;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
70
|
+
.picker-card #cwd-recent-menu button:hover{background:#11141a;color:#7cc4ff}
|
|
71
|
+
.picker-card #cwd-recent-menu .empty{padding:8px 10px;color:#7a7f87;font-style:italic;font-size:11px}
|
|
72
|
+
.picker-card .actions-row{display:flex;justify-content:flex-end;margin-top:6px}
|
|
73
|
+
|
|
74
|
+
/* ── Session mode (toggled via <body class="chat-session">) ──── */
|
|
75
|
+
:root{--topbar-h:38px;--bar-h:92px}
|
|
76
|
+
body.chat-session{margin:0;padding:0;max-width:none;height:100dvh;min-height:100dvh;overscroll-behavior:none}
|
|
77
|
+
body.chat-session > header,
|
|
78
|
+
body.chat-session #nav-drawer,
|
|
79
|
+
body.chat-session #nav-backdrop,
|
|
80
|
+
body.chat-session .picker-card{display:none !important}
|
|
81
|
+
body.chat-session main{margin:0;padding:0}
|
|
82
|
+
|
|
83
|
+
/* Topbar */
|
|
84
|
+
#chat-topbar{position:fixed;top:0;left:0;right:0;height:var(--topbar-h);background:#11141a;border-bottom:1px solid #1f2329;display:none;align-items:center;gap:8px;padding:0 10px;z-index:21;box-sizing:border-box}
|
|
85
|
+
body.chat-session #chat-topbar{display:flex}
|
|
86
|
+
#chat-topbar button.iconbtn{flex:0 0 auto;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;height:26px;width:36px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;font:14px ui-monospace,monospace;-webkit-tap-highlight-color:transparent;outline:none;padding:0}
|
|
87
|
+
#chat-topbar button.iconbtn:active{background:#252b34;border-color:#3a414b}
|
|
88
|
+
#chat-topbar button#chat-kill{color:#f85149;border-color:#4a2329}
|
|
89
|
+
#chat-topbar button#chat-kill:active{background:#4a2329}
|
|
90
|
+
#chat-topbar .title{flex:1 1 auto;display:flex;align-items:center;gap:8px;min-width:0;color:#c9d1d9;font-size:12px}
|
|
91
|
+
#chat-topbar .title-dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#9aa0a6;transition:background .2s,box-shadow .2s}
|
|
92
|
+
#chat-topbar .title-dot[data-state="connecting"]{background:#d29922}
|
|
93
|
+
#chat-topbar .title-dot[data-state="connected"]{background:#7ee787;box-shadow:0 0 6px #7ee78766}
|
|
94
|
+
#chat-topbar .title-dot[data-state="closed"],
|
|
95
|
+
#chat-topbar .title-dot[data-state="error"]{background:#f85149}
|
|
96
|
+
#chat-topbar .title-name{flex:0 1 auto;font-weight:600;color:#e6e8eb;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
97
|
+
#chat-topbar .brand{flex:0 0 auto;color:#7cc4ff;font-size:11px;font-weight:600;letter-spacing:.08em;padding-left:8px;margin-left:auto}
|
|
98
|
+
#chat-topbar .ver{flex:0 0 auto;color:#7a7f87;font-size:10px;padding-left:6px}
|
|
99
|
+
|
|
100
|
+
/* Terminal — exact position spec copied from llmux. The touch-action
|
|
101
|
+
claim is what lets pinch-to-zoom work on mobile. */
|
|
102
|
+
#term-wrap{display:none;background:#000;position:fixed;top:var(--topbar-h);left:0;right:0;bottom:var(--bar-h);touch-action:none}
|
|
103
|
+
body.chat-session #term-wrap{display:block}
|
|
104
|
+
.xterm{height:100%}
|
|
105
|
+
.xterm-viewport{background:#000 !important}
|
|
106
|
+
|
|
107
|
+
/* Soft-keyboard helper bar — CSS copied verbatim from
|
|
108
|
+
@cordfuse/llmux/src/daemon/web/server.ts (session page) so the
|
|
109
|
+
visual feel matches exactly. */
|
|
110
|
+
:root{--allkeys-h:0px}
|
|
111
|
+
#chat-bar{display:none;position:fixed;bottom:0;left:0;right:0;height:var(--bar-h);background:#11141a;border-top:1px solid #1f2329;flex-direction:column;gap:8px;padding:6px 0 14px;z-index:20;box-sizing:border-box}
|
|
112
|
+
body.chat-session #chat-bar{display:flex}
|
|
113
|
+
#chat-bar .row{display:flex;align-items:center;gap:6px;padding:0 6px;flex:0 0 auto;height:32px}
|
|
114
|
+
#chat-bar .row.arrows{justify-content:center}
|
|
115
|
+
#chat-bar .row.keys{justify-content:flex-start}
|
|
116
|
+
#chat-bar #more{flex:0 0 auto;margin-left:auto}
|
|
117
|
+
#chat-bar button{flex:0 0 auto;min-width:40px;height:30px;padding:0 10px;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;font:13px ui-monospace,monospace;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none;transition:background .15s,border-color .15s}
|
|
118
|
+
#chat-bar button:active{background:#252b34;border-color:#3a414b}
|
|
119
|
+
#chat-bar button[aria-pressed="true"]{background:#1e3a52;border-color:#2d5a85;color:#7cc4ff}
|
|
120
|
+
#chat-bar button[aria-pressed="locked"]{background:#2d5a85;border-color:#4a7fae;color:#fff}
|
|
121
|
+
|
|
122
|
+
/* Expanded all-keys panel — opens above #chat-bar when ⋯ is tapped.
|
|
123
|
+
Contains shell chars, numbers, brackets, operators, navigation,
|
|
124
|
+
and function keys. Same structure as llmux's #all-keys. */
|
|
125
|
+
#all-keys{position:fixed;bottom:var(--bar-h);left:0;right:0;background:#0e1116;border-top:1px solid #1f2329;display:none;padding:8px;z-index:19;max-height:40vh;overflow-y:auto;box-sizing:border-box}
|
|
126
|
+
body.chat-session #all-keys.open{display:block}
|
|
127
|
+
#all-keys h4{margin:14px 4px 6px;font:500 10px/1 ui-monospace,monospace;color:#7a7f87;text-transform:uppercase;letter-spacing:.06em}
|
|
128
|
+
#all-keys h4:first-child{margin-top:4px}
|
|
129
|
+
#all-keys .row{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}
|
|
130
|
+
#all-keys button{flex:0 0 auto;min-width:36px;height:30px;padding:0 8px;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;font:12px ui-monospace,monospace;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none}
|
|
131
|
+
#all-keys button:active{background:#252b34;border-color:#3a414b}
|
|
132
|
+
|
|
133
|
+
/* When the all-keys panel is open, the terminal shrinks to make
|
|
134
|
+
room. --allkeys-h is set in JS based on the panel's actual height. */
|
|
135
|
+
body.chat-session.allkeys-open #term-wrap{bottom:calc(var(--bar-h) + var(--allkeys-h))}
|
|
136
|
+
|
|
137
|
+
/* Desktop / mouse-primary devices have a real keyboard — hide the
|
|
138
|
+
bar entirely so the terminal extends to the viewport bottom. */
|
|
139
|
+
@media (pointer: fine) and (hover: hover){
|
|
140
|
+
body.chat-session{--bar-h:0px}
|
|
141
|
+
#chat-bar, #all-keys{display:none !important}
|
|
142
|
+
}
|
|
143
|
+
/* Landscape phones — compact rows */
|
|
144
|
+
@media (orientation: landscape) and (max-height: 500px){
|
|
145
|
+
body.chat-session{--topbar-h:28px;--bar-h:64px}
|
|
146
|
+
#chat-bar button{height:22px;min-width:36px;padding:0 8px;font-size:11px}
|
|
147
|
+
#chat-bar{padding:4px 0 10px;gap:4px}
|
|
148
|
+
#chat-bar .row{gap:4px;height:24px}
|
|
149
|
+
#all-keys{max-height:60vh}
|
|
150
|
+
#all-keys button{height:24px;min-width:30px;padding:0 7px;font-size:11px}
|
|
151
|
+
}
|
|
152
|
+
`,
|
|
153
|
+
body: `
|
|
154
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" />
|
|
155
|
+
|
|
156
|
+
<section class="card picker-card">
|
|
157
|
+
<h3>Interactive chat</h3>
|
|
158
|
+
<p class="sub">Spawns a fresh agent process on the host attached to your browser via WebSocket. Close the tab, hit the back arrow, or type <code>/exit</code> to terminate gracefully. Hit the red <code>×</code> in the topbar for an immediate SIGKILL when an agent is wedged. Async-by-design: no persistent sessions — for durable work, use the orch bus.</p>
|
|
159
|
+
|
|
160
|
+
<div class="pick-row">
|
|
161
|
+
<label for="chat-agent">agent</label>
|
|
162
|
+
<select id="chat-agent"></select>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="pick-row cwd-row">
|
|
165
|
+
<label for="chat-cwd">cwd</label>
|
|
166
|
+
<input type="text" id="chat-cwd" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="/path/to/dir">
|
|
167
|
+
<button type="button" id="cwd-recent" title="Recent directories">recent ⌄</button>
|
|
168
|
+
<div id="cwd-recent-menu" role="menu" aria-hidden="true"></div>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="actions-row">
|
|
171
|
+
<button type="button" class="primary" id="chat-start">start session</button>
|
|
172
|
+
</div>
|
|
173
|
+
</section>
|
|
174
|
+
|
|
175
|
+
<div id="chat-topbar">
|
|
176
|
+
<button class="iconbtn" id="chat-back" title="Back to picker (graceful exit)" aria-label="back">⌂</button>
|
|
177
|
+
<button class="iconbtn" id="chat-kill" title="SIGKILL the agent immediately" aria-label="kill">×</button>
|
|
178
|
+
<span class="title">
|
|
179
|
+
<span class="title-dot" id="chat-dot" data-state="connecting" title="connecting"></span>
|
|
180
|
+
<span class="title-name" id="chat-title">—</span>
|
|
181
|
+
</span>
|
|
182
|
+
<span class="brand">CROSSTALK</span>
|
|
183
|
+
<span class="ver">${versionStr ? 'v' + escapeHtml(versionStr) : ''}</span>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div id="term-wrap"></div>
|
|
187
|
+
|
|
188
|
+
<div id="chat-bar" aria-label="keyboard helper">
|
|
189
|
+
<div class="row arrows">
|
|
190
|
+
<button data-mod="shift" title="Shift (tap then key; double-tap to lock)">Shift</button>
|
|
191
|
+
<button data-key="home" title="Home">Home</button>
|
|
192
|
+
<button data-key="up" title="Up">▲</button>
|
|
193
|
+
<button data-key="down" title="Down">▼</button>
|
|
194
|
+
<button data-key="left" title="Left">◀</button>
|
|
195
|
+
<button data-key="right" title="Right">▶</button>
|
|
196
|
+
<button data-key="end" title="End">End</button>
|
|
197
|
+
</div>
|
|
198
|
+
<div class="row keys">
|
|
199
|
+
<button data-key="esc" title="Escape">Esc</button>
|
|
200
|
+
<button data-key="tab" title="Tab">Tab</button>
|
|
201
|
+
<button data-mod="ctrl" title="Ctrl (tap then key; double-tap to lock)">Ctrl</button>
|
|
202
|
+
<button data-mod="alt" title="Alt (tap then key; double-tap to lock)">Alt</button>
|
|
203
|
+
<button id="more" title="More keys (functions, brackets, etc.)">⋯</button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div id="all-keys" aria-hidden="true">
|
|
208
|
+
<h4>shell</h4>
|
|
209
|
+
<div class="row">
|
|
210
|
+
<button data-char="~">~</button>
|
|
211
|
+
<button data-char="\`">\`</button>
|
|
212
|
+
<button data-char="/">/</button>
|
|
213
|
+
<button data-char="\\\\">\\</button>
|
|
214
|
+
<button data-char="|">|</button>
|
|
215
|
+
<button data-char="-">-</button>
|
|
216
|
+
<button data-char="_">_</button>
|
|
217
|
+
</div>
|
|
218
|
+
<h4>numbers</h4>
|
|
219
|
+
<div class="row">
|
|
220
|
+
<button data-char="0">0</button><button data-char="1">1</button><button data-char="2">2</button>
|
|
221
|
+
<button data-char="3">3</button><button data-char="4">4</button><button data-char="5">5</button>
|
|
222
|
+
<button data-char="6">6</button><button data-char="7">7</button><button data-char="8">8</button>
|
|
223
|
+
<button data-char="9">9</button>
|
|
224
|
+
</div>
|
|
225
|
+
<h4>brackets & quotes</h4>
|
|
226
|
+
<div class="row">
|
|
227
|
+
<button data-char="(">(</button><button data-char=")">)</button>
|
|
228
|
+
<button data-char="[">[</button><button data-char="]">]</button>
|
|
229
|
+
<button data-char="{">{</button><button data-char="}">}</button>
|
|
230
|
+
<button data-char="<"><</button><button data-char=">">></button>
|
|
231
|
+
<button data-char="'">'</button><button data-char='"'>"</button>
|
|
232
|
+
</div>
|
|
233
|
+
<h4>operators</h4>
|
|
234
|
+
<div class="row">
|
|
235
|
+
<button data-char="=">=</button><button data-char="+">+</button>
|
|
236
|
+
<button data-char="*">*</button><button data-char="&">&</button>
|
|
237
|
+
<button data-char="^">^</button><button data-char="%">%</button>
|
|
238
|
+
<button data-char="$">$</button><button data-char="#">#</button>
|
|
239
|
+
<button data-char="@">@</button><button data-char="!">!</button>
|
|
240
|
+
<button data-char="?">?</button>
|
|
241
|
+
</div>
|
|
242
|
+
<h4>punctuation</h4>
|
|
243
|
+
<div class="row">
|
|
244
|
+
<button data-char=":">:</button><button data-char=";">;</button>
|
|
245
|
+
<button data-char=",">,</button><button data-char=".">.</button>
|
|
246
|
+
</div>
|
|
247
|
+
<h4>navigation & edit</h4>
|
|
248
|
+
<div class="row">
|
|
249
|
+
<button data-key="pgup">PgUp</button><button data-key="pgdn">PgDn</button>
|
|
250
|
+
<button data-key="del">Del</button><button data-key="ins">Ins</button>
|
|
251
|
+
<button data-key="bsp">⌫ Bsp</button><button data-key="enter">↵ Enter</button>
|
|
252
|
+
</div>
|
|
253
|
+
<h4>function keys</h4>
|
|
254
|
+
<div class="row">
|
|
255
|
+
<button data-key="f1">F1</button><button data-key="f2">F2</button>
|
|
256
|
+
<button data-key="f3">F3</button><button data-key="f4">F4</button>
|
|
257
|
+
<button data-key="f5">F5</button><button data-key="f6">F6</button>
|
|
258
|
+
<button data-key="f7">F7</button><button data-key="f8">F8</button>
|
|
259
|
+
<button data-key="f9">F9</button><button data-key="f10">F10</button>
|
|
260
|
+
<button data-key="f11">F11</button><button data-key="f12">F12</button>
|
|
261
|
+
</div>
|
|
262
|
+
<h4>actions</h4>
|
|
263
|
+
<div class="row">
|
|
264
|
+
<button id="reset-term" title="Clear xterm buffer and send Ctrl-L to redraw">Reset terminal</button>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
`,
|
|
268
|
+
inlineScript: `${TOAST_HELPER}
|
|
269
|
+
window.__CHAT_AGENTS__ = ${agentsJson};
|
|
270
|
+
window.__CHAT_HOME__ = ${homeJson};
|
|
271
|
+
`,
|
|
272
|
+
}).replace('</body>', `
|
|
273
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
|
274
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
|
275
|
+
<script>
|
|
276
|
+
(() => {
|
|
277
|
+
const agents = window.__CHAT_AGENTS__ || [];
|
|
278
|
+
const home = window.__CHAT_HOME__ || '/';
|
|
279
|
+
const sel = document.getElementById('chat-agent');
|
|
280
|
+
const cwdInput = document.getElementById('chat-cwd');
|
|
281
|
+
const cwdRecentBtn = document.getElementById('cwd-recent');
|
|
282
|
+
const cwdRecentMenu = document.getElementById('cwd-recent-menu');
|
|
283
|
+
const startBtn = document.getElementById('chat-start');
|
|
284
|
+
const backBtn = document.getElementById('chat-back');
|
|
285
|
+
const killBtn = document.getElementById('chat-kill');
|
|
286
|
+
const dot = document.getElementById('chat-dot');
|
|
287
|
+
const titleEl = document.getElementById('chat-title');
|
|
288
|
+
const wrap = document.getElementById('term-wrap');
|
|
289
|
+
|
|
290
|
+
if (agents.length === 0) {
|
|
291
|
+
sel.innerHTML = '<option value="">(no agents installed)</option>';
|
|
292
|
+
sel.disabled = true;
|
|
293
|
+
startBtn.disabled = true;
|
|
294
|
+
} else {
|
|
295
|
+
sel.innerHTML = agents.map(a => '<option value="' + a + '">' + a + '</option>').join('') +
|
|
296
|
+
'<option value="bash">bash (shell)</option>';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── CWD picker (Option A) ─────────────────────────────────────
|
|
300
|
+
// Pre-fill the input from the last-used cwd, falling back to $HOME.
|
|
301
|
+
// The recent-cwds dropdown surfaces the last 5 used dirs from
|
|
302
|
+
// localStorage. Server-side validates the path on spawn — invalid
|
|
303
|
+
// paths fall back to $HOME with a warning line in the terminal.
|
|
304
|
+
const LS_KEY = 'crosstalk_recent_cwds';
|
|
305
|
+
function readRecent(){
|
|
306
|
+
try {
|
|
307
|
+
const raw = localStorage.getItem(LS_KEY);
|
|
308
|
+
const arr = raw ? JSON.parse(raw) : [];
|
|
309
|
+
return Array.isArray(arr) ? arr.filter(s => typeof s === 'string') : [];
|
|
310
|
+
} catch { return []; }
|
|
311
|
+
}
|
|
312
|
+
function writeRecent(arr){
|
|
313
|
+
try { localStorage.setItem(LS_KEY, JSON.stringify(arr.slice(0, 5))); } catch {}
|
|
314
|
+
}
|
|
315
|
+
function pushRecent(dir){
|
|
316
|
+
if (!dir) return;
|
|
317
|
+
const cur = readRecent().filter(d => d !== dir);
|
|
318
|
+
cur.unshift(dir);
|
|
319
|
+
writeRecent(cur);
|
|
320
|
+
}
|
|
321
|
+
function renderRecentMenu(){
|
|
322
|
+
const recent = readRecent();
|
|
323
|
+
if (recent.length === 0){
|
|
324
|
+
cwdRecentMenu.innerHTML = '<div class="empty">no recent directories yet</div>';
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
cwdRecentMenu.innerHTML = recent
|
|
328
|
+
.map(d => '<button type="button" data-cwd="' + d.replace(/"/g, '"') + '" title="' + d.replace(/"/g, '"') + '">' + d + '</button>')
|
|
329
|
+
.join('');
|
|
330
|
+
}
|
|
331
|
+
function setCwdMenuOpen(open){
|
|
332
|
+
cwdRecentMenu.classList.toggle('open', open);
|
|
333
|
+
cwdRecentMenu.setAttribute('aria-hidden', String(!open));
|
|
334
|
+
}
|
|
335
|
+
// Initial value: last-used cwd or $HOME.
|
|
336
|
+
const recentInit = readRecent();
|
|
337
|
+
cwdInput.value = recentInit[0] || home;
|
|
338
|
+
cwdInput.placeholder = home;
|
|
339
|
+
|
|
340
|
+
cwdRecentBtn.addEventListener('click', () => {
|
|
341
|
+
const isOpen = cwdRecentMenu.classList.contains('open');
|
|
342
|
+
if (!isOpen) renderRecentMenu();
|
|
343
|
+
setCwdMenuOpen(!isOpen);
|
|
344
|
+
});
|
|
345
|
+
cwdRecentMenu.addEventListener('click', (ev) => {
|
|
346
|
+
const t = ev.target;
|
|
347
|
+
if (!(t instanceof HTMLElement) || !t.dataset.cwd) return;
|
|
348
|
+
cwdInput.value = t.dataset.cwd;
|
|
349
|
+
setCwdMenuOpen(false);
|
|
350
|
+
});
|
|
351
|
+
document.addEventListener('click', (ev) => {
|
|
352
|
+
if (!cwdRecentMenu.classList.contains('open')) return;
|
|
353
|
+
const t = ev.target;
|
|
354
|
+
if (t === cwdRecentBtn || cwdRecentMenu.contains(t)) return;
|
|
355
|
+
setCwdMenuOpen(false);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
let term = null;
|
|
359
|
+
let fitAddon = null;
|
|
360
|
+
let ws = null;
|
|
361
|
+
|
|
362
|
+
// Modifier state — 'off' | 'pending' (one-shot) | 'locked' (sticky).
|
|
363
|
+
// Tap once → pending; tap again → locked; tap third time → off.
|
|
364
|
+
const mods = { ctrl: 'off', alt: 'off', shift: 'off' };
|
|
365
|
+
function setMod(mod, val){
|
|
366
|
+
mods[mod] = val;
|
|
367
|
+
const btn = document.querySelector('[data-mod="' + mod + '"]');
|
|
368
|
+
if (btn){
|
|
369
|
+
if (val === 'off') btn.removeAttribute('aria-pressed');
|
|
370
|
+
else btn.setAttribute('aria-pressed', val === 'locked' ? 'locked' : 'true');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function consumeMods(d){
|
|
374
|
+
let out = d;
|
|
375
|
+
if (mods.shift !== 'off' && d.length === 1){
|
|
376
|
+
out = d.toUpperCase();
|
|
377
|
+
if (mods.shift === 'pending') setMod('shift', 'off');
|
|
378
|
+
}
|
|
379
|
+
if (mods.ctrl !== 'off' && out.length === 1){
|
|
380
|
+
const c = out.charCodeAt(0);
|
|
381
|
+
if (c >= 0x40 && c <= 0x7f) out = String.fromCharCode(c & 0x1f);
|
|
382
|
+
else if (c === 0x20) out = String.fromCharCode(0);
|
|
383
|
+
if (mods.ctrl === 'pending') setMod('ctrl', 'off');
|
|
384
|
+
}
|
|
385
|
+
if (mods.alt !== 'off'){
|
|
386
|
+
out = String.fromCharCode(0x1b) + out;
|
|
387
|
+
if (mods.alt === 'pending') setMod('alt', 'off');
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function setStatus(state, label){
|
|
393
|
+
dot.dataset.state = state;
|
|
394
|
+
dot.title = label;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function enterSession(agent){
|
|
398
|
+
document.body.classList.add('chat-session');
|
|
399
|
+
titleEl.textContent = agent;
|
|
400
|
+
setStatus('connecting', 'connecting…');
|
|
401
|
+
}
|
|
402
|
+
function leaveSession(){
|
|
403
|
+
document.body.classList.remove('chat-session');
|
|
404
|
+
document.body.classList.remove('allkeys-open');
|
|
405
|
+
document.getElementById('all-keys').classList.remove('open');
|
|
406
|
+
if (term){ try { term.dispose(); } catch(_){} }
|
|
407
|
+
term = null; fitAddon = null;
|
|
408
|
+
wrap.innerHTML = '';
|
|
409
|
+
setMod('ctrl', 'off');
|
|
410
|
+
setMod('alt', 'off');
|
|
411
|
+
setMod('shift', 'off');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function startSession(){
|
|
415
|
+
const agent = sel.value;
|
|
416
|
+
if (!agent){ window.showToast('pick an agent first', 'err'); return; }
|
|
417
|
+
const cwd = (cwdInput.value || '').trim() || home;
|
|
418
|
+
pushRecent(cwd);
|
|
419
|
+
enterSession(agent);
|
|
420
|
+
|
|
421
|
+
term = new window.Terminal({
|
|
422
|
+
cursorBlink: true,
|
|
423
|
+
fontFamily: 'ui-monospace, monospace',
|
|
424
|
+
fontSize: 13,
|
|
425
|
+
theme: { background: '#000000', foreground: '#e6e8eb', cursor: '#7cc4ff' },
|
|
426
|
+
scrollback: 5000,
|
|
427
|
+
});
|
|
428
|
+
fitAddon = new window.FitAddon.FitAddon();
|
|
429
|
+
term.loadAddon(fitAddon);
|
|
430
|
+
term.open(wrap);
|
|
431
|
+
requestAnimationFrame(() => {
|
|
432
|
+
try { fitAddon.fit(); } catch(_){}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
436
|
+
const wsUrl = proto + '//' + location.host + '/ws/chat?agent=' + encodeURIComponent(agent) + '&cols=' + (term.cols || 80) + '&rows=' + (term.rows || 24) + '&cwd=' + encodeURIComponent(cwd);
|
|
437
|
+
ws = new WebSocket(wsUrl);
|
|
438
|
+
ws.binaryType = 'arraybuffer';
|
|
439
|
+
|
|
440
|
+
ws.addEventListener('open', () => {
|
|
441
|
+
setStatus('connected', 'connected');
|
|
442
|
+
try {
|
|
443
|
+
if (fitAddon) fitAddon.fit();
|
|
444
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
445
|
+
} catch(_){}
|
|
446
|
+
term.focus();
|
|
447
|
+
});
|
|
448
|
+
ws.addEventListener('message', (ev) => {
|
|
449
|
+
const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(new Uint8Array(ev.data));
|
|
450
|
+
term.write(data);
|
|
451
|
+
});
|
|
452
|
+
ws.addEventListener('close', (ev) => {
|
|
453
|
+
setStatus('closed', 'disconnected (code ' + ev.code + ')');
|
|
454
|
+
ws = null;
|
|
455
|
+
// Server-initiated close (agent exited naturally — 'exit' in
|
|
456
|
+
// bash, '/exit' inside an agent CLI, Ctrl-D, etc.) — auto-return
|
|
457
|
+
// to the picker after a short delay so the operator can read the
|
|
458
|
+
// exit line. Mirrors the back-button behavior; means the operator
|
|
459
|
+
// never has to manually dismiss the terminal screen.
|
|
460
|
+
//
|
|
461
|
+
// Skip auto-leave for transport-level disconnects (code 1006
|
|
462
|
+
// abnormal closure) so the operator can still see what happened
|
|
463
|
+
// and choose to retry rather than getting bounced silently.
|
|
464
|
+
const NATURAL_EXIT_CODES = new Set([1000, 1001, 1003, 1011]);
|
|
465
|
+
if (NATURAL_EXIT_CODES.has(ev.code)){
|
|
466
|
+
setTimeout(leaveSession, 1500);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
ws.addEventListener('error', () => {
|
|
470
|
+
setStatus('error', 'connection error');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
term.onData((data) => {
|
|
474
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
475
|
+
// Apply Ctrl/Alt modifiers before forwarding.
|
|
476
|
+
const mapped = consumeMods(data);
|
|
477
|
+
// /exit interception (graceful exit).
|
|
478
|
+
if (data === String.fromCharCode(13) || data === String.fromCharCode(10)){
|
|
479
|
+
const trailingLine = (term.__buf || '').trim();
|
|
480
|
+
term.__buf = '';
|
|
481
|
+
if (trailingLine === '/exit'){
|
|
482
|
+
ws.send(JSON.stringify({ type: 'exit' }));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
term.__buf = (term.__buf || '') + data;
|
|
487
|
+
if (term.__buf.length > 64) term.__buf = term.__buf.slice(-64);
|
|
488
|
+
ws.send(mapped);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Resize handler. visualViewport.resize is the event that fires
|
|
492
|
+
// when the soft keyboard opens/closes — re-fit the terminal so
|
|
493
|
+
// its cell grid matches the available height.
|
|
494
|
+
let resizeT = null;
|
|
495
|
+
const onResize = () => {
|
|
496
|
+
if (resizeT) clearTimeout(resizeT);
|
|
497
|
+
resizeT = setTimeout(() => {
|
|
498
|
+
try { fitAddon.fit(); } catch(_){}
|
|
499
|
+
if (ws && ws.readyState === WebSocket.OPEN){
|
|
500
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
501
|
+
}
|
|
502
|
+
}, 150);
|
|
503
|
+
};
|
|
504
|
+
window.addEventListener('resize', onResize);
|
|
505
|
+
if (window.visualViewport) window.visualViewport.addEventListener('resize', onResize);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function gracefulExit(){
|
|
509
|
+
if (ws && ws.readyState === WebSocket.OPEN){
|
|
510
|
+
ws.send(JSON.stringify({ type: 'exit' }));
|
|
511
|
+
setTimeout(leaveSession, 250);
|
|
512
|
+
} else {
|
|
513
|
+
leaveSession();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function forceKill(){
|
|
518
|
+
if (ws && ws.readyState === WebSocket.OPEN){
|
|
519
|
+
ws.send(JSON.stringify({ type: 'kill' }));
|
|
520
|
+
setTimeout(leaveSession, 250);
|
|
521
|
+
} else {
|
|
522
|
+
leaveSession();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
startBtn.addEventListener('click', startSession);
|
|
527
|
+
backBtn.addEventListener('click', gracefulExit);
|
|
528
|
+
killBtn.addEventListener('click', () => {
|
|
529
|
+
if (confirm('SIGKILL this agent immediately? Unsaved work in the agent will be lost.')) forceKill();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Keyboard helper bar + all-keys panel.
|
|
533
|
+
//
|
|
534
|
+
// KEYS map carries the byte sequence each named key emits. Values
|
|
535
|
+
// are written with \\u escapes so they survive the engine's HTML
|
|
536
|
+
// template-literal embedding, then unescaped at runtime.
|
|
537
|
+
const KEYS = {
|
|
538
|
+
esc: '\\u001b', tab: '\\t', enter: '\\r', bsp: '\\u007f',
|
|
539
|
+
up: '\\u001b[A', down: '\\u001b[B', right: '\\u001b[C', left: '\\u001b[D',
|
|
540
|
+
home: '\\u001b[H', end: '\\u001b[F',
|
|
541
|
+
pgup: '\\u001b[5~', pgdn: '\\u001b[6~',
|
|
542
|
+
del: '\\u001b[3~', ins: '\\u001b[2~',
|
|
543
|
+
f1: '\\u001bOP', f2: '\\u001bOQ', f3: '\\u001bOR', f4: '\\u001bOS',
|
|
544
|
+
f5: '\\u001b[15~', f6: '\\u001b[17~', f7: '\\u001b[18~', f8: '\\u001b[19~',
|
|
545
|
+
f9: '\\u001b[20~', f10: '\\u001b[21~', f11: '\\u001b[23~', f12: '\\u001b[24~',
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const allKeysEl = document.getElementById('all-keys');
|
|
549
|
+
const moreBtn = document.getElementById('more');
|
|
550
|
+
|
|
551
|
+
function updateAllKeysH(){
|
|
552
|
+
if (!allKeysEl.classList.contains('open')){
|
|
553
|
+
document.documentElement.style.setProperty('--allkeys-h', '0px');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
// Cap the panel at 40% of the visible viewport so it doesn't crowd
|
|
557
|
+
// the terminal entirely on short screens (landscape phones, etc.).
|
|
558
|
+
const visibleH = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
|
559
|
+
const h = Math.min(allKeysEl.scrollHeight, Math.floor(visibleH * 0.4));
|
|
560
|
+
document.documentElement.style.setProperty('--allkeys-h', h + 'px');
|
|
561
|
+
}
|
|
562
|
+
function toggleAllKeys(){
|
|
563
|
+
const open = !allKeysEl.classList.contains('open');
|
|
564
|
+
allKeysEl.classList.toggle('open', open);
|
|
565
|
+
document.body.classList.toggle('allkeys-open', open);
|
|
566
|
+
allKeysEl.setAttribute('aria-hidden', String(!open));
|
|
567
|
+
moreBtn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
|
568
|
+
updateAllKeysH();
|
|
569
|
+
// Re-fit the terminal so the cell grid adapts to the new height.
|
|
570
|
+
requestAnimationFrame(() => {
|
|
571
|
+
try { fitAddon && fitAddon.fit(); } catch(_){}
|
|
572
|
+
if (ws && ws.readyState === WebSocket.OPEN && term){
|
|
573
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
moreBtn.addEventListener('click', toggleAllKeys);
|
|
578
|
+
|
|
579
|
+
function sendKey(key){
|
|
580
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
581
|
+
const seq = KEYS[key];
|
|
582
|
+
if (!seq) return;
|
|
583
|
+
ws.send(consumeMods(seq));
|
|
584
|
+
if (term) term.focus();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function sendChar(ch){
|
|
588
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
589
|
+
ws.send(consumeMods(ch));
|
|
590
|
+
if (term) term.focus();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Bar (modifiers + Esc/Tab/arrows/Home/End)
|
|
594
|
+
document.getElementById('chat-bar').addEventListener('click', (ev) => {
|
|
595
|
+
const t = ev.target;
|
|
596
|
+
if (!(t instanceof HTMLElement)) return;
|
|
597
|
+
if (t.id === 'more') return; // moreBtn has its own listener
|
|
598
|
+
if (t.dataset.mod){
|
|
599
|
+
const mod = t.dataset.mod;
|
|
600
|
+
const cur = mods[mod];
|
|
601
|
+
const next = cur === 'off' ? 'pending' : (cur === 'pending' ? 'locked' : 'off');
|
|
602
|
+
setMod(mod, next);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (t.dataset.key) sendKey(t.dataset.key);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// All-keys panel (chars + extended keys + reset action)
|
|
609
|
+
allKeysEl.addEventListener('click', (ev) => {
|
|
610
|
+
const t = ev.target;
|
|
611
|
+
if (!(t instanceof HTMLElement)) return;
|
|
612
|
+
if (t.id === 'reset-term'){
|
|
613
|
+
// Reset = clear xterm scrollback + send Ctrl-L to redraw the
|
|
614
|
+
// remote prompt. Useful when an agent CLI gets confused about
|
|
615
|
+
// terminal state (cursor in weird position, garbled output).
|
|
616
|
+
if (term){ try { term.clear(); term.reset(); } catch(_){} }
|
|
617
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send('\\u000c'.replace(/\\\\u([0-9a-f]{4})/gi, (_,h) => String.fromCharCode(parseInt(h,16))));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (t.dataset.char){
|
|
621
|
+
sendChar(t.dataset.char);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (t.dataset.key) sendKey(t.dataset.key);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// The KEYS literal strings carry \\u escapes that survive engine
|
|
628
|
+
// HTML embedding. Unescape them once at boot so sendKey works as
|
|
629
|
+
// expected — converts '\\u001b[A' into the 3-byte sequence ESC [ A.
|
|
630
|
+
for (const k of Object.keys(KEYS)){
|
|
631
|
+
KEYS[k] = KEYS[k].replace(/\\\\u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
632
|
+
}
|
|
633
|
+
})();
|
|
634
|
+
</script>
|
|
635
|
+
</body>`);
|
|
636
|
+
}
|