One section of Twig's hard-won lessons. The index, the ship checklist and the shared conventions are in AGENTS.md; the other sections live beside this file in docs/lessons/.
(For the remote/SSH side see "SSH, SFTP and the terminal" above.)
-
The terminal is garbled after the screen comes back on (★ 2026-08-27,
TermSizeFreezeLayout): turning the screen off relays out the window a couple of times and it ends up at the size it started from (the IME is dismissed and restored; with fullscreen on, the status bar returns for the keyguard andapplyFullscreenhides it again on focus). termux resizes the emulator straight fromTerminalView.onSizeChanged, and resizing the alternate screen buffer (tmux, htop, vim) destroys what is on it — the peer is supposed to repaint on SIGWINCH. With a round trip it never does: for SSH the resize is debounced 300 ms and de-duplicated, so A→B→A sends nothing at all, and even a local PTY only gets two SIGWINCHes whose net size is unchanged, which tmux answers with no redraw. So the mangled buffer stays on screen until some later size change makes the peer draw again — hence "hide and show the keyboard and it comes back".- The fix is to not let a window that is merely jittering reach the emulator:
TerminalViewis final (it cannot be subclassed to gateonSizeChanged), so a parent frame keeps handing the child the size it already has. It freezes ononWindowFocusChanged(false)— ★ that arrives beforeonPause, and the screen-off relayout can land in between — and is lifted once layout has been quiet for 250 ms after focus/resume (with a 1.5 s ceiling), applying the settled size in one step, which is a no-op when it is the size we started from. The soft keyboard does not take window focus, so the ordinary show/hide resize is untouched. - Explicit
updateSize()callers (attachSession/setTextSize/setTypeface) are deliberately not gated: those run when we decide, not when the window jitters — and gating them would leave a freshly opened session stuck at 80x24 (openNewreads the emulator size 120 ms after attach to open the shell with it). TermSizeFreezeTestasserts on the child's real width/height across a round trip, not on "was the flag set" — the former is whatonSizeChangedreacts to.- ★★ Only returning to the foreground may arm the thaw; a layout pass may postpone an armed one but must never start one (second round, same day): leaving the app restores the status bar, and that relayout kept rescheduling the 250 ms debounce until it went quiet — ~300 ms later, with the user already in another app, the thaw fired and applied the shorter size. We then froze again on that wrong size, and coming back applied the real one: a blank strip about a status bar tall at the bottom, and the content visibly jumping. Note the freeze itself was working; what leaked was the thaw.
- This was settled by a log line, not by reasoning (
logSize, kept in the code —adb logcat -s TwigTerm:I, grepsize freeze): the trace readfreeze 1934/38 → thaw 1835/36 (still in the background) → freeze 1835/36 → thaw 1934/38, which named the culprit immediately. The round before it had "fixed" the wrong thing (freezing earlier, inonUserLeaveHint) on a plausible but wrong story — the log shows focus loss always arrives first, so that hook never even ran. The exits (focus loss /onPause/ user-leave) arrive in no fixed order, so correctness must not depend on their ordering.
- The fix is to not let a window that is merely jittering reach the emulator:
-
Chinese looks fat and
●looks tall and thin in the terminal (★ 2026-08-02): termux'sTerminalRendererfixes the column width atmeasureText("X"), and when the measured width of a drawn run does not match "columns × column width" it doescanvas.scale(ratio, 1f)to squeeze or stretch it into the grid horizontally only (vertically never). Hence: the systemMONOSPACEhas no Han characters and falls back to Noto Sans CJK (Han = 1.0em) while the Latin X is only ≈0.6em, so a 2-column target of 1.2em against a measured 1.0em stretches Chinese 20% wider = fat;●(U+25CF) is East Asian Ambiguous and is absent from theWcWidthtable (dumped it — the nearby ranges are 9725–9726 and friends) so it counts as 1 column, while the glyph is full-width → squeezed to 60% width at unchanged height = tall and thin.- Changing the font is the only fix (the scaling switch is internal to the renderer with no
API; changing wcwidth would disagree with the remote
wcwidthand misplace the cursor, which is worse). The criterion is that the font's advance must be 0.5em — then Latin is 1 column, the system's fallback Han is 1.0em = exactly 2 columns, and both scale factors return to 1.0. It does not have to be a CJK monospace font:Iosevka(0.5em, and it includes the geometric shapes block so●is also 0.5em) was measured perfect on 2026-08-02 and is far smaller than a full CJK font. A 0.6em monospace font (JetBrains Mono and friends) still deforms. - The font is not bundled (size first):
TerminalFontimports via SAF → validates the sfnt magic- tries to load it → copies into
filesDir/fonts(content:// permissions are unreliable across restarts, the same pattern as importing an SFTP private key).
- tries to load it → copies into
- Changing the font is the only fix (the scaling switch is internal to the renderer with no
API; changing wcwidth would disagree with the remote
-
Local shell sessions (★ 2026-08-03): termux's
TerminalSessionalready forks a local PTY withJNI.createSubprocess, andlibtermux.sohas always shipped in the APK with the terminal-emulator dependency (~10 KB each for arm64/x86_64), so the local terminal does not need the bridge thread, resize probing or auto-reconnect — those are all for SSH. Two timing facts you must know:TerminalView.attachSession→updateSize()→TerminalSession.updateSize()will callinitializeEmulatoritself (i.e. fork the shell) whenmEmulator == null. Calling it again manually after attach forks a second process and overwrites the first one's fd/emulator. CheckgetEmulator() == nullbefore topping it up.- An SSH session injects the emulator before creating the view, but a local session only has one
once the process is running — so
TermSession'slateinit var emulatorthrowsUninitializedPropertyAccessExceptionin UI callbacks (maybeShowImeand friends). Anywhere that might run before the session is ready must useemulatorOrNull. - Limitations: the process runs as the app's uid with no root; only the system mksh and toybox
are available; API 29+ W^X means binaries in the app's private directory cannot be execve'd
(interpreting a script with
sh xxx.shis fine). $HOMEonly applies to the shell itself; OpenSSH does not honor it:ssh/ssh-keygenresolve~throughgetpwuid(getuid())->pw_dir, and on Android an app uid'spw_diris always the unwritable/data→Could not create directory '/data/.ssh'. Keys can live in the app's private directory, but the path must be spelled out everywhere (-f,-i,-o UserKnownHostsFile=,-F, and no~inside config either). Current design:SshHomecreatesfilesDir/.ssh(700) plus aconfigwith fully absolute paths (600), and.mkshrcadds-Fpointing at it forssh/scp/sftp. ★ The config must exist first — with-Fpointing at a nonexistent file, ssh errors out immediately, unlike a missing default~/.ssh/configwhich is silently skipped.ssh-keygengets its default-ffrom a wrapper function rather than an alias: for-R/-Fthe-fmeans known_hosts, and a hard-coded alias would makessh-keygen -R hostrewrite the private key file.- Cross-session history is impossible (★ measured and settled 2026-08-06, do not try again):
Android's bundled
/system/bin/sh(mksh R59) is compiled withHAVE_PERSISTENT_HISTORY=0, andstrings /system/bin/sh | grep -i histdoes not even contain the stringHISTFILE(onlyHISTSIZE) — setting it neither writes nor reads (pre-creating the file and passing it as an environment variable still givesfc -l"no history (yet)"). History is a pure in-memory array, andfchas no loading command like bash'shistory -r. The app layer cannot fill the gap either, since the only channel in is PTY input and input means execution. Really doing it would mean bundling a shell with persistent history (in nativeLibraryDir to dodge W^X), which conflicts with size-first. Not done. - Tab completion finds no commands = the PATH directories cannot be listed (★ 2026-08-06,
CmdShims): the system PATH directories aredrwxr-x--x root:shell(/system/xbinisdrwxr-x---), so the app uid has onlyx(enter/execute by name), notr(list), and mksh's completionopendirreads out zero candidates./apex/…/binis 0755, which is why it looks like "some complete and some do not". Everything is normal underadb shellbecause that user is in theshellgroup — do not take adb's behavior as the truth when diagnosing this; to verify as the app uid you must temporarily install a debug build and userun-as(a release build refusesrun-as). This is DAC, not SELinux, so there is no avc denied in logcat. The workaround (CmdShims) does not list directories, it asks by name: runningtoyboxwith no arguments prints every command name it supports (210 measured; most commands in/system/binare symlinks to it), plus a hard-coded list of Android/third-party names probed by stat along PATH; every hit gets a symlink of the same name infilesDir/bin, and that directory is prepended to PATH. ★ Symlinks are not subject to W^X: after the kernel resolves it, the execve target is the real binary under/system/bin, not a file on/data. ★ Probing must walk the whole PATH:ssh/scp/ssh-keygenare measured to live in/product/bin, not/system/bin. - Prefix history search is free: mksh has
search-history-up/-down(searching by whatever is typed before the cursor as a prefix) but binds them only to PageUp/PageDown (^[[5~/^[[6~), which a phone keyboard cannot reach. The.mkshrctemplate binds them to the arrow keys; on an empty line the behavior is identical toup-history, so it is a pure improvement. Bind both^[[(normal cursor keys) and^[O(application cursor keys, SS3) — binding the latter also makes mksh treat^[Oas prefix-2, so the SS3 forms of Home/End start working too. ★ To inspect mksh bindings useadb shell -t -t "sh -ic 'bind'":bindrequires an interactive shell (sh -creports "can't bind, not a tty"; having a pty is not enough), and in its output^Xis how^[[is displayed.
-
The accessory keys were clipped at a large system font scale (★ 2026-08-27): the eight keys per row are
width=0, weight=1, so on a 360dp screen a cell is only ~45dp — while the default button style spends 16dp of padding on each side (and reserves an 88dpminWidth), leaving barely 13dp for "SHIFT"/"PGUP". At font scale 1.0 it just about survives; the labels aresp, so raising the system font size clips them. The cells are adjacent, so the gap between labels is spacing enough — the padding and both minimums are zeroed, which alone buys back 32dp. ★ Per-button auto-sizing is the wrong second half (tried and reverted the same day):TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(8, 12, 1, SP)does keep every label whole, but each key shrinks independently — measured at font scale 1.5, "SHIFT" lands on 9sp next to "ESC" still at 12sp, and a grid of uniform keys in a ragged mix of sizes reads as "the labels all got smaller". A key bar must share one size:TermKeyFitpicks the largest at which the widest label fits a cell (measured bold — a modifier key turns bold while armed, its widest state), andfitExtraKeyTextapplies it to every button from a layout listener. It runs during layout, so it must settle: the text size does not change the cells, so the next pass computes the same value and theabs(... ) > 0.5fguard stops therequestLayoutloop. ★TermKeyFitTestmust run with@GraphicsMode(NATIVE): Robolectric's stub paint reports one pixel per character, so every label "fits" at any size and the whole assertion is vacuous (the first run of this measurement printedpaint=5.0for "SHIFT" and was believed for a moment). -
Pinch zoom in the terminal did not change the font size (★ 2026-08-02): termux's
mScaleFactoris a cumulative factor, and it is overwritten by the return value ofTerminalViewClient.onScale. The old implementation used a fixed initial font size as the base and returned 1.0f every time, resetting the accumulation, so one gesture only ever set the samebase × thresholdvalue → pinching did nothing. The base must be the current font size. Two related fixes:setTextSizedoes notrequestLayout(), so theOnGlobalLayoutListenerpath that syncs the remote PTY never fires (the remote keeps emitting at the old cols/rows and never receives SIGWINCH, leaving stale content on screen) — hook termux'sonEmulatorSetcallback, which fires when rows/columns actually change; andupdateSize()'sinvalidate()is inside the "rows/columns changed" branch (setTypefacecompensates for this itself,setTextSizedid not), so changing the font size must issue one itself. -
The local shell's row in the session list had no title under it (★ 2026-09-19): the session dropdown's second line is whatever the running program set through OSC 0/2 — htop, ranger, vim and most distributions' default prompts write one, so SSH rows have it. Android's
/system/bin/sh(mksh R59) writes none, and nothing on a stock device does either, so a local row was blank. The fix is one line appended to the generated.mkshrc([TermRc.TITLE]), wrapping whatever PS1 the system/system/etc/mkshrcset: mksh re-expands PS1 on every prompt (its own default already relies on that — it contains${PWD:-?}), so${PWD}followscdwith no callback of ours involved. What a local row promises is the directory and nothing else — no program name will appear there, because no program on the device writes one. Do not "fix" that.-
★★ The escape has to be wrapped in mksh's non-printing delimiters, or line editing drifts: mksh counts the prompt's width in characters, escape sequences included, and then believes the cursor is further right than it is, so a long command line scrolls early and redraws misaligned. ksh's convention — which Android's mksh implements — is if the second character of PS1 is
\r, the first character is a delimiter, and anything between a pair of it is not counted. HencePS1=$'\001\r\001\033]0;${PWD}\a\001'"$PS1". -
Measured, not reasoned (
adb shell -t -twith a 40-columnsttyand a 3-character prompt, counting the characters echoed before the line wrapped): bare escape 27, wrapped escape 35, plain prompt with no escape at all 35. The 8-character difference is exactly the length ofESC ] 0 ; T T T BEL. The end-to-end check then ran the string extracted from the built APK's dex as an rc file on the device, so the Kotlin escaping was verified too, not just the intended bytes: the prompt emittedESC]0;/BEL, and aftercd /data/local/tmpit emittedESC]0;/data/local/tmpBEL. -
The block is appended to an existing
.mkshrconce, guarded byPrefs.termTitleRcDonerather than by searching the file for it: the rc file belongs to the user, so deleting the block has to keep it deleted. -
★ Do not type a prompt hook into a remote shell (tried and removed, same day): a remote prompt's title can be truncated (oh-my-zsh's default tab title is
%15<..<%~%<<, so Twig saw..r_cmt/backend), and the obvious answer — send the far side a PS1/precmd hook of our own — is wrong for two reasons that only show up on a real server. Sent on its own it is echoed into the session, twice, because zsh's line editor redraws it; and chainingclearafter it would take the login banner with it. Sent chained onto thecda session opens with it is invisible, but it still lands in that server's shell history, because everything Twig types into an interactive shell does. The directory comes from the titles the prompt already writes instead (TermSession.pathTitlekeeps the newest one that names a path — oh-my-zsh writes the full%n@%m:%~to OSC 2 and the truncation to OSC 1, and the emulator only keeps the last). ★★ And thecd … && clearthat had always been typed is gone too, for the same reason: an SSH channel can run a command instead of starting a bare shell, and sshd runs it through the login shell, so nothing is typed, nothing is echoed and nothing reaches the history file —SftpFileSystem.openShell(cols, rows, command)withcd '<dir>'; exec ${SHELL:-/bin/sh} -l. Three things make that line what it is:execso the user still gets their own shell and not a subshell;;rather than&&so a directory that has gone leaves them a shell withcd's message on screen instead of an ended session; and${SHELL:-/bin/sh}because$SHELLis what sshd sets from the account. Measured through a real pty before shipping —$-containsi,$0is the login shell, and the cwd is the target. The cast toSession.ShellinopenShellis not cosmetic:execis typed asSession.Command, sshj answers both with the sameSessionChannel, and window-change (SIGWINCH on rotate / keyboard) lives on theShellside. Windows servers keep the typedcd /d … && cls: cmd.exe has noexec. -
★ A privileged session sources no rc unless you give it one:
privEnv(the Shizuku pty) never set$ENV, so the shell fell back to mksh's default startup and none of this reached it — the row stayed blank while plain local rows worked. It cannot point at the app's own.mkshrceither: the helper runs as the shell uid and the app's private directory is 0700. The rc for privileged shells is therefore written by the helper into/data/local/tmp/twig(the directory it already owns forlibtwigpty.so), and because setting$ENVreplaces mksh's default startup file, that rc has to source/system/etc/mkshrcitself or the session loses its normal prompt.
-
-
Status-bar fullscreen and cutouts (2026-10-12): TerminalActivity uses the same
StatusBarFullscreenlayout policy as the file panes: no cutout padding, while navigation and keyboard insets remain applied. Keep the terminal-size freeze before focus-loss relayouts and the thaw after fullscreen is reapplied; changing window insets must not bypassTermSizeFreezeLayout. -
Launcher command identities (
RemoteCmd.target, 2026-10-19): ordinary local directories, including/, use the app uid. The command dialog's optional privileged-session checkbox defaults off and follows the selected Root or Shizuku provider. The target is stored in the private command record, and old records default to SSH. Never infer the identity again from the edited working directory or current privilege setting. Root commands usesu -cas a child of the PTY shell and check uid 0 before user code; neither denial nor a missingsumay fall back to local execution. Local command PTYs pass the executable as argv[0] before-c: Termux's JNI passes the argument array straight toexecvp. Background commands use separate processes and drain stdout/stderr concurrently, with bounded output tails. Do not run user commands through the persistent shell used for filesystem access. Native PTY and root authorization checks remain user-operated device tests.Shizuku commands retain the chosen uid (0 or 2000); a service restart with a different identity must fail before user code runs. Terminal commands pass argv through the helper's AIDL to
Pty.nativeOpen; do not enqueue the ordinary startupcdafter launching a command, since it could become input to that command rather than the shell. The helper adds argv[0] itself, unlike Termux's JNI. BumpPrivService.VERSIONwhenever the helper changes. Background commands use a fresh Shizuku process, never the persistent shell serving filesystem operations.