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 "> ></ 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>
651688const NOWRAP_COLS = 80 ;
652689let 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
690732const 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' ) ;
692745const inputBar = document . getElementById ( 'inputbar' ) ;
693746const termEl = document . getElementById ( 'term' ) ;
694747const logsEl = document . getElementById ( 'logs' ) ;
@@ -698,6 +751,59 @@ <h2 id="errorModalTitle">Driver crashed</h2>
698751const logsBadge = document . getElementById ( 'logsBadge' ) ;
699752const 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+
701807const sessions = [ ] ; // game sessions, tab order
702808const byId = new Map ( ) ; // driver conn id -> session
703809let 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
9221039function saveDraft ( ) {
923- if ( view && view !== 'logs' ) view . inputValue = input . value ;
1040+ if ( view && view !== 'logs' ) view . inputValue = cmdReal . value ;
9241041}
9251042
9261043function 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