chamba 0.3.0 → 0.4.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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -44
  3. package/bin/chamba.js +212 -0
  4. package/dist/commands/advanced.js +278 -0
  5. package/dist/commands/dev.js +619 -0
  6. package/dist/commands/doctor.js +29 -0
  7. package/dist/commands/menu.js +80 -0
  8. package/dist/commands/onboard.js +229 -0
  9. package/dist/commands/settings.js +349 -0
  10. package/dist/lib/agent-context.js +177 -0
  11. package/dist/lib/browser.js +40 -0
  12. package/dist/lib/chamba-yaml.js +191 -0
  13. package/dist/lib/constants.js +135 -0
  14. package/dist/lib/dockerfile-builder.js +267 -0
  15. package/dist/lib/env.js +78 -0
  16. package/dist/lib/global-config.js +66 -0
  17. package/dist/lib/pnpm-store.js +19 -0
  18. package/dist/lib/ports.js +210 -0
  19. package/dist/lib/safe-rm.js +26 -0
  20. package/dist/lib/sessions.js +34 -0
  21. package/dist/lib/shadows.js +174 -0
  22. package/dist/lib/webterm.js +490 -0
  23. package/dist/lib/workspace-identity.js +260 -0
  24. package/package.json +61 -24
  25. package/schema/chamba.schema.json +65 -0
  26. package/templates/.dockerignore +3 -0
  27. package/templates/Dockerfile +173 -0
  28. package/templates/claude-statusline.sh +120 -0
  29. package/templates/context/baseline.md +13 -0
  30. package/templates/context/context-usage.md +1 -0
  31. package/templates/context/git-mode-local.md +1 -0
  32. package/templates/context/git-mode-strict.md +1 -0
  33. package/templates/context/git-mode-unrestricted.md +1 -0
  34. package/templates/context/git-unavailable.md +1 -0
  35. package/templates/context/shadow-paths.md +3 -0
  36. package/templates/context-usage.sh +249 -0
  37. package/templates/git-readonly-wrapper.mjs +309 -0
  38. package/templates/npmrc +2 -0
  39. package/templates/pnpm-config.yaml +9 -0
  40. package/templates/runtime-constants.mjs +18 -0
  41. package/templates/skills/chamba-statusline/SKILL.md +79 -0
  42. package/templates/skills/context-usage/SKILL.md +53 -0
  43. package/templates/skills/web-pane/SKILL.md +62 -0
  44. package/templates/startup-git-mode.mjs +145 -0
  45. package/templates/startup.mjs +333 -0
  46. package/templates/webpane.sh +126 -0
  47. package/templates/webterm/README.md +157 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +269 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +248 -0
  52. package/templates/webterm/package-lock.json +884 -0
  53. package/templates/webterm/package.json +17 -0
  54. package/templates/webterm/pane.js +156 -0
  55. package/templates/webterm/proc.js +89 -0
  56. package/templates/webterm/public/app/alerts.js +472 -0
  57. package/templates/webterm/public/app/cards.js +123 -0
  58. package/templates/webterm/public/app/clipboard.js +229 -0
  59. package/templates/webterm/public/app/composer.js +226 -0
  60. package/templates/webterm/public/app/connection.js +342 -0
  61. package/templates/webterm/public/app/dictation.js +98 -0
  62. package/templates/webterm/public/app/dom.js +37 -0
  63. package/templates/webterm/public/app/drafts.js +244 -0
  64. package/templates/webterm/public/app/frames.js +166 -0
  65. package/templates/webterm/public/app/main.js +82 -0
  66. package/templates/webterm/public/app/new-session.js +188 -0
  67. package/templates/webterm/public/app/note.js +24 -0
  68. package/templates/webterm/public/app/pane-frame.js +166 -0
  69. package/templates/webterm/public/app/pane.js +353 -0
  70. package/templates/webterm/public/app/state.js +51 -0
  71. package/templates/webterm/public/app/status-strip.js +170 -0
  72. package/templates/webterm/public/app/tabs.js +475 -0
  73. package/templates/webterm/public/app/terminal.js +102 -0
  74. package/templates/webterm/public/app/theme.js +46 -0
  75. package/templates/webterm/public/favicon.svg +21 -0
  76. package/templates/webterm/public/index.html +105 -0
  77. package/templates/webterm/public/styles.css +1193 -0
  78. package/templates/webterm/server.js +1142 -0
  79. package/templates/webterm/sessions.js +515 -0
  80. package/templates/webterm/snapshot.js +135 -0
  81. package/templates/webterm.sh +167 -0
  82. package/dist/cli.js +0 -1691
  83. package/dist/server.js +0 -1919
  84. package/inject/annotate.js +0 -18
  85. package/skill/README.md +0 -12
  86. package/skill/SKILL.md +0 -93
  87. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  88. package/web/assets/index-B9DI4F1Z.js +0 -202
  89. package/web/assets/index-DK_n6CTo.css +0 -2
  90. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  91. package/web/index.html +0 -28
@@ -0,0 +1,229 @@
1
+ // clipboard.js - copy and paste, which have two paths because a TUI can own the mouse.
2
+ //
3
+ // - The agent owns it. claude turns on mouse tracking, so xterm switches its own selection off and the
4
+ // highlight you drag belongs to the agent, not to the terminal. The agent then hands that text to the
5
+ // terminal with OSC 52, exactly as it would in a native one - so the OSC 52 handler below is what makes
6
+ // a drag in claude reach the clipboard at all.
7
+ // - The terminal owns it (a plain shell, or an agent that leaves the mouse alone). Then the selection is
8
+ // xterm's, and the copy shortcut below copies it.
9
+ //
10
+ // Either way the page writes the clipboard itself and says what it did in the status pill: the browser's
11
+ // own path through xterm only fires when the terminal has focus and a live selection, and says nothing
12
+ // when it does not fire. Ctrl+C is deliberately left alone - interrupting the agent must never depend on
13
+ // whether something happens to be selected.
14
+ //
15
+ // There is one clipboard - the machine's - and it changes only when the user copies something in the
16
+ // window they are looking at. Nothing else here may write it: not a screen being replayed, not a session
17
+ // running in the background. Anything less stops feeling like a clipboard.
18
+
19
+ import { connected, sendFrame } from "./connection.js";
20
+ import { noteMsg } from "./note.js";
21
+ import { attachedSid } from "./state.js";
22
+ import { term } from "./terminal.js";
23
+
24
+ // Shortcut hints follow the platform, so the pill only ever teaches keys that work here.
25
+ const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
26
+ const COPY_HINT = IS_MAC ? "Cmd+C" : "Ctrl+Shift+C";
27
+ const PASTE_HINT = IS_MAC ? "Cmd+V" : "Ctrl+V";
28
+ // The modifier that takes the mouse back from an app that is tracking it, so the browser gets the drag
29
+ // and the selection is xterm's again (xterm's shouldForceSelection: Alt on macOS, Shift everywhere else).
30
+ const FORCE_SELECT_KEY = IS_MAC ? "Alt" : "Shift";
31
+
32
+ // True while a TUI is tracking the mouse, which is also when xterm has no selection of its own to give.
33
+ function agentOwnsMouse() {
34
+ return term.modes.mouseTrackingMode !== "none";
35
+ }
36
+
37
+ // Last-resort clipboard write, for when the async clipboard API is missing or refuses (a non-localhost
38
+ // origin is not a secure context; an editor's embedded browser blocks it). execCommand only works inside
39
+ // the click or keypress that asked for the copy, which is why this stays synchronous.
40
+ function copyByExecCommand(text) {
41
+ const scratch = document.createElement("textarea");
42
+ scratch.value = text;
43
+ scratch.readOnly = true;
44
+ // Off-screen rather than hidden: a display:none field cannot be selected, so it cannot be copied.
45
+ scratch.style.position = "fixed";
46
+ scratch.style.top = "-1000px";
47
+ document.body.append(scratch);
48
+ scratch.select();
49
+ let copied = false;
50
+ try {
51
+ copied = document.execCommand("copy");
52
+ } catch {
53
+ // Blocked outright; the caller reports it.
54
+ }
55
+ scratch.remove();
56
+ term.focus();
57
+ return copied;
58
+ }
59
+
60
+ async function copyToClipboard(text) {
61
+ // No async clipboard at all: go straight to the fallback while the user gesture is still current.
62
+ if (!navigator.clipboard?.writeText) return copyByExecCommand(text);
63
+ try {
64
+ await navigator.clipboard.writeText(text);
65
+ return true;
66
+ } catch {
67
+ return copyByExecCommand(text);
68
+ }
69
+ }
70
+
71
+ // Silence is what made this feel broken, so every outcome says so.
72
+ async function copyText(text) {
73
+ if (!text) {
74
+ noteMsg("nothing is selected in the terminal - drag over the text you want first");
75
+ return false;
76
+ }
77
+ if (await copyToClipboard(text)) {
78
+ const lines = text.split("\n").length;
79
+ noteMsg(`copied ${lines} line${lines === 1 ? "" : "s"}`);
80
+ return true;
81
+ }
82
+ noteMsg("this browser would not give the page clipboard access", true);
83
+ return false;
84
+ }
85
+
86
+ // Text an agent handed over with OSC 52 that has not reached the clipboard yet - the browser refused the
87
+ // write, or this window was not the one in front. It arrives just after the drag rather than inside it,
88
+ // and Safari only allows a clipboard write inside the gesture that asked for one, so it is kept here and
89
+ // the copy shortcut - a real gesture, in this window - can finish the job. Nothing is lost either way.
90
+ let pendingOscText = "";
91
+
92
+ // A replay is the attached session's own past output, so it can still contain the OSC 52 an agent sent
93
+ // when something was copied in that session earlier. Writing it into the terminal re-runs that sequence,
94
+ // which put stale text on the clipboard on every attach - a session switch, a reload, a reconnect after
95
+ // sleep - and that is what made each session tab look like it carried a clipboard of its own. So the
96
+ // clipboard is left alone while a replay is being parsed.
97
+ //
98
+ // A counter rather than a flag because two replays can be in flight (a switch, then an immediate
99
+ // reconnect). xterm runs each write's callback once that chunk is parsed and before it parses anything
100
+ // written after it, so output that arrives while a replay is still parsing copies normally. The gate does
101
+ // cover anything already queued and not yet parsed when the replay lands, which in practice is nothing: a
102
+ // copy and a session switch are both done by hand, and the parser is never that far behind.
103
+ let pendingReplays = 0;
104
+
105
+ export function writeReplay(data) {
106
+ if (!data) return;
107
+ pendingReplays++;
108
+ term.write(data, () => {
109
+ pendingReplays--;
110
+ });
111
+ }
112
+
113
+ function copySelection() {
114
+ const text = term.getSelection();
115
+ if (text) {
116
+ copyText(text);
117
+ return;
118
+ }
119
+ // A copy that did not land when the agent pushed it - refused, or this window was not in front. This
120
+ // keypress is the gesture it was waiting for, in the window that asked for it.
121
+ if (pendingOscText) {
122
+ const pending = pendingOscText;
123
+ pendingOscText = "";
124
+ copyText(pending);
125
+ return;
126
+ }
127
+ // No terminal selection because the agent has the mouse: say what does work instead of "nothing here".
128
+ if (agentOwnsMouse()) {
129
+ noteMsg(`the agent is handling the mouse - dragging copies on its own, or hold ${FORCE_SELECT_KEY} while dragging to select here`);
130
+ return;
131
+ }
132
+ copyText("");
133
+ }
134
+
135
+ // Paste the clipboard into the attached session, as one paste frame the server wraps in bracketed-paste
136
+ // markers - the same path Send uses, so a multi-line paste arrives as a paste and not as a burst of
137
+ // Enter presses. Reading the clipboard needs permission, which the browser asks for once.
138
+ async function pasteIntoTerminal() {
139
+ if (!connected || !attachedSid) return;
140
+ if (!navigator.clipboard?.readText) {
141
+ noteMsg(`this browser will not let the page read the clipboard - press ${PASTE_HINT} in the terminal instead`, true);
142
+ return;
143
+ }
144
+ try {
145
+ const text = await navigator.clipboard.readText();
146
+ if (text) sendFrame({ t: "paste", data: text });
147
+ } catch {
148
+ noteMsg(`clipboard read was blocked - press ${PASTE_HINT} in the terminal instead`, true);
149
+ }
150
+ }
151
+
152
+ // Both handlers hang off the terminal, so they are installed from the entry rather than when this module is
153
+ // loaded - the terminal is put on the page there, in order, and a module that reached for it while the page
154
+ // was still loading would be reaching into something half-built.
155
+ export function installClipboard() {
156
+ // OSC 52 is how a program in the terminal puts text on the clipboard - it is what claude sends when you
157
+ // drag-select in its own UI. Nothing handled it here before, so those selections went nowhere.
158
+ term.parser.registerOscHandler(52, (payload) => {
159
+ // Payload is "<target>;<base64>": the target picks a clipboard (c: system, p: primary), which the
160
+ // browser does not distinguish, so it is dropped.
161
+ const split = payload.indexOf(";");
162
+ const data = split === -1 ? "" : payload.slice(split + 1);
163
+ // "?" asks the terminal to hand the clipboard back. It is never answered: the clipboard belongs to the
164
+ // host, and a process in the container must not be able to read it out through the relay. An empty
165
+ // payload is a clipboard clear, which is also not worth acting on.
166
+ if (!data || data === "?") return true;
167
+ // Replayed output, not something the user just did. Ignored outright: it is a copy that already
168
+ // happened, and the clipboard has moved on since.
169
+ if (pendingReplays > 0) return true;
170
+ let text = "";
171
+ try {
172
+ // Base64 to bytes to UTF-8. Decoding with atob alone yields latin-1, which mangles every
173
+ // non-ASCII character - the bug the upstream xterm clipboard addon shipped with.
174
+ text = new TextDecoder().decode(Uint8Array.from(atob(data), (char) => char.charCodeAt(0)));
175
+ } catch {
176
+ // A malformed payload is the sending program's problem, not something to report here.
177
+ return true;
178
+ }
179
+ // Only the window in front may write the clipboard. A background window - a second tab, or this one
180
+ // while the user is in another app - would replace what they copied there, so the text waits for a
181
+ // copy shortcut here instead of taking a clipboard it was not asked for.
182
+ if (!document.hasFocus()) {
183
+ pendingOscText = text;
184
+ return true;
185
+ }
186
+ // Not awaited: the parser reads the rest of the agent's output while the clipboard write settles.
187
+ // Chrome allows this write without a fresh click (the tab is focused and holds the permission); where
188
+ // it is refused, the text is kept so the copy shortcut can finish it.
189
+ copyText(text).then((copied) => {
190
+ pendingOscText = copied ? "" : text;
191
+ if (!copied) noteMsg(`press ${COPY_HINT} to finish copying the agent's selection`, true);
192
+ });
193
+ return true;
194
+ });
195
+
196
+ // Taken before xterm sees the key. Cmd+C would otherwise depend on the browser's copy event reaching
197
+ // xterm, and the ctrl+shift pair is worse than nothing today: xterm maps ctrl+letter ignoring shift, so
198
+ // Ctrl+Shift+C sends ^C and interrupts the agent. Plain Cmd+V / Ctrl+V is left to the browser's own
199
+ // paste event, which carries the clipboard with it and needs no permission.
200
+ term.attachCustomKeyEventHandler((event) => {
201
+ if (event.type !== "keydown") return true;
202
+
203
+ // Shift+Enter is a newline here too, so one habit works in both boxes on this page. A terminal has no such key
204
+ // of its own - Enter is a carriage return whatever else is held down, which is why an agent's own answer to
205
+ // "give me a newline" is Alt+Enter, ESC followed by CR. That is exactly what this sends, so this is a second
206
+ // key onto a sequence the agent already understands rather than anything new for it to support. Alt+Enter is
207
+ // untouched and still works, for the fingers that already know it.
208
+ if (event.key === "Enter" && event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
209
+ event.preventDefault();
210
+ if (connected && attachedSid) sendFrame({ t: "in", data: "\x1b\r" });
211
+ return false;
212
+ }
213
+
214
+ const cmd = event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;
215
+ const ctrlShift = event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey;
216
+ const key = event.key.toLowerCase();
217
+ if ((cmd || ctrlShift) && key === "c") {
218
+ event.preventDefault();
219
+ copySelection();
220
+ return false;
221
+ }
222
+ if (ctrlShift && key === "v") {
223
+ event.preventDefault();
224
+ pasteIntoTerminal();
225
+ return false;
226
+ }
227
+ return true;
228
+ });
229
+ }
@@ -0,0 +1,226 @@
1
+ // composer.js - the box under the terminal: a message written properly, and the images that go with it.
2
+ //
3
+ // What is in it belongs to the attached session (see drafts.js), and on Send the whole text goes as one paste
4
+ // frame the server wraps in bracketed-paste markers, so a multi-line message arrives as one message.
5
+
6
+ import { connected, everConnected, sendFrame } from "./connection.js";
7
+ import { attachBtn, fileInput, input, sendBtn } from "./dom.js";
8
+ import {
9
+ addImageToDraft,
10
+ atFirstLine,
11
+ atLastLine,
12
+ captureDraft,
13
+ dropDraft,
14
+ endHistoryWalk,
15
+ historyAt,
16
+ persistDrafts,
17
+ rememberSent,
18
+ schedulePersist,
19
+ showRecalled,
20
+ walkHistory,
21
+ } from "./drafts.js";
22
+ import { noteMsg } from "./note.js";
23
+ import { attachedSid, defaultAgent, KEY_QUERY, sessions } from "./state.js";
24
+ import { term } from "./terminal.js";
25
+
26
+ // Connection and attachment state show on the composer's border and in its placeholder rather than a
27
+ // separate status line: lit in the workspace colour when keystrokes have somewhere to go, grey when they do
28
+ // not, with the reason in the placeholder.
29
+ const INPUT_PLACEHOLDER = "Type a message. Paste or drop an image. Enter sends, Shift+Enter for a newline, Up recalls the last one.";
30
+
31
+ export function refreshComposer() {
32
+ const live = connected && Boolean(attachedSid);
33
+ input.classList.toggle("online", live);
34
+ input.classList.toggle("waiting", !live);
35
+ input.disabled = !live;
36
+ if (!connected) input.placeholder = everConnected ? "Reconnecting... your session kept running." : "Connecting...";
37
+ else if (live) input.placeholder = INPUT_PLACEHOLDER;
38
+ else if (sessions.length === 0) input.placeholder = `No sessions. Use "+ New session" above to start ${defaultAgent}.`;
39
+ else input.placeholder = "Pick a session above to type in it.";
40
+ }
41
+
42
+ export function autoGrow() {
43
+ input.style.height = "auto";
44
+ // Floor at the default height (112px, enough for the stacked buttons) so the box never shrinks
45
+ // behind them; grow with content up to a cap.
46
+ input.style.height = `${Math.min(Math.max(input.scrollHeight, 112), 200)}px`;
47
+ }
48
+ input.addEventListener("input", () => {
49
+ autoGrow();
50
+ // A recalled message that has been edited is a new message, so the arrows stop stepping through history.
51
+ endHistoryWalk();
52
+ // Kept up to date as you type, so a reload mid-sentence or a session taken away in another window does
53
+ // not lose it. The write itself is debounced.
54
+ captureDraft(attachedSid);
55
+ schedulePersist();
56
+ });
57
+
58
+ // Insert text into the composer at the current caret position (surrounded by spaces so a path
59
+ // does not fuse with adjacent words), then place the caret right after it.
60
+ export function insertAtCursor(text) {
61
+ const start = input.selectionStart ?? input.value.length;
62
+ const end = input.selectionEnd ?? input.value.length;
63
+ const before = input.value.slice(0, start);
64
+ const after = input.value.slice(end);
65
+ const lead = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
66
+ const trail = after && !after.startsWith(" ") && !after.startsWith("\n") ? " " : " ";
67
+ const piece = lead + text + trail;
68
+ // Attaching an image to a recalled message makes it a new message.
69
+ endHistoryWalk();
70
+ input.value = before + piece + after;
71
+ const caret = before.length + piece.length;
72
+ input.setSelectionRange(caret, caret);
73
+ autoGrow();
74
+ input.focus();
75
+ }
76
+
77
+ // A friendly placeholder token is shown in the composer for each attached image (like claude's own
78
+ // [Image #N]); the real container path is swapped in only at send time. Keyed by the visible token.
79
+ export const pendingImages = new Map();
80
+ export let imageCounter = 0;
81
+
82
+ // The counter belongs to the box, and a draft carries its own: switching sessions puts that session's counter
83
+ // back, so [Image #1] in one session can never resolve to another session's file.
84
+ export function setImageCounter(value) {
85
+ imageCounter = value;
86
+ }
87
+
88
+ // Empty the box, and the image tokens with it: a token whose text is gone points at nothing.
89
+ export function clearComposer() {
90
+ input.value = "";
91
+ pendingImages.clear();
92
+ imageCounter = 0;
93
+ endHistoryWalk();
94
+ autoGrow();
95
+ }
96
+
97
+ // --- Images ------------------------------------------------------------------------------------------------------------------------------
98
+
99
+ // Upload one image blob; on success insert a friendly [Image #N] token inline where the caret was,
100
+ // and remember which container path it maps to.
101
+ async function uploadImage(blob) {
102
+ // The upload is async: the caret can move and the window can switch sessions before it lands, so both
103
+ // are read now and the token goes where the image was actually attached.
104
+ const sid = attachedSid;
105
+ const at = input.selectionStart ?? input.value.length;
106
+ try {
107
+ const res = await fetch(`/upload${KEY_QUERY}`, { method: "POST", headers: { "Content-Type": blob.type }, body: blob });
108
+ const data = await res.json();
109
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
110
+ if (sid && sid !== attachedSid) {
111
+ addImageToDraft(sid, data.path);
112
+ return;
113
+ }
114
+ const token = `[Image #${++imageCounter}]`;
115
+ pendingImages.set(token, data.path);
116
+ input.setSelectionRange(at, at);
117
+ insertAtCursor(token);
118
+ captureDraft(attachedSid);
119
+ persistDrafts();
120
+ } catch (err) {
121
+ noteMsg(`upload failed: ${err.message}`, true);
122
+ }
123
+ }
124
+
125
+ // --- Sending -----------------------------------------------------------------------------------------------------------------------------
126
+
127
+ // Send the whole composer text as one paste. Each [Image #N] token is expanded to its real path
128
+ // (in place, so order is preserved) just before sending, so the agent receives the actual file paths.
129
+ function send() {
130
+ if (!connected || !attachedSid) return;
131
+
132
+ let text = input.value;
133
+ for (const [token, path] of pendingImages) {
134
+ text = text.split(token).join(path);
135
+ }
136
+ text = text.trim();
137
+ if (!text) return;
138
+
139
+ sendFrame({ t: "paste", data: text });
140
+
141
+ const sid = attachedSid;
142
+ // Stored expanded, exactly as the agent received it, so recalling and sending again says the same thing.
143
+ rememberSent(sid, text);
144
+ clearComposer();
145
+ dropDraft(sid);
146
+ term.focus();
147
+ }
148
+
149
+ sendBtn.addEventListener("click", send);
150
+
151
+ // Enter sends; Shift+Enter inserts a newline. Ctrl+C clears the composer (when nothing is selected,
152
+ // so a real copy still works), mirroring claude's "Ctrl+C clears the input line" behavior.
153
+ // Up and Down walk back through what this session was already sent - see drafts.js for when they
154
+ // are a recall and when they are just the caret moving.
155
+ input.addEventListener("keydown", (e) => {
156
+ if (e.ctrlKey && (e.key === "c" || e.key === "C")) {
157
+ if (input.selectionStart === input.selectionEnd) {
158
+ e.preventDefault();
159
+ clearComposer();
160
+ dropDraft(attachedSid);
161
+ }
162
+ return;
163
+ }
164
+ const plain = !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey;
165
+ if (e.key === "ArrowUp" && plain) {
166
+ // Only from the top of the box, and only into an empty one unless a walk is already under way.
167
+ if (!atFirstLine()) return;
168
+ if (historyAt === null && input.value !== "") return;
169
+ if (walkHistory(true)) e.preventDefault();
170
+ return;
171
+ }
172
+ if (e.key === "ArrowDown" && plain) {
173
+ if (historyAt === null || !atLastLine()) return;
174
+ if (walkHistory(false)) e.preventDefault();
175
+ return;
176
+ }
177
+ // Escape puts the box back the way the walk found it: empty, and typing into it again.
178
+ if (e.key === "Escape" && historyAt !== null) {
179
+ e.preventDefault();
180
+ endHistoryWalk();
181
+ showRecalled("");
182
+ return;
183
+ }
184
+ if (e.key === "Enter" && !e.shiftKey) {
185
+ e.preventDefault();
186
+ send();
187
+ }
188
+ });
189
+
190
+ // Paste an image straight into the composer.
191
+ input.addEventListener("paste", (e) => {
192
+ const items = e.clipboardData?.items || [];
193
+ for (const item of items) {
194
+ if (item.type.startsWith("image/")) {
195
+ const blob = item.getAsFile();
196
+ if (blob) {
197
+ e.preventDefault();
198
+ uploadImage(blob);
199
+ }
200
+ }
201
+ }
202
+ });
203
+
204
+ // Drag and drop images onto the composer.
205
+ ["dragover", "drop"].forEach((type) => {
206
+ input.addEventListener(type, (e) => {
207
+ // Only a file drag is ours. Without this the composer accepts any drag at all - including a session
208
+ // tab on its way along the bar, which would end as a drop on the box instead of a reorder.
209
+ if (!e.dataTransfer?.types?.includes("Files")) return;
210
+ e.preventDefault();
211
+ if (type === "drop") {
212
+ for (const file of e.dataTransfer?.files || []) {
213
+ if (file.type.startsWith("image/")) uploadImage(file);
214
+ }
215
+ }
216
+ });
217
+ });
218
+
219
+ // Attach button -> file picker.
220
+ attachBtn.addEventListener("click", () => fileInput.click());
221
+ fileInput.addEventListener("change", () => {
222
+ for (const file of fileInput.files || []) {
223
+ if (file.type.startsWith("image/")) uploadImage(file);
224
+ }
225
+ fileInput.value = "";
226
+ });