Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
e233f6a
Sessions that come back, own keys, pinch zoom, SSH server, web server…
claude Oct 24, 2026
0a52b5c
Native extractor: read a .tar.zst to its end, so that zstd checks its…
claude Oct 24, 2026
575bb78
Emulator test: export ssh_login, which expect runs in a new bash
claude Oct 24, 2026
35abde8
Emulator test: align cortex-emu-runner for 16 KB pages
claude Oct 24, 2026
49164be
Native extractor: decompress a .tar.zst on a thread of its own
claude Oct 24, 2026
ba1a75e
Hook: the app's files are root's in stat(), as the programs are root
claude Oct 24, 2026
6e98a24
Changelog: git's ownership check is fixed, not restored
claude Oct 24, 2026
45ecea3
Hook: accept() as accept4(), the call Android's seccomp filter is mad…
claude Oct 24, 2026
9a61e5b
Emulator test: time limits on adb, no packages on the 16 KB image
claude Oct 24, 2026
cc7e60a
Hook: sshd's privilege-separated user has root's ids
claude Oct 24, 2026
5f7e2fc
Hook: the fortified getgroups and recvfrom go through it too
claude Oct 24, 2026
2f15a34
Changelog: the emulator test's 16 KB run, ownership check and adb limits
claude Oct 24, 2026
1d4ffa7
Hook: sandbox limits that take away descriptors are not applied
claude Oct 24, 2026
c521330
Hook: closefrom() and close_range() leave the ARM translator's memory…
claude Oct 24, 2026
3993d55
Hook: know the ARM translator's memory file by its name
claude Oct 24, 2026
ce61c6e
Emulator test: record what a program sees of its executable and descr…
claude Oct 24, 2026
9054195
Static Go programs see the rootfs: Ubuntu's paths and programs for Go
claude Oct 25, 2026
f3cbede
Merge the Go support onto the branch's merged history (v1.26.38 was s…
claude Oct 25, 2026
fcb2f3b
A new look around the terminal: tabs, themed keys and drawer, seven m…
claude Oct 25, 2026
649be84
Scroll position and a way back to the end, a themed first-start dialo…
claude Oct 25, 2026
545da3c
Go programs patched in normal mode keep working in root mode; README
claude Oct 25, 2026
ce39a5b
Website and README: Go programs no longer need root mode; tabs and co…
claude Oct 25, 2026
ddd5c33
Tests: the tabs, keys, search bar, layouts, first-start dialog and sc…
claude Oct 25, 2026
721e363
Go programs: CORTEX_NO_GOSYS switch and an unpatched control in the e…
claude Oct 25, 2026
7812fe5
Settings theme: the interface's own containers and outline colors, no…
claude Oct 25, 2026
adc951a
Emulator test: a second session is started, and the scheme picker is …
claude Oct 25, 2026
ef79aa8
Scheme picker in the interface's colors; the emulator test retries op…
claude Oct 25, 2026
7b079fa
README and website: screenshots of the new interface, and the scheme …
claude Oct 25, 2026
56e138d
Package compatibility run: apt-get is not started by timeout in the b…
claude Oct 25, 2026
2316c10
Version 1.26.39: Go programs see Ubuntu, session tabs, seven more col…
claude Oct 25, 2026
cd303dd
Hook: Rust programs no longer crash in statx; reading /proc/self/fd o…
claude Oct 25, 2026
7d467e2
Merge the branch's merged history (PR #28 was squashed into main)
claude Oct 25, 2026
c39b5f5
Hook: shadow database and SELinux for Ubuntu's programs, the translat…
claude Oct 25, 2026
af04acc
Fix unclosed Kotlin comment in BaseUsers KDoc
claude Oct 25, 2026
4bc81d7
Version 1.26.40: faster program starts, sudo/su without root, Rust, r…
claude Oct 25, 2026
f98f6a1
Compat run: a MariaDB row; docs say MariaDB and redis run
claude Oct 25, 2026
958bd68
Emulator test: base users and groups, sudo
claude Oct 25, 2026
f1101fc
A color scheme chosen later reaches the cells of the sessions (#29)
claude Oct 25, 2026
29310aa
Review fixes: scheme remap by role, slots of vfork children given bac…
claude Oct 25, 2026
a56e0f4
Merge the branch's merged history (PR #31 was squashed into main)
claude Oct 26, 2026
5fedf73
No more ~/storage link to the shared storage (1.26.41)
claude Oct 26, 2026
bcb86ef
Fix the unclosed comment in the storage-link KDoc; test that no Kotli…
claude Oct 26, 2026
0803909
Test that a ~/storage link to another place is left alone
claude Oct 26, 2026
2474682
Start the next change from main
claude Oct 26, 2026
8cc804b
New app icon, held backspace keeps deleting, copy bar in the right pl…
claude Oct 26, 2026
5c9e1af
Chinese, Japanese, Korean and emoji take two cells instead of overlap…
claude Oct 27, 2026
0d6afc9
Start the next change from main
claude Oct 27, 2026
c5829ae
Fix a half left behind after inserting over a wide character; keep th…
claude Oct 27, 2026
f46bd24
Copy: a selection that ends on the first half of an emoji takes the w…
claude Oct 27, 2026
de3b864
Wide characters: mark the wrap padding, blank halves fully, cut copy;…
claude Oct 27, 2026
c9eb9df
Start the next change from main
claude Oct 27, 2026
4ae2b29
A cleaner drawer and tabs, and a white color scheme; 1.26.44
claude Oct 27, 2026
abc4169
Test the session tabs row by row, and their screen reader labels
claude Oct 27, 2026
515d3c7
Settings: the back arrow follows the color scheme
claude Oct 27, 2026
1d22b55
e2e: scroll the settings list all the way up before choosing the whit…
claude Oct 27, 2026
73f39f1
e2e: give the terminal the focus before typing in the second session
claude Oct 27, 2026
ed8ea5e
Hook: the real path of an open file keeps the rootfs in front of it
claude Oct 27, 2026
a72c5b1
Terminal: a character of the supplementary planes that wcwidth() give…
claude Oct 27, 2026
e6cfa61
Test: compare the colors with the ones the escape codes set
claude Oct 27, 2026
662a6ea
Review: fd links only for fd/N, the root tab says root, a plain curso…
claude Oct 27, 2026
07ce921
e2e: pictures for the README (neofetch, the end of the settings)
claude Oct 27, 2026
0d655de
Merge the earlier history of this branch (squash-merged as #37)
claude Oct 27, 2026
6157483
README and website: the session cards have no directory in their pict…
claude Oct 27, 2026
9b39eb7
e2e: neofetch for the README with the small logo and lines that fit
claude Oct 27, 2026
9d4fb47
tests/e2e: a script that makes the README's pictures from an emulator…
claude Oct 27, 2026
d085168
e2e: neofetch for the README without the emulator's model and archite…
claude Oct 27, 2026
dc33763
README and website pictures from the 1.26.44 emulator run
claude Oct 27, 2026
a342bd2
e2e: the neofetch picture only after a successful install and a finis…
claude Oct 27, 2026
bf700b5
The keyboard's Select all, Copy, Cut and Paste work in the terminal, …
claude Oct 28, 2026
94fc8d4
Merge the earlier history of this branch (squash-merged as #37 and #38)
claude Oct 28, 2026
c09f130
Review: Cut also does what delete does, the selection is the session'…
claude Oct 28, 2026
b3c825a
Review: the selected text is read under the emulator's lock
claude Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ What changed in each version of Cortex Terminal, newest first. The release workf
of a GitHub release from the section of its version here. Older versions are described on the
[GitHub releases](https://github.com/ssfuisu/cortex/releases) page.

## 1.26.45 (2026-10-28)

### Fixed

- **The Select all, Copy, Cut and Paste buttons of a keyboard did nothing (#32).** A keyboard asks the
app for them as the system's editing actions (or sends the Copy, Cut and Paste keys), and the
terminal answered none of them: only its own bar, shown after a long press, worked. They now select
all the text of the terminal, copy the selection, and paste, and the terminal tells a keyboard that
copies by itself what is selected.
- **Delete over a selection** only took the selection away and deleted a single character (#32). With
the cursor in the selection (all of it selected, say), delete now clears the command line you are
typing. The text above it belongs to the programs and stays. Not in a full-screen program (vim,
nano), where the keys that clear the line mean other things.

## 1.26.44 (2026-10-27)

### Added
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,10 +334,6 @@ Have feedback, questions, or ideas for new features? We'd love to hear from you:
- **GitHub Issues**: Open a bug report or feature request on [GitHub Issues](https://github.com/ssfuisu/cortex/issues).
- **Telegram**: Chat directly with the developer on Telegram: [@ratzgn](https://t.me/ratzgn).

## Thanks

The app icon is the design of [hasanelfalakiy](https://github.com/hasanelfalakiy), who suggested it in [#30](https://github.com/ssfuisu/cortex/issues/30). Thank you!

---

## License
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ android {
// from the app's writable data directory (filesDir). Android 10+ (targetSdk >= 29)
// enforces SELinux W^X restrictions preventing execution from writable app data storage.
targetSdk = 28
versionCode = 12644
versionName = "1.26.44"
versionCode = 12645
versionName = "1.26.45"
// UbuntuOnDeviceTest (app/src/androidTest), on Firebase Test Lab
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import java.util.zip.GZIPInputStream

object BootstrapManager {
private const val TAG = "BootstrapManager"
const val CURRENT_BOOTSTRAP_VERSION = 12644
const val CURRENT_BOOTSTRAP_VERSION = 12645

fun initializeFileSystem(context: Context) {
val root = Environment.getCortexRoot(context)
Expand Down
136 changes: 121 additions & 15 deletions app/src/main/java/org/cortex/terminal/view/TerminalView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class TerminalView @JvmOverloads constructor(
value?.onRedraw = { postInvalidate() }
value?.emulator?.onScreenUpdate = onEmulatorScreenUpdate
if (previous !== value) {
// The selection is the old session's: delete over it would act on the new one's line
clearSelection()
previous?.setFocused(false)
value?.setFocused(hasWindowFocus())
cursorBlinkOn = true
Expand Down Expand Up @@ -1356,20 +1358,30 @@ class TerminalView @JvmOverloads constructor(
}

fun copySelectionToClipboard(): Boolean {
val norm = getNormalizedSelection()
val text = session?.emulator?.buffer?.getSelectedText(norm[0], norm[1], norm[2], norm[3]) ?: ""
if (text.isNotEmpty()) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Cortex", text))
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
clearSelection()
return true
}
// Without a selection there is nothing to copy (the numbers are those of the last one)
if (!isSelecting) return false
val text = selectedTerminalText()
if (text != null) putOnClipboard(text)
clearSelection()
return false
return text != null
}

fun pasteFromClipboard() {
/** Cut: copied, then what delete does to the selection (the command line, when the cursor is in it). */
private fun cutSelectionToClipboard(): Boolean {
val text = selectedTerminalText()
if (text != null) putOnClipboard(text)
deleteSelection()
return text != null
}

private fun putOnClipboard(text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Cortex", text))
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}

/** [plainTextOnly]: the clip's text, not the image it may also hold (the keyboard's Paste as plain text). */
fun pasteFromClipboard(plainTextOnly: Boolean = false) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
val clip = clipboard.primaryClip ?: return
if (clip.itemCount == 0) return
Expand All @@ -1386,7 +1398,8 @@ class TerminalView @JvmOverloads constructor(
p.endsWith(".webp", true) || p.endsWith(".gif", true)
} == true)

if (uri != null && hasImageMime) {
val preferText = plainTextOnly && !item.text.isNullOrEmpty()
if (!preferText && uri != null && hasImageMime) {
val savedPath = saveImageFromUri(uri, mimeType)
if (savedPath != null) {
session?.write(savedPath)
Expand Down Expand Up @@ -1503,6 +1516,7 @@ class TerminalView @JvmOverloads constructor(

fun selectAllText() {
val histSize = session?.emulator?.buffer?.history?.size ?: 0
isSelecting = true // from the keyboard's Select all as well, not only from the bar of a selection
selectStartRow = -histSize
selectStartCol = 0
selectEndRow = rows - 1
Expand All @@ -1513,6 +1527,44 @@ class TerminalView @JvmOverloads constructor(
}
}

/** The text of the selection, for a keyboard that copies it itself; null without one. */
private fun selectedTerminalText(): String? {
if (!isSelecting) return null
val norm = getNormalizedSelection()
val emulator = session?.emulator ?: return null
// Under the lock: output on the PTY thread moves the rows of the screen and the scrollback
val text = emulator.lock.withLock {
emulator.buffer.getSelectedText(norm[0], norm[1], norm[2], norm[3])
}
return text.takeIf { it.isNotEmpty() }
}

/**
* What delete does to a selection (#32). The text on the screen is the programs', so what can be
* taken away is what is being typed: with the cursor in the selection (all of it selected, say), the
* command line is cleared, with End and then the kill of the line that shells and the tty know
* (Ctrl+E, Ctrl+U). Not in a full-screen program, where those keys mean other things (in nano
* Ctrl+U pastes). The selection is gone after it either way; true when the line was cleared.
*/
private fun deleteSelection(): Boolean {
if (!isSelecting) return false
val emulator = session?.emulator
var clear = false
if (emulator != null) {
emulator.lock.withLock {
val buffer = emulator.buffer
clear = !buffer.isAlternate &&
selectionHoldsCell(getNormalizedSelection(), buffer.cursorRow, buffer.cursorCol)
Comment thread
ssfuisu marked this conversation as resolved.
Resolved
}
}
clearSelection()
if (clear) {
scrollOffset = 0
session?.write(CLEAR_LINE)
}
return clear
}

/**
* Search state can be mutated from the PTY reader thread (throttled live refresh),
* so always hand the result back to the UI thread before touching widgets.
Expand Down Expand Up @@ -1692,7 +1744,26 @@ class TerminalView @JvmOverloads constructor(

override fun getTextAfterCursor(n: Int, flags: Int): CharSequence = ""

override fun getSelectedText(flags: Int): CharSequence? = null
override fun getSelectedText(flags: Int): CharSequence? = selectedTerminalText()

/** The keyboard's own Select all, Copy, Cut and Paste buttons (the system's names for them). */
override fun performContextMenuAction(id: Int): Boolean = when (id) {
android.R.id.selectAll -> {
selectAllText()
true
}
android.R.id.copy -> copySelectionToClipboard()
android.R.id.cut -> cutSelectionToClipboard()
android.R.id.paste -> {
pasteFromClipboard()
true
}
android.R.id.pasteAsPlainText -> {
pasteFromClipboard(plainTextOnly = true)
true
}
else -> super.performContextMenuAction(id)
}

@RequiresApi(Build.VERSION_CODES.S)
override fun getSurroundingText(beforeLength: Int, afterLength: Int, flags: Int): SurroundingText {
Expand Down Expand Up @@ -1749,6 +1820,11 @@ class TerminalView @JvmOverloads constructor(
}

override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
// Nothing committed over a selection is how a keyboard deletes it
if (text.isNullOrEmpty() && composingLength == 0 && deleteSelection()) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
lineIsBack()
return true
}
clearSelection()
// What the keyboard was composing (the pinyin of a Chinese word, the kana of a Japanese
// one) is on the line already: the text it commits replaces it
Expand Down Expand Up @@ -1799,7 +1875,11 @@ class TerminalView @JvmOverloads constructor(
}

override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
clearSelection()
if (deleteSelection()) {
composingLength = 0 // the line it was composed on is gone
lineIsBack()
return true
}
if (beforeLength == 0 && afterLength == 0) {
sendKeySequence(KeyEvent.KEYCODE_DEL)
} else {
Expand All @@ -1822,7 +1902,6 @@ class TerminalView @JvmOverloads constructor(

override fun sendKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
clearSelection()
val handled = onKeyDown(event.keyCode, event)
if (event.keyCode == KeyEvent.KEYCODE_DEL || event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL) lineIsBack()
return handled
Expand Down Expand Up @@ -1934,6 +2013,22 @@ class TerminalView @JvmOverloads constructor(
}

override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
// The keys of a keyboard's clipboard buttons, and delete over a selection
when (keyCode) {
KeyEvent.KEYCODE_PASTE -> {
pasteFromClipboard()
return true
}
KeyEvent.KEYCODE_COPY -> {
copySelectionToClipboard()
return true
}
KeyEvent.KEYCODE_CUT -> {
cutSelectionToClipboard()
return true
}
KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_FORWARD_DEL -> if (deleteSelection()) return true
}
clearSelection()
scrollOffset = 0
if (sendKeySequence(keyCode)) {
Expand Down Expand Up @@ -1992,6 +2087,17 @@ private const val IME_LINE = " "
internal fun selectionBackground(dark: Boolean, background: Int, accent: Int): Int =
if (dark) Chrome.WHITE else Chrome.mix(background, accent, 0.35f)

/** What clears the command line: End, then the kill of the line (the tty's and readline's Ctrl+U). */
private const val CLEAR_LINE = "\u0005\u0015"

/**
* Whether the cell ([row], [col]) is in the selection [norm]: its first row and column, then its last
* ones, in this order (the buffer's rows: those of the scrollback are negative).
*/
internal fun selectionHoldsCell(norm: IntArray, row: Int, col: Int): Boolean =
(row > norm[0] || (row == norm[0] && col >= norm[1])) &&
(row < norm[2] || (row == norm[2] && col <= norm[3]))

/** The text sizes two fingers can set, in sp. */
internal const val MIN_PINCH_TEXT_SP = 6f
internal const val MAX_PINCH_TEXT_SP = 36f
Expand Down
67 changes: 67 additions & 0 deletions app/src/test/java/org/cortex/terminal/view/ChromeViewsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.cortex.terminal.view
import android.app.Activity
import android.content.DialogInterface
import android.os.Looper
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
Expand Down Expand Up @@ -222,4 +223,70 @@ class ChromeViewsTest {
assertEquals(" ", around.text.toString())
assertEquals(1, around.selectionStart)
}

@Test
fun theKeyboardsOwnSelectAllCopyAndPasteButtonsAreAnswered() {
// WM Keyboard's buttons did nothing (#32): they come as the system's context menu actions
val view = TerminalView(activity)
val ic = view.onCreateInputConnection(EditorInfo())
assertFalse("nothing to copy without a selection", ic.performContextMenuAction(android.R.id.copy))
assertFalse(view.isSelecting)
assertTrue(ic.performContextMenuAction(android.R.id.selectAll))
assertTrue("Select all selects", view.isSelecting)
assertTrue(ic.performContextMenuAction(android.R.id.paste))
assertTrue(ic.performContextMenuAction(android.R.id.pasteAsPlainText))
assertFalse("another action is not ours", ic.performContextMenuAction(android.R.id.startSelectingText))
// Cut is a copy and then what delete does: the selection is gone (nothing on screen to copy without a session)
view.selectAllText()
assertFalse(ic.performContextMenuAction(android.R.id.cut))
assertFalse(view.isSelecting)
}

@Test
fun theClipboardKeysCopyAndPaste() {
val view = TerminalView(activity)
view.selectAllText()
assertTrue(view.isSelecting)
assertTrue(view.onKeyDown(KeyEvent.KEYCODE_COPY, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_COPY)))
assertFalse("copy leaves the selection (nothing on screen to copy without a session)", view.isSelecting)
assertTrue(view.onKeyDown(KeyEvent.KEYCODE_PASTE, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_PASTE)))
view.selectAllText()
assertTrue(view.onKeyDown(KeyEvent.KEYCODE_CUT, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_CUT)))
assertFalse(view.isSelecting)
}

@Test
fun deleteOverASelectionTakesTheSelectionAway() {
val view = TerminalView(activity)
val ic = view.onCreateInputConnection(EditorInfo())
view.selectAllText()
assertTrue(ic.deleteSurroundingText(1, 0))
assertFalse(view.isSelecting)
view.selectAllText()
assertTrue(ic.commitText("", 1))
assertFalse(view.isSelecting)
view.selectAllText()
// (Without a session there is nothing to send the key to: what it returns is not the point)
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
assertFalse(view.isSelecting)
}

@Test
fun theCursorIsInASelectionOnItsRowsAndBetweenItsEnds() {
// From row 2, column 5 to row 4, column 3 (rows of the scrollback are negative)
val norm = intArrayOf(2, 5, 4, 3)
assertTrue(selectionHoldsCell(norm, 3, 0))
assertTrue(selectionHoldsCell(norm, 3, 79))
assertTrue(selectionHoldsCell(norm, 2, 5))
assertFalse(selectionHoldsCell(norm, 2, 4))
assertTrue(selectionHoldsCell(norm, 4, 3))
assertFalse(selectionHoldsCell(norm, 4, 4))
assertFalse(selectionHoldsCell(norm, 1, 40))
assertFalse(selectionHoldsCell(norm, 5, 0))
// All of it, as Select all does: from the first row of the scrollback to the last of the screen
assertTrue(selectionHoldsCell(intArrayOf(-300, 0, 23, 79), 12, 6))
// One cell
assertTrue(selectionHoldsCell(intArrayOf(7, 2, 7, 2), 7, 2))
assertFalse(selectionHoldsCell(intArrayOf(7, 2, 7, 2), 7, 3))
}
}
Loading