Skip to content

Commit 182679a

Browse files
thefallentreeclaude
andcommitted
web_shell_override: replace the native command input with a custom widget + history
Mirrors fluffos/fluffos@4b623a70 into this repo's copy (pack_lib_for_web.sh prefers it over the release zip's own index.html at publish time). Reported live on iOS Chrome: the system AutoFill accessory bar (passwords/cards/contacts icons above the keyboard) kept showing even on the very first prompt of a session, on a plain type="text" field that had never been type="password" -- ruling out the previous CSS- masking fix's theory. Researched properly: this is iOS's system-level AutoFill accessory view tied to focusing any real, on-screen, focusable form control, not something reliably suppressible via HTML/CSS from web content for that specific UI surface. Replaced the real <input>/<textarea> with a custom-rendered widget: a plain div renders text/caret/placeholder itself, with an invisible real <textarea> underneath doing actual keystroke/IME/selection capture -- confirmed zero <input> elements exist on the page now. Also restores genuine mid-string cursor editing (not just append-only) and adds per-session shell-style command history (ArrowUp/ArrowDown, 200-entry cap, draft-stash so arrowing back down past the newest entry restores the in-progress line). Enter-to-submit checks event.isComposing so confirming an IME candidate doesn't also submit. Verified end to end against a real packed site: typing, masking, mid-string insert-at-cursor, history recall/restore, and per-session draft+history surviving a tab switch and back all correct, zero console errors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VyQCUoTo1Z93Py9aVFHQi1
1 parent 414a0ff commit 182679a

1 file changed

Lines changed: 193 additions & 33 deletions

File tree

scripts/web_shell_override/index.html

Lines changed: 193 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -181,23 +181,57 @@
181181
}
182182
#inputbar.hidden, #inputbar.logs-active { display: none; }
183183
#inputbar .prompt { padding: 10px 0 10px 16px; color: var(--accent); }
184+
/* Custom command input: NOT a real <input>/<textarea> visibly -- #cmd is
185+
a plain div that renders text/caret/placeholder itself, overlaying an
186+
invisible real <textarea id="cmdReal"> that does the actual
187+
keystroke/IME capture (#cmdReal rules below). This exists because a
188+
real, on-screen, focusable <input> or <textarea> is exactly what iOS
189+
keys its system-level AutoFill accessory bar (the passwords/cards/
190+
contacts icon row above the keyboard) off of -- confirmed live on
191+
both Safari and Chrome for iOS (same WebKit engine underneath) even
192+
with autocomplete=off, autocapitalize=off, and (previously) CSS-only
193+
password masking that never touched `type`: none of that stopped it,
194+
because the bar isn't solely a type="password"/autocomplete heuristic
195+
but a broader system feature tied to focusing a real form control.
196+
An invisible, off-the-visual-stage-but-still-real control avoids that
197+
system integration entirely while keeping real OS text editing
198+
(selection, IME composition, copy/paste, undo) working underneath. */
184199
#cmd {
185200
flex: 1; background: transparent; border: 0; outline: 0; color: var(--fg);
186201
font: inherit; padding: 10px 16px 10px 8px;
202+
position: relative; white-space: pre; overflow-x: auto; overflow-y: hidden;
203+
cursor: text;
204+
}
205+
/* Scrollbar-less horizontal scroll (overflow-x:auto above is what lets
206+
renderCmd()'s scrollIntoView bring a caret past the visible edge back
207+
into view -- this just hides the resulting scrollbar chrome). */
208+
#cmd { scrollbar-width: none; -ms-overflow-style: none; }
209+
#cmd::-webkit-scrollbar { display: none; }
210+
#cmd .cmd-placeholder { color: var(--dim); }
211+
#cmd.has-text .cmd-placeholder { display: none; }
212+
#cmd .cmd-caret {
213+
display: inline-block; width: 1px; height: 1em; background: var(--fg);
214+
vertical-align: text-bottom; margin-left: -1px; visibility: hidden;
215+
}
216+
#cmd.focused .cmd-caret {
217+
visibility: visible; animation: cmd-caret-blink 1s step-end infinite;
218+
}
219+
@keyframes cmd-caret-blink { 50% { opacity: 0; } }
220+
#cmd.disabled { color: var(--dim); cursor: default; }
221+
#cmd.disabled .cmd-caret { visibility: hidden; }
222+
#cmdReal {
223+
position: absolute; inset: 0; width: 100%; height: 100%;
224+
margin: 0; padding: inherit; border: 0; outline: 0; resize: none;
225+
overflow: hidden; background: transparent; font: inherit;
226+
white-space: pre; color: transparent; caret-color: transparent;
227+
/* Belt-and-suspenders on top of color:transparent: masks the real
228+
value even from a long-press text-selection loupe on WebKit, which
229+
renders actual glyphs at the OS compositing level rather than
230+
going through page CSS color. Harmless to leave on unconditionally
231+
(this element is never visible either way) even when the session
232+
isn't currently password-masked. */
233+
-webkit-text-security: disc;
187234
}
188-
/* Password-style masking WITHOUT ever setting type="password": once an
189-
<input> has been type="password" even briefly, iOS/Safari's AutoFill
190-
heuristic sticks to it as a credential field for the rest of the
191-
page's life (the passwords/cards/contacts QuickType bar keeps showing
192-
above the keyboard even after switching back to type="text" --
193-
reported live). This mud never wants the browser's credential
194-
manager involved at all (autocomplete=off already says so) -- the
195-
masking is purely visual, so do it with CSS instead and never touch
196-
`type`. -webkit-text-security is WebKit/Blink-only (not in Firefox);
197-
acceptable here since it only affects whether typed characters are
198-
LEGIBLE, never functionality, and iOS/Chrome cover the overwhelming
199-
majority of mobile traffic this page sees. */
200-
#cmd.masked { -webkit-text-security: disc; }
201235
.modal-overlay {
202236
position: fixed; inset: 0; background: rgba(4, 6, 10, 0.72);
203237
display: flex; align-items: center; justify-content: center;
@@ -374,9 +408,12 @@ <h1>FluffOS / WebAssembly</h1>
374408
<canvas id="jscanvas" width="300" height="130"></canvas>
375409
<div id="inputbar">
376410
<span class="prompt">&gt;</span>
377-
<input id="cmd" autocomplete="off" autocapitalize="off" autocorrect="off"
378-
spellcheck="false" enterkeyhint="send" autofocus
379-
placeholder="loading…" disabled>
411+
<div id="cmd">
412+
<span id="cmdPlaceholder" class="cmd-placeholder"></span><span id="cmdBefore" class="cmd-text"></span><span id="cmdCaret" class="cmd-caret"></span><span id="cmdAfter" class="cmd-text"></span>
413+
<textarea id="cmdReal" rows="1" autocomplete="off" autocapitalize="off"
414+
autocorrect="off" spellcheck="false" enterkeyhint="send"
415+
autofocus disabled></textarea>
416+
</div>
380417
</div>
381418

382419
<div id="errorModal" class="modal-overlay" hidden>
@@ -651,6 +688,11 @@ <h2 id="errorModalTitle">Driver crashed</h2>
651688
const NOWRAP_COLS = 80;
652689
let wrapMode = localStorage.getItem('fluffos-wrap') !== 'off';
653690

691+
// Cap on each session's command history (see makeSession()'s history/
692+
// historyIndex/historyDraft) so an extremely long session doesn't grow
693+
// the array forever; oldest entries drop off the front.
694+
const HISTORY_MAX = 200;
695+
654696
// Single choke point for sizing a session's terminal to the current
655697
// viewport, mode-aware -- replaces every raw fit() call below. fit() (and
656698
// proposeDimensions()) are no-ops on a hidden pane either way (the addon
@@ -688,7 +730,18 @@ <h2 id="errorModalTitle">Driver crashed</h2>
688730
}
689731

690732
const status = document.getElementById('status');
691-
const input = document.getElementById('cmd');
733+
// The custom command input -- see the big comment on #cmd's CSS for why
734+
// this isn't a real visible <input>/<textarea>: cmdWrap is the plain div
735+
// that renders text/caret/placeholder itself; cmdReal is the invisible
736+
// real <textarea> doing actual keystroke/IME/selection capture underneath
737+
// it. Everything that used to read/write a native input's .value/
738+
// .disabled/.focus()/.placeholder now goes through renderCmd() plus these
739+
// refs instead.
740+
const cmdWrap = document.getElementById('cmd');
741+
const cmdReal = document.getElementById('cmdReal');
742+
const cmdBefore = document.getElementById('cmdBefore');
743+
const cmdAfter = document.getElementById('cmdAfter');
744+
const cmdPlaceholder = document.getElementById('cmdPlaceholder');
692745
const inputBar = document.getElementById('inputbar');
693746
const termEl = document.getElementById('term');
694747
const logsEl = document.getElementById('logs');
@@ -698,6 +751,59 @@ <h2 id="errorModalTitle">Driver crashed</h2>
698751
const logsBadge = document.getElementById('logsBadge');
699752
const utf8enc = new TextEncoder();
700753

754+
// --- custom command input rendering ---------------------------------------
755+
// Syncs cmdWrap's visible spans to cmdReal's real value + cursor position.
756+
// Called after anything that could change either: typing, IME composition,
757+
// paste, undo, arrow/Home/End navigation, a click repositioning the caret,
758+
// programmatic value changes (submit-clear, history recall, tab-switch
759+
// draft restore), and the masked-class toggle. Cheap enough to just call
760+
// liberally rather than track precisely what changed.
761+
function renderCmd() {
762+
const val = cmdReal.value;
763+
const masked = cmdWrap.classList.contains('masked');
764+
const display = masked ? '•'.repeat(val.length) : val;
765+
// selectionStart tracks the real textarea's cursor even though it's
766+
// invisible -- this is what lets Left/Right/Home/End/click-to-position
767+
// keep working underneath a custom-rendered caret instead of only
768+
// supporting append-at-the-end editing.
769+
const pos = Math.max(0, Math.min(display.length, cmdReal.selectionStart ?? display.length));
770+
cmdBefore.textContent = display.slice(0, pos);
771+
cmdAfter.textContent = display.slice(pos);
772+
cmdWrap.classList.toggle('has-text', val.length > 0);
773+
// Keep the caret scrolled into view -- a real <input> does this
774+
// automatically when its content overflows; #cmd is a plain div and
775+
// won't unless told to. 'nearest' is a no-op when already visible, so
776+
// this is cheap to call on every render.
777+
cmdCaret.scrollIntoView({ block: 'nearest', inline: 'nearest' });
778+
}
779+
// A MUD command line is conceptually single-line; cmdReal is a <textarea>
780+
// (not <input>) specifically because textareas are a much weaker signal
781+
// to iOS's AutoFill heuristics than a single-line text input (see the CSS
782+
// comment on #cmd), but that means an actual '\n' CAN land in .value from
783+
// a multi-line paste. Flatten it rather than let renderCmd() (white-space:
784+
// pre) show a broken-looking embedded line break, or Enter-to-submit
785+
// later send a multi-line blob to the driver as one "line".
786+
cmdReal.addEventListener('input', () => {
787+
if (cmdReal.value.includes('\n')) {
788+
const pos = cmdReal.selectionStart;
789+
cmdReal.value = cmdReal.value.replace(/\r?\n/g, ' ');
790+
cmdReal.selectionStart = cmdReal.selectionEnd = pos;
791+
}
792+
renderCmd();
793+
});
794+
// keyup/click/select cover cursor-position changes that 'input' alone
795+
// misses (arrow keys, Home/End, clicking to reposition, select-all).
796+
for (const ev of ['keyup', 'click', 'select']) {
797+
cmdReal.addEventListener(ev, renderCmd);
798+
}
799+
cmdReal.addEventListener('focus', () => cmdWrap.classList.add('focused'));
800+
cmdReal.addEventListener('blur', () => cmdWrap.classList.remove('focused'));
801+
// Clicking anywhere in the rendered area should feel like clicking a
802+
// normal input -- cmdReal already covers the full box (position:absolute;
803+
// inset:0) and sits last in DOM order so it naturally receives the click
804+
// directly; this is just for the rare case something intercepts it first.
805+
cmdWrap.addEventListener('click', () => cmdReal.focus());
806+
701807
const sessions = []; // game sessions, tab order
702808
const byId = new Map(); // driver conn id -> session
703809
let view = null; // what the pane area shows: a session, or 'logs'
@@ -762,6 +868,9 @@ <h2 id="errorModalTitle">Driver crashed</h2>
762868
serverEchoes: false,
763869
disconnected: false,
764870
inputValue: '', // unsent draft in the input bar, saved on tab switch
871+
history: [], // submitted lines, oldest first, per-session shell-style recall
872+
historyIndex: -1, // -1 = editing a fresh line; 0 = newest entry; counts backward
873+
historyDraft: '', // the in-progress line stashed when ArrowUp starts browsing
765874
unread: 0,
766875
pendingIn: [], // [id, bytes] arriving before our conn id is known
767876
outbox: [], // page -> driver, flushed outside receive()
@@ -846,8 +955,14 @@ <h2 id="errorModalTitle">Driver crashed</h2>
846955
s.telnet.onText = (t) => s.term.write(t);
847956
s.telnet.onEcho = (on) => {
848957
s.serverEchoes = on;
849-
// CSS-only masking, never input.type -- see the #cmd.masked rule.
850-
if (view === s && !s.charMode) input.classList.toggle('masked', on);
958+
// Custom-rendered masking, never a real type="password" -- see the
959+
// #cmd/#cmdReal CSS comment. Re-render immediately: if there's
960+
// already buffered text when echo state flips mid-line, it should
961+
// switch to/from dots right away, not just on the next keystroke.
962+
if (view === s && !s.charMode) {
963+
cmdWrap.classList.toggle('masked', on);
964+
renderCmd();
965+
}
851966
};
852967
s.telnet.onCharMode = (on) => {
853968
if (on === s.charMode) return;
@@ -892,7 +1007,7 @@ <h2 id="errorModalTitle">Driver crashed</h2>
8921007
s.term.options.fontSize = size;
8931008
if (view === s) refit(s);
8941009
}
895-
input.placeholder = placeholderForViewport();
1010+
cmdPlaceholder.textContent = placeholderForViewport();
8961011
});
8971012

8981013
// Reflect the active session's state onto the shared input bar/status.
@@ -912,15 +1027,17 @@ <h2 id="errorModalTitle">Driver crashed</h2>
9121027
status.textContent = s.disconnected ? 'disconnected'
9131028
: s.connId >= 0 ? 'connected (conn ' + s.connId + ')'
9141029
: 'connecting…';
915-
input.classList.toggle('masked', s.serverEchoes);
916-
input.disabled = s.disconnected || s.connId < 0;
1030+
cmdWrap.classList.toggle('masked', s.serverEchoes);
1031+
cmdReal.disabled = s.disconnected || s.connId < 0;
1032+
cmdWrap.classList.toggle('disabled', cmdReal.disabled);
1033+
renderCmd();
9171034
refit(s);
918-
if (!input.disabled && view === s) input.focus();
1035+
if (!cmdReal.disabled && view === s) cmdReal.focus();
9191036
}
9201037
}
9211038

9221039
function saveDraft() {
923-
if (view && view !== 'logs') view.inputValue = input.value;
1040+
if (view && view !== 'logs') view.inputValue = cmdReal.value;
9241041
}
9251042

9261043
function activateSession(s) {
@@ -939,7 +1056,8 @@ <h2 id="errorModalTitle">Driver crashed</h2>
9391056
o.tab.classList.toggle('active', o === s);
9401057
o.pane.classList.toggle('active', o === s);
9411058
}
942-
input.value = s.inputValue;
1059+
cmdReal.value = s.inputValue;
1060+
renderCmd();
9431061
// fit() no-ops while a pane is display:none (the addon bails on NaN
9441062
// geometry), so refit now that this one is visible again.
9451063
applyInputState(s);
@@ -1016,11 +1134,53 @@ <h2 id="errorModalTitle">Driver crashed</h2>
10161134
});
10171135

10181136
// --- line mode input (shared bar, routed to the active session) ----------
1019-
input.addEventListener('keydown', (ev) => {
1020-
if (ev.key !== 'Enter' || !activeGame) return;
1137+
// ArrowUp/ArrowDown: shell-style per-session history recall. `history` is
1138+
// oldest-first; `historyIndex` counts back from the newest entry (-1 =
1139+
// not browsing / editing a fresh line, 0 = the single most recent entry,
1140+
// 1 = the one before that, ...), so history[history.length - 1 -
1141+
// historyIndex] is always the entry currently shown. `historyDraft`
1142+
// stashes whatever was being typed the moment ArrowUp first starts
1143+
// browsing, so ArrowDown-ing back past the newest entry restores it
1144+
// instead of leaving a stale value or an empty line.
1145+
cmdReal.addEventListener('keydown', (ev) => {
1146+
if (!activeGame) return;
10211147
const s = activeGame;
1022-
const line = input.value;
1023-
input.value = '';
1148+
if (ev.key === 'ArrowUp') {
1149+
if (!s.history.length || s.historyIndex >= s.history.length - 1) return;
1150+
ev.preventDefault();
1151+
if (s.historyIndex === -1) s.historyDraft = cmdReal.value;
1152+
s.historyIndex++;
1153+
cmdReal.value = s.history[s.history.length - 1 - s.historyIndex];
1154+
cmdReal.selectionStart = cmdReal.selectionEnd = cmdReal.value.length;
1155+
renderCmd();
1156+
return;
1157+
}
1158+
if (ev.key === 'ArrowDown') {
1159+
if (s.historyIndex === -1) return;
1160+
ev.preventDefault();
1161+
s.historyIndex--;
1162+
cmdReal.value = s.historyIndex === -1
1163+
? s.historyDraft
1164+
: s.history[s.history.length - 1 - s.historyIndex];
1165+
cmdReal.selectionStart = cmdReal.selectionEnd = cmdReal.value.length;
1166+
renderCmd();
1167+
return;
1168+
}
1169+
// isComposing: mid-IME-composition (e.g. choosing a Pinyin candidate)
1170+
// Enter confirms the composition, it must NOT also submit the line --
1171+
// cmdReal being a <textarea> means an un-prevented Enter would otherwise
1172+
// insert a literal newline too (a real single-line <input> couldn't).
1173+
if (ev.key !== 'Enter' || ev.isComposing) return;
1174+
ev.preventDefault();
1175+
const line = cmdReal.value;
1176+
cmdReal.value = '';
1177+
renderCmd();
1178+
if (line.length && s.history[s.history.length - 1] !== line) {
1179+
s.history.push(line);
1180+
if (s.history.length > HISTORY_MAX) s.history.shift();
1181+
}
1182+
s.historyIndex = -1;
1183+
s.historyDraft = '';
10241184
if (!s.serverEchoes) s.term.write(line + '\r\n');
10251185
s.sendData(Array.from(utf8enc.encode(line + '\r\n')));
10261186
});
@@ -1168,9 +1328,9 @@ <h2 id="errorModalTitle">Driver crashed</h2>
11681328
// Belt-and-suspenders for the same keyboard problem: on a browser without
11691329
// window.visualViewport, pull a freshly-focused input back into view
11701330
// rather than trusting the browser's own scroll-into-view behavior.
1171-
input.addEventListener('focus', () => {
1331+
cmdReal.addEventListener('focus', () => {
11721332
if (window.visualViewport) return;
1173-
setTimeout(() => input.scrollIntoView({ block: 'end', behavior: 'smooth' }), 50);
1333+
setTimeout(() => inputBar.scrollIntoView({ block: 'end', behavior: 'smooth' }), 50);
11741334
});
11751335

11761336
// The first game tab exists before the driver finishes loading, so the
@@ -1294,8 +1454,8 @@ <h2 id="errorModalTitle">Driver crashed</h2>
12941454
// done its job. (connectSession -> applyInputState already wrote the
12951455
// 'connected (conn N)' status; finish() only clears the overlay.)
12961456
BootProgress.finish();
1297-
input.placeholder = placeholderForViewport();
1298-
input.focus();
1457+
cmdPlaceholder.textContent = placeholderForViewport();
1458+
cmdReal.focus();
12991459
tabAdd.disabled = false;
13001460
}).catch((e) => {
13011461
BootProgress.finish();

0 commit comments

Comments
 (0)