@lelouchhe/webagent 0.1.0

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.
@@ -0,0 +1,196 @@
1
+ // User input handling: send, cancel, keyboard shortcuts
2
+
3
+ import {
4
+ state, dom, setBusy, sendCancel,
5
+ getConfigOption, getConfigValue, updateNewBtnVisibility,
6
+ } from './state.mmjqzu9r.js';
7
+ import { addMessage, addSystem, addBashBlock, showWaiting } from './render.mmjqzu9r.js';
8
+ import { handleSlashCommand, hideSlashMenu, handleSlashMenuKey, updateSlashMenu } from './commands.mmjqzu9r.js';
9
+ import { renderAttachPreview } from './images.mmjqzu9r.js';
10
+
11
+ // Wire up cancel-timeout feedback (state.js cannot import render.js directly)
12
+ state._onCancelTimeout = () => addSystem('warn: Agent not responding to cancel');
13
+
14
+ function sendMessage() {
15
+ const text = dom.input.value.trim();
16
+ if (!text && state.pendingImages.length === 0) return;
17
+
18
+ // Slash commands and bash always go through, even while busy
19
+ if ((text.startsWith('/') || text === '?' || text.startsWith('? ')) && state.pendingImages.length === 0) {
20
+ dom.input.value = '';
21
+ dom.input.style.height = 'auto';
22
+ updateNewBtnVisibility();
23
+ syncSendBtn();
24
+ handleSlashCommand(text);
25
+ return;
26
+ }
27
+
28
+ if (text.startsWith('!') && state.pendingImages.length === 0) {
29
+ const command = text.slice(1).trim();
30
+ if (!command) return;
31
+ if (!state.sessionId) {
32
+ addSystem('warn: Session not ready yet, please wait…');
33
+ return;
34
+ }
35
+ dom.input.value = '';
36
+ dom.input.style.height = 'auto';
37
+ dom.inputArea.classList.remove('bash-mode');
38
+ updateNewBtnVisibility();
39
+ addBashBlock(command, true);
40
+ state.ws.send(JSON.stringify({ type: 'bash_exec', sessionId: state.sessionId, command }));
41
+ setBusy(true);
42
+ return;
43
+ }
44
+
45
+ // Regular messages require agent to be idle
46
+ if (state.busy) return;
47
+
48
+ dom.input.value = '';
49
+ dom.input.style.height = 'auto';
50
+ dom.inputArea.classList.remove('bash-mode');
51
+ updateNewBtnVisibility();
52
+
53
+ if (!state.sessionId) {
54
+ addSystem('warn: Session not ready yet, please wait…');
55
+ return;
56
+ }
57
+
58
+ // Show user message with image thumbnails
59
+ const msgEl = addMessage('user', text || '(image)');
60
+ for (const img of state.pendingImages) {
61
+ const imgEl = document.createElement('img');
62
+ imgEl.className = 'user-image';
63
+ imgEl.src = img.previewUrl;
64
+ msgEl.appendChild(imgEl);
65
+ }
66
+
67
+ // Upload images to server, then send prompt
68
+ const images = state.pendingImages.slice();
69
+ state.pendingImages.length = 0;
70
+ renderAttachPreview();
71
+
72
+ if (images.length > 0) {
73
+ Promise.all(images.map(img =>
74
+ fetch(`/api/images/${state.sessionId}`, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify({ data: img.data, mimeType: img.mimeType }),
78
+ }).then(r => r.json()).then(j => ({ data: img.data, mimeType: img.mimeType, path: j.path }))
79
+ )).then(uploaded => {
80
+ state.ws.send(JSON.stringify({ type: 'prompt', sessionId: state.sessionId, text: text || 'What is in this image?', images: uploaded }));
81
+ });
82
+ } else {
83
+ state.ws.send(JSON.stringify({ type: 'prompt', sessionId: state.sessionId, text }));
84
+ }
85
+ state.turnEnded = false;
86
+ setBusy(true);
87
+ showWaiting();
88
+ }
89
+
90
+ function doCancel() {
91
+ if (sendCancel()) addSystem('^X');
92
+ }
93
+
94
+ // --- Event listeners ---
95
+
96
+ /** True when the input contains a slash command or bang-bash that can bypass busy. */
97
+ function inputHasCommand() {
98
+ const text = dom.input.value.trim();
99
+ return text.startsWith('/') || text.startsWith('!') || text === '?' || text.startsWith('? ');
100
+ }
101
+
102
+ /** Update the send button label to reflect whether the input has a command. */
103
+ function syncSendBtn() {
104
+ if (!state.busy) return;
105
+ if (inputHasCommand()) {
106
+ dom.sendBtn.textContent = '↵';
107
+ dom.sendBtn.title = 'Send (Enter)';
108
+ dom.sendBtn.classList.remove('cancel');
109
+ } else {
110
+ dom.sendBtn.textContent = '^X';
111
+ dom.sendBtn.title = 'Cancel (Ctrl+X)';
112
+ dom.sendBtn.classList.add('cancel');
113
+ }
114
+ }
115
+
116
+ dom.sendBtn.onclick = () => {
117
+ if (state.busy && !inputHasCommand()) {
118
+ doCancel();
119
+ } else {
120
+ sendMessage();
121
+ }
122
+ };
123
+
124
+ dom.input.addEventListener('keydown', (e) => {
125
+ // Slash menu navigation
126
+ if (handleSlashMenuKey(e)) {
127
+ e.preventDefault();
128
+ return;
129
+ }
130
+ if (e.key === 'Enter' && !e.shiftKey) {
131
+ e.preventDefault();
132
+ hideSlashMenu();
133
+ sendMessage();
134
+ return;
135
+ }
136
+ // Ctrl+U to upload file
137
+ if (e.key === 'u' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
138
+ e.preventDefault();
139
+ dom.fileInput.click();
140
+ return;
141
+ }
142
+ });
143
+
144
+ // Global Escape to dismiss slash menu
145
+ document.addEventListener('keydown', (e) => {
146
+ if (e.key === 'x' && (e.ctrlKey || e.metaKey) && !e.shiftKey && state.busy) {
147
+ e.preventDefault();
148
+ doCancel();
149
+ return;
150
+ }
151
+ if (e.key === 'Escape' && dom.slashMenu.classList.contains('active')) {
152
+ e.preventDefault();
153
+ hideSlashMenu();
154
+ dom.input.focus();
155
+ }
156
+ });
157
+
158
+ // Cycle mode helper
159
+ function cycleMode() {
160
+ const opt = getConfigOption('mode');
161
+ if (!opt || !opt.options.length) return;
162
+ const idx = opt.options.findIndex(o => o.value === opt.currentValue);
163
+ const next = opt.options[(idx + 1) % opt.options.length];
164
+ state.ws.send(JSON.stringify({ type: 'set_config_option', sessionId: state.sessionId, configId: 'mode', value: next.value }));
165
+ addSystem(`Mode → ${next.name}`);
166
+ }
167
+
168
+ // Global Ctrl+M to cycle mode
169
+ document.addEventListener('keydown', (e) => {
170
+ if (e.key === 'm' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
171
+ e.preventDefault();
172
+ cycleMode();
173
+ }
174
+ });
175
+
176
+ // Click prompt indicator to cycle mode
177
+ dom.prompt.addEventListener('click', cycleMode);
178
+
179
+ // Click + to fill /new into input and show path menu
180
+ dom.newBtn.addEventListener('click', () => {
181
+ dom.input.value = '/new ';
182
+ updateNewBtnVisibility();
183
+ syncSendBtn();
184
+ updateSlashMenu();
185
+ dom.input.focus();
186
+ });
187
+
188
+ // Hide + button when input has content; sync send button label
189
+ dom.input.addEventListener('input', () => { updateNewBtnVisibility(); syncSendBtn(); });
190
+ dom.input.addEventListener('focus', updateNewBtnVisibility);
191
+
192
+ // Auto-resize textarea
193
+ dom.input.addEventListener('input', () => {
194
+ dom.input.style.height = 'auto';
195
+ dom.input.style.height = Math.min(dom.input.scrollHeight, 200) + 'px';
196
+ });
@@ -0,0 +1,200 @@
1
+ // Rendering functions, theme, markdown, bash UI
2
+
3
+ import { dom, state } from './state.mmjqzu9r.js';
4
+
5
+ // --- Markdown ---
6
+ marked.setOptions({ breaks: true, gfm: true });
7
+
8
+ export function renderMd(text) {
9
+ return DOMPurify.sanitize(marked.parse(text));
10
+ }
11
+
12
+ // --- Message helpers ---
13
+
14
+ export function addMessage(role, text) {
15
+ const el = document.createElement('div');
16
+ el.className = `msg ${role}`;
17
+ el.innerHTML = role === 'user' ? escHtml(text).replace(/\n/g, '<br>') : renderMd(text);
18
+ appendMessageElement(el);
19
+ return el;
20
+ }
21
+
22
+ export function addSystem(text) {
23
+ const el = document.createElement('div');
24
+ el.className = 'system-msg';
25
+ el.textContent = text;
26
+ appendMessageElement(el);
27
+ }
28
+
29
+ export function finishAssistant() {
30
+ state.currentAssistantEl = null;
31
+ state.currentAssistantText = '';
32
+ }
33
+
34
+ export function finishThinking() {
35
+ if (state.currentThinkingEl) {
36
+ const sum = state.currentThinkingEl.querySelector('summary');
37
+ sum.textContent = '⠿ thought';
38
+ sum.classList.remove('active');
39
+ sum.style.animation = 'none';
40
+ state.currentThinkingEl = null;
41
+ state.currentThinkingText = '';
42
+ }
43
+ }
44
+
45
+ let waitingEl = null;
46
+ const SCROLL_FOLLOW_THRESHOLD = 80;
47
+
48
+ function isNearBottom(el) {
49
+ return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_FOLLOW_THRESHOLD;
50
+ }
51
+
52
+ function updateScrollFollowState() {
53
+ state.followMessages = isNearBottom(dom.messages);
54
+ }
55
+
56
+ dom.messages.addEventListener('scroll', updateScrollFollowState);
57
+
58
+ function shouldFollowNewContent() {
59
+ return state.followMessages || isNearBottom(dom.messages);
60
+ }
61
+
62
+ export function appendMessageElement(el, force = false) {
63
+ const shouldFollow = force || shouldFollowNewContent();
64
+ dom.messages.appendChild(el);
65
+ scrollToBottom(shouldFollow);
66
+ return el;
67
+ }
68
+
69
+ export function showWaiting() {
70
+ hideWaiting();
71
+ waitingEl = document.createElement('div');
72
+ waitingEl.id = 'waiting';
73
+ waitingEl.innerHTML = '<span class="cursor">▌</span>';
74
+ appendMessageElement(waitingEl, true);
75
+ }
76
+ export function hideWaiting() {
77
+ if (waitingEl) { waitingEl.remove(); waitingEl = null; }
78
+ }
79
+
80
+ export function scrollToBottom(force) {
81
+ const el = dom.messages;
82
+ if (force || state.followMessages) {
83
+ el.scrollTop = el.scrollHeight;
84
+ state.followMessages = true;
85
+ return;
86
+ }
87
+ state.followMessages = isNearBottom(el);
88
+ }
89
+
90
+ export function escHtml(s) {
91
+ const d = document.createElement('div');
92
+ d.textContent = s;
93
+ return d.innerHTML;
94
+ }
95
+
96
+ export function formatLocalTime(utcStr) {
97
+ if (!utcStr) return '';
98
+ const d = new Date(utcStr.endsWith('Z') ? utcStr : utcStr + 'Z');
99
+ const pad = n => String(n).padStart(2, '0');
100
+ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
101
+ }
102
+
103
+ export function renderPatchDiff(ri) {
104
+ // Case 1: patch string format (*** Begin Patch)
105
+ if (typeof ri === 'string' && ri.includes('*** Begin Patch')) {
106
+ const lines = ri.split('\n');
107
+ const html = [];
108
+ for (const line of lines) {
109
+ if (line.startsWith('*** Begin Patch') || line.startsWith('*** End Patch')) continue;
110
+ if (line.startsWith('*** Update File:') || line.startsWith('*** Add File:') || line.startsWith('*** Delete File:')) {
111
+ html.push(`<span class="diff-file">${escHtml(line)}</span>`);
112
+ } else if (line.startsWith('@@')) {
113
+ html.push(`<span class="diff-hunk">${escHtml(line)}</span>`);
114
+ } else if (line.startsWith('-')) {
115
+ html.push(`<span class="diff-del">${escHtml(line)}</span>`);
116
+ } else if (line.startsWith('+')) {
117
+ html.push(`<span class="diff-add">${escHtml(line)}</span>`);
118
+ } else {
119
+ html.push(escHtml(line));
120
+ }
121
+ }
122
+ return html.join('\n');
123
+ }
124
+ // Case 2: object with old_str / new_str (edit tool rawInput)
125
+ if (ri && typeof ri === 'object') {
126
+ const html = [];
127
+ if (ri.path) html.push(`<span class="diff-file">*** ${escHtml(ri.path)}</span>`);
128
+ if (ri.old_str != null) {
129
+ for (const line of String(ri.old_str).split('\n')) {
130
+ html.push(`<span class="diff-del">- ${escHtml(line)}</span>`);
131
+ }
132
+ }
133
+ if (ri.new_str != null) {
134
+ for (const line of String(ri.new_str).split('\n')) {
135
+ html.push(`<span class="diff-add">+ ${escHtml(line)}</span>`);
136
+ }
137
+ }
138
+ if (ri.file_text != null) {
139
+ html.push(`<span class="diff-add">+ (new file, ${ri.file_text.split('\n').length} lines)</span>`);
140
+ }
141
+ return html.length > (ri.path ? 1 : 0) ? html.join('\n') : null;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ // --- Bash command UI ---
147
+
148
+ export function addBashBlock(command, running = false) {
149
+ const el = document.createElement('div');
150
+ el.className = 'bash-block';
151
+ el.innerHTML = `<span class="bash-cmd${running ? ' running' : ''}">${escHtml(command)}</span>` +
152
+ `<div class="bash-output"></div>`;
153
+ el.querySelector('.bash-cmd').onclick = () => {
154
+ const out = el.querySelector('.bash-output');
155
+ if (out.style.display === 'none') {
156
+ out.style.display = 'block';
157
+ } else if (out.classList.contains('has-content')) {
158
+ out.style.display = 'none';
159
+ }
160
+ };
161
+ appendMessageElement(el);
162
+ if (running) state.currentBashEl = el;
163
+ return el;
164
+ }
165
+
166
+ export function finishBash(el, code, signal) {
167
+ if (!el) return;
168
+ const cmd = el.querySelector('.bash-cmd');
169
+ cmd.classList.remove('running');
170
+ let exitText = '';
171
+ if (signal) {
172
+ exitText = `[signal: ${signal}]`;
173
+ } else if (code !== 0 && code != null) {
174
+ exitText = `[exit: ${code}]`;
175
+ }
176
+ if (exitText) {
177
+ const span = document.createElement('span');
178
+ span.className = `bash-exit ${code === 0 ? 'ok' : 'fail'}`;
179
+ span.textContent = exitText;
180
+ cmd.after(span);
181
+ }
182
+ if (el === state.currentBashEl) state.currentBashEl = null;
183
+ }
184
+
185
+ // --- Theme ---
186
+
187
+ const THEME_ICONS = { auto: '◑', light: '☀', dark: '☾' };
188
+ const THEME_CYCLE = ['auto', 'light', 'dark'];
189
+ function getTheme() { return localStorage.getItem('theme') || 'auto'; }
190
+ function applyTheme(t) {
191
+ document.documentElement.setAttribute('data-theme', t);
192
+ dom.themeBtn.textContent = THEME_ICONS[t];
193
+ dom.themeBtn.title = `Theme: ${t}`;
194
+ localStorage.setItem('theme', t);
195
+ }
196
+ dom.themeBtn.onclick = () => {
197
+ const cur = getTheme();
198
+ applyTheme(THEME_CYCLE[(THEME_CYCLE.indexOf(cur) + 1) % 3]);
199
+ };
200
+ applyTheme(getTheme());
@@ -0,0 +1,176 @@
1
+ // Shared state, DOM refs, config helpers, routing, session management
2
+
3
+ const $ = (s) => document.querySelector(s);
4
+
5
+ export const dom = {
6
+ messages: $('#messages'),
7
+ input: $('#input'),
8
+ sendBtn: $('#send-btn'),
9
+ prompt: $('#input-prompt'),
10
+ status: $('#status'),
11
+ sessionInfo: $('#session-info'),
12
+ newBtn: $('#new-btn'),
13
+ attachBtn: $('#attach-btn'),
14
+ fileInput: $('#file-input'),
15
+ attachPreview: $('#attach-preview'),
16
+ themeBtn: $('#theme-btn'),
17
+ slashMenu: $('#slash-menu'),
18
+ inputArea: $('#input-area'),
19
+ };
20
+
21
+ export const state = {
22
+ ws: null,
23
+ sessionId: null,
24
+ sessionCwd: null,
25
+ sessionTitle: null,
26
+ awaitingNewSession: false,
27
+ configOptions: [],
28
+ currentAssistantEl: null,
29
+ currentAssistantText: '',
30
+ currentThinkingEl: null,
31
+ currentThinkingText: '',
32
+ busy: false,
33
+ pendingImages: [],
34
+ currentBashEl: null,
35
+ followMessages: true,
36
+ pendingToolCallIds: new Set(),
37
+ pendingPermissionRequestIds: new Set(),
38
+ pendingPromptDone: false,
39
+ turnEnded: false,
40
+ cancelTimeout: 10_000,
41
+ _cancelTimerId: null,
42
+ _onCancelTimeout: null,
43
+ lastEventSeq: 0,
44
+ replayInProgress: false,
45
+ replayQueue: [],
46
+ };
47
+
48
+ const CONNECTION_STATUS_CLASSES = {
49
+ disconnected: 'is-disconnected',
50
+ connecting: 'is-connecting',
51
+ connected: 'is-connected',
52
+ };
53
+
54
+ export function setConnectionStatus(status, label = status) {
55
+ dom.status.textContent = '';
56
+ dom.status.className = `status-dot ${CONNECTION_STATUS_CLASSES[status]}`;
57
+ dom.status.dataset.state = status;
58
+ dom.status.setAttribute('aria-label', label);
59
+ dom.status.setAttribute('title', label);
60
+ }
61
+
62
+ // --- Config helpers ---
63
+
64
+ export function getConfigOption(id) { return state.configOptions.find(o => o.id === id); }
65
+ export function getConfigValue(id) { return getConfigOption(id)?.currentValue ?? null; }
66
+ export function setConfigValue(id, value) {
67
+ const opt = getConfigOption(id);
68
+ if (opt) opt.currentValue = value;
69
+ }
70
+ export function updateConfigOptions(newOptions) {
71
+ state.configOptions = newOptions;
72
+ updateModeUI();
73
+ }
74
+
75
+ export function updateModeUI() {
76
+ dom.inputArea.classList.remove('plan-mode', 'autopilot-mode');
77
+ const modeValue = getConfigValue('mode') || '';
78
+ if (modeValue.includes('#plan')) dom.inputArea.classList.add('plan-mode');
79
+ else if (modeValue.includes('#autopilot')) dom.inputArea.classList.add('autopilot-mode');
80
+ }
81
+
82
+ export function setBusy(on) {
83
+ state.busy = on;
84
+ if (on) {
85
+ dom.sendBtn.textContent = '^X';
86
+ dom.sendBtn.title = 'Cancel (Ctrl+X)';
87
+ dom.sendBtn.classList.add('cancel');
88
+ dom.prompt.classList.add('busy');
89
+ } else {
90
+ dom.sendBtn.textContent = '↵';
91
+ dom.sendBtn.title = 'Send (Enter)';
92
+ dom.sendBtn.classList.remove('cancel');
93
+ dom.prompt.classList.remove('busy');
94
+ }
95
+ }
96
+
97
+ export function requestNewSession({ cwd, inheritFromSessionId = state.sessionId } = {}) {
98
+ const payload = { type: 'new_session' };
99
+ if (cwd) payload.cwd = cwd;
100
+ if (inheritFromSessionId) payload.inheritFromSessionId = inheritFromSessionId;
101
+ state.awaitingNewSession = true;
102
+ state.ws.send(JSON.stringify(payload));
103
+ }
104
+
105
+ export function resetSessionUI() {
106
+ dom.messages.innerHTML = '';
107
+ state.currentAssistantEl = null;
108
+ state.currentAssistantText = '';
109
+ state.currentThinkingEl = null;
110
+ state.currentThinkingText = '';
111
+ state.pendingImages.length = 0;
112
+ state.followMessages = true;
113
+ state.pendingToolCallIds.clear();
114
+ state.pendingPermissionRequestIds.clear();
115
+ state.pendingPromptDone = false;
116
+ state.turnEnded = false;
117
+ state._cancelTimerId = null;
118
+ state.lastEventSeq = 0;
119
+ state.replayInProgress = false;
120
+ state.replayQueue = [];
121
+ dom.attachPreview.innerHTML = '';
122
+ dom.attachPreview.classList.remove('active');
123
+ dom.input.disabled = false;
124
+ dom.sendBtn.disabled = false;
125
+ dom.input.placeholder = '';
126
+ setBusy(false);
127
+ }
128
+
129
+ export function updateNewBtnVisibility() {
130
+ dom.newBtn.classList.toggle('hidden', dom.input.value.length > 0);
131
+ }
132
+
133
+ // Send cancel without UI side-effect — callers add their own feedback.
134
+ // If state.cancelTimeout > 0, arms a timer that calls onCancelTimeout() when
135
+ // the agent fails to acknowledge the cancel in time.
136
+ export function sendCancel() {
137
+ if (!state.busy || !state.ws) return false;
138
+ state.ws.send(JSON.stringify({ type: 'cancel', sessionId: state.sessionId }));
139
+ clearCancelTimer();
140
+ if (state.cancelTimeout > 0) {
141
+ state._cancelTimerId = setTimeout(() => {
142
+ state._cancelTimerId = null;
143
+ if (state.busy) {
144
+ state.turnEnded = true;
145
+ setBusy(false);
146
+ state._onCancelTimeout?.();
147
+ }
148
+ }, state.cancelTimeout);
149
+ }
150
+ return true;
151
+ }
152
+
153
+ export function clearCancelTimer() {
154
+ if (state._cancelTimerId != null) {
155
+ clearTimeout(state._cancelTimerId);
156
+ state._cancelTimerId = null;
157
+ }
158
+ }
159
+
160
+ // --- Hash routing ---
161
+
162
+ export function getHashSessionId() {
163
+ const h = location.hash.slice(1);
164
+ return h || null;
165
+ }
166
+
167
+ export function setHashSessionId(id) {
168
+ history.replaceState(null, '', `#${id}`);
169
+ }
170
+
171
+ export function updateSessionInfo(id, title) {
172
+ dom.sessionInfo.textContent = title || (id ? id.slice(0, 8) + '…' : '');
173
+ document.title = title || '>_';
174
+ }
175
+
176
+ setConnectionStatus('disconnected');
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "WebAgent",
3
+ "short_name": ">_",
4
+ "description": "Remote Copilot CLI via browser",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#0d1117",
8
+ "theme_color": "#0d1117",
9
+ "icons": [
10
+ {
11
+ "src": "/icons/icon.svg",
12
+ "sizes": "any",
13
+ "type": "image/svg+xml"
14
+ },
15
+ {
16
+ "src": "/icons/icon-192.png",
17
+ "sizes": "192x192",
18
+ "type": "image/png"
19
+ },
20
+ {
21
+ "src": "/icons/icon-512.png",
22
+ "sizes": "512x512",
23
+ "type": "image/png"
24
+ }
25
+ ]
26
+ }