Polish session controls and server management
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Has been cancelled

This commit is contained in:
Codex
2026-07-14 16:16:42 +00:00
parent 974abed278
commit 2824fafc32
@@ -44,6 +44,7 @@ import android.widget.ImageView;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.ScrollView; import android.widget.ScrollView;
import android.widget.SeekBar;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
@@ -56,7 +57,9 @@ import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@@ -107,6 +110,10 @@ public final class MainActivity extends Activity {
private static final String PAGE_UPDATE = "Update"; private static final String PAGE_UPDATE = "Update";
private static final String PAGE_ABOUT = "About"; private static final String PAGE_ABOUT = "About";
private static final String TERMINAL_ENTER = "\r"; private static final String TERMINAL_ENTER = "\r";
private static final String SERVER_INSTALL_COMMANDS = "chmod +x tmux-ui.run\n"
+ "./tmux-ui.run install\n"
+ "./tmux-ui.run service-install\n"
+ "./tmux-ui.run service-status";
private static final String[] SERVER_PROFILES = { private static final String[] SERVER_PROFILES = {
"http://100.89.0.116:3000", "http://100.89.0.116:3000",
"http://100.89.0.2:3000", "http://100.89.0.2:3000",
@@ -116,6 +123,7 @@ public final class MainActivity extends Activity {
}; };
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final ExecutorService serverProbeExecutor = Executors.newFixedThreadPool(5);
private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Handler mainHandler = new Handler(Looper.getMainLooper());
private SharedPreferences prefs; private SharedPreferences prefs;
private UpdateManager updateManager; private UpdateManager updateManager;
@@ -154,6 +162,9 @@ public final class MainActivity extends Activity {
private String pendingImageUploadSession; private String pendingImageUploadSession;
private int lastSessionCount = -1; private int lastSessionCount = -1;
private int lastActiveSessionCount = -1; private int lastActiveSessionCount = -1;
private final Map<String, Integer> serverSessionCounts = new HashMap<>();
private final Map<String, Integer> serverActiveSessionCounts = new HashMap<>();
private final Set<String> serverCountFailures = new HashSet<>();
private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS); private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
private final StringBuilder queuedTerminalInput = new StringBuilder(); private final StringBuilder queuedTerminalInput = new StringBuilder();
private final Set<String> terminalExpandedBlocks = new HashSet<>(); private final Set<String> terminalExpandedBlocks = new HashSet<>();
@@ -167,6 +178,10 @@ public final class MainActivity extends Activity {
private int terminalViewMode = TERMINAL_VIEW_CHAT; private int terminalViewMode = TERMINAL_VIEW_CHAT;
private int terminalKeyPage; private int terminalKeyPage;
private int terminalHistoryOffset; private int terminalHistoryOffset;
private float terminalFontSizeSp = 11f;
private float terminalLineHeight = 1.05f;
private String terminalFontFamily = "monospace";
private String terminalThemeId = "dark";
private float terminalTouchStartY; private float terminalTouchStartY;
private int terminalTouchStartScrollY; private int terminalTouchStartScrollY;
private int terminalReconnectAttempt; private int terminalReconnectAttempt;
@@ -295,6 +310,7 @@ public final class MainActivity extends Activity {
1 1
)); ));
setStatus("Servers"); setStatus("Servers");
refreshServerSummaries(serverUrls);
} }
private View serverProfileCard(String url) { private View serverProfileCard(String url) {
@@ -351,15 +367,21 @@ public final class MainActivity extends Activity {
stateBlock.setOrientation(LinearLayout.VERTICAL); stateBlock.setOrientation(LinearLayout.VERTICAL);
stateBlock.setGravity(Gravity.END); stateBlock.setGravity(Gravity.END);
TextView active = new TextView(this); TextView active = new TextView(this);
active.setText(selected && lastActiveSessionCount >= 0 ? String.valueOf(lastActiveSessionCount) : "0"); Integer activeCount = serverActiveSessionCounts.get(url);
Integer totalCount = serverSessionCounts.get(url);
active.setText(activeCount == null ? "" : String.valueOf(activeCount));
active.setTextColor(COLOR_ACCENT); active.setTextColor(COLOR_ACCENT);
active.setTextSize(13); active.setTextSize(13);
active.setTypeface(Typeface.MONOSPACE); active.setTypeface(Typeface.MONOSPACE);
active.setTag("server-active:" + url);
TextView total = new TextView(this); TextView total = new TextView(this);
total.setText("of " + (selected && lastSessionCount >= 0 ? lastSessionCount : 0)); total.setText(totalCount == null
? (serverCountFailures.contains(url) ? "offline" : "sessions")
: "of " + totalCount);
total.setTextColor(COLOR_TEXT_DIM); total.setTextColor(COLOR_TEXT_DIM);
total.setTextSize(9); total.setTextSize(9);
total.setTypeface(Typeface.MONOSPACE); total.setTypeface(Typeface.MONOSPACE);
total.setTag("server-total:" + url);
stateBlock.addView(active); stateBlock.addView(active);
stateBlock.addView(total); stateBlock.addView(total);
card.addView(stateBlock); card.addView(stateBlock);
@@ -378,6 +400,51 @@ public final class MainActivity extends Activity {
return card; return card;
} }
private void refreshServerSummaries(List<String> urls) {
for (String url : urls) {
serverProbeExecutor.execute(() -> {
try {
List<SessionSummary> sessions = new SessionApiClient(url).getSessions();
int activeCount = 0;
for (SessionSummary session : sessions) {
String status = defaultValue(session.status, "").toLowerCase(java.util.Locale.ROOT);
if (status.contains("attach") || status.contains("active")) {
activeCount++;
}
}
int finalActiveCount = activeCount;
runOnUiThread(() -> updateServerSummary(url, sessions.size(), finalActiveCount, false));
} catch (Exception error) {
runOnUiThread(() -> updateServerSummary(url, 0, 0, true));
}
});
}
}
private void updateServerSummary(String url, int totalCount, int activeCount, boolean failed) {
if (failed) {
serverSessionCounts.remove(url);
serverActiveSessionCounts.remove(url);
serverCountFailures.add(url);
} else {
serverSessionCounts.put(url, totalCount);
serverActiveSessionCounts.put(url, activeCount);
serverCountFailures.remove(url);
}
if (!PAGE_SERVERS.equals(activeMainPage)) {
return;
}
TextView active = root.findViewWithTag("server-active:" + url);
TextView total = root.findViewWithTag("server-total:" + url);
if (active != null) {
active.setText(failed ? "" : String.valueOf(activeCount));
active.setTextColor(failed ? COLOR_TEXT_DIM : COLOR_ACCENT);
}
if (total != null) {
total.setText(failed ? "offline" : "of " + totalCount);
}
}
private void renderSessionScreen() { private void renderSessionScreen() {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = null; activeSessionName = null;
@@ -510,6 +577,9 @@ public final class MainActivity extends Activity {
LinearLayout utilities = new LinearLayout(this); LinearLayout utilities = new LinearLayout(this);
utilities.setOrientation(LinearLayout.HORIZONTAL); utilities.setOrientation(LinearLayout.HORIZONTAL);
utilities.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); utilities.setGravity(Gravity.END | Gravity.CENTER_VERTICAL);
Button display = terminalToolButton("", view -> promptSessionSettings(""));
display.setContentDescription("Session display settings");
utilities.addView(display);
Button refresh = compactButton("↻ Refresh", view -> refreshSessions()); Button refresh = compactButton("↻ Refresh", view -> refreshSessions());
refresh.setContentDescription("Refresh sessions"); refresh.setContentDescription("Refresh sessions");
utilities.addView(refresh); utilities.addView(refresh);
@@ -846,24 +916,26 @@ public final class MainActivity extends Activity {
ScrollView scroll = new ScrollView(this); ScrollView scroll = new ScrollView(this);
LinearLayout content = pageContent(); LinearLayout content = pageContent();
content.addView(infoBlock( content.addView(createPageHeader(
"Installed", "release channel",
appIdentityText() + "\n" "Updates",
+ "Update source: " + updateSourceHost() + "\n" "Check",
+ prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL) view -> updateManager.checkSelected(true)
)); ));
content.addView(sectionTitle("Update")); content.addView(updateSummaryPanel());
content.addView(sectionTitle("Release source"));
content.addView(actionPanel( content.addView(actionPanel(
actionButton("Auto check", view -> updateManager.check(true)),
actionButton("Gitea", view -> updateManager.checkGitea(true)), actionButton("Gitea", view -> updateManager.checkGitea(true)),
actionButton("GitHub", view -> updateManager.checkGithub(true)), actionButton("GitHub", view -> updateManager.checkGithub(true)),
actionButton("Preview", view -> updateManager.checkPreview(true)), actionButton("Preview", view -> updateManager.checkPreview(true)),
actionButton("Selected", view -> updateManager.checkSelected(true)), actionButton("Selected", view -> updateManager.checkSelected(true)),
actionButton("Source", view -> showUpdateSourcePicker()), actionButton("Choose source", view -> showUpdateSourcePicker()),
actionButton("APK", view -> updateManager.openApkDownload()) actionButton("Download APK", view -> updateManager.openApkDownload())
)); ));
content.addView(sectionTitle("Permissions")); content.addView(sectionTitle("Server installation"));
content.addView(infoBlock("Android", permissionSummary())); content.addView(serverInstallPanel());
content.addView(sectionTitle("Android access"));
content.addView(infoBlock("Permissions", permissionSummary()));
content.addView(actionPanel( content.addView(actionPanel(
actionButton("Install permission", view -> openInstallPermissionSettings()), actionButton("Install permission", view -> openInstallPermissionSettings()),
actionButton("Notifications", view -> requestNotificationPermission()), actionButton("Notifications", view -> requestNotificationPermission()),
@@ -883,6 +955,91 @@ public final class MainActivity extends Activity {
setStatus("Update and permissions"); setStatus("Update and permissions");
} }
private View updateSummaryPanel() {
LinearLayout panel = new LinearLayout(this);
panel.setOrientation(LinearLayout.HORIZONTAL);
panel.setGravity(Gravity.CENTER_VERTICAL);
panel.setPadding(dp(14), dp(12), dp(12), dp(12));
panel.setBackground(cardBackground());
TextView icon = new TextView(this);
icon.setText("");
icon.setTextColor(COLOR_ACCENT);
icon.setTextSize(24);
icon.setGravity(Gravity.CENTER);
icon.setBackground(rounded(COLOR_ACCENT_DARK, 8, Color.TRANSPARENT, 0));
panel.addView(icon, new LinearLayout.LayoutParams(dp(44), dp(44)));
LinearLayout details = new LinearLayout(this);
details.setOrientation(LinearLayout.VERTICAL);
details.setPadding(dp(12), 0, dp(8), 0);
TextView version = new TextView(this);
version.setText("v" + BuildConfig.VERSION_NAME + " · build " + BuildConfig.VERSION_CODE);
version.setTextColor(COLOR_TEXT);
version.setTextSize(14);
version.setTypeface(Typeface.MONOSPACE, Typeface.BOLD);
TextView source = bodyText(updateSourceHost());
source.setTextSize(10);
source.setSingleLine(true);
source.setEllipsize(TextUtils.TruncateAt.MIDDLE);
details.addView(version, matchWrap());
details.addView(source, matchWrap());
panel.addView(details, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
Button release = terminalToolButton("", view -> updateManager.openReleasePage());
release.setContentDescription("Open release page");
panel.addView(release);
LinearLayout.LayoutParams params = matchWrap();
params.bottomMargin = dp(8);
panel.setLayoutParams(params);
return panel;
}
private View serverInstallPanel() {
LinearLayout panel = new LinearLayout(this);
panel.setOrientation(LinearLayout.VERTICAL);
panel.setPadding(dp(12), dp(11), dp(12), dp(12));
panel.setBackground(panelBackground());
TextView title = new TextView(this);
title.setText("tmux-ui server");
title.setTextColor(COLOR_TEXT);
title.setTextSize(15);
title.setTypeface(Typeface.DEFAULT_BOLD);
panel.addView(title, matchWrap());
TextView requirements = bodyText(
"Place tmux-ui.run on the target host\nNode.js 20+ · npm · tmux · Tailscale"
);
requirements.setTextSize(10);
requirements.setPadding(0, dp(3), 0, dp(9));
panel.addView(requirements, matchWrap());
TextView command = new TextView(this);
command.setText(SERVER_INSTALL_COMMANDS);
command.setTextColor(COLOR_TEXT);
command.setTextSize(11);
command.setTypeface(Typeface.MONOSPACE);
command.setTextIsSelectable(true);
command.setPadding(dp(10), dp(9), dp(10), dp(9));
command.setBackground(rounded(COLOR_FIELD, 7, COLOR_BORDER_SOFT, 1));
panel.addView(command, matchWrap());
LinearLayout actions = new LinearLayout(this);
actions.setOrientation(LinearLayout.HORIZONTAL);
Button copy = compactButton("Copy commands", view -> copyText("Server install", SERVER_INSTALL_COMMANDS));
actions.addView(copy, new LinearLayout.LayoutParams(0, dp(40), 1));
Button add = compactButton("Add server", view -> promptCustomServer());
LinearLayout.LayoutParams addParams = new LinearLayout.LayoutParams(0, dp(40), 1);
addParams.leftMargin = dp(7);
actions.addView(add, addParams);
panel.addView(actions, spacedMatchWrap(10, 0));
LinearLayout.LayoutParams params = matchWrap();
params.bottomMargin = dp(8);
panel.setLayoutParams(params);
return panel;
}
private void renderAboutScreen() { private void renderAboutScreen() {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = null; activeSessionName = null;
@@ -1692,6 +1849,7 @@ public final class MainActivity extends Activity {
private void openTerminal(String sessionName) { private void openTerminal(String sessionName) {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = sessionName; activeSessionName = sessionName;
loadTerminalDisplaySettings(sessionName);
terminalViewMode = TERMINAL_VIEW_CHAT; terminalViewMode = TERMINAL_VIEW_CHAT;
projectList = null; projectList = null;
sessionGroupList = null; sessionGroupList = null;
@@ -1727,18 +1885,18 @@ public final class MainActivity extends Activity {
terminalScroll = new ScrollView(this); terminalScroll = new ScrollView(this);
terminalScroll.setFillViewport(true); terminalScroll.setFillViewport(true);
terminalScroll.setBackgroundColor(COLOR_TERMINAL_BG); terminalScroll.setBackgroundColor(terminalBackgroundColor());
terminalText = new TextView(this); terminalText = new TextView(this);
terminalText.setTextColor(COLOR_TEXT); terminalText.setTextColor(COLOR_TEXT);
terminalText.setTextSize(11); terminalText.setTextSize(terminalFontSizeSp);
terminalText.setTypeface(Typeface.MONOSPACE); terminalText.setTypeface(terminalTypeface());
terminalText.setIncludeFontPadding(false); terminalText.setIncludeFontPadding(false);
terminalText.setHorizontallyScrolling(true); terminalText.setHorizontallyScrolling(true);
terminalText.setLineSpacing(0, 1.05f); terminalText.setLineSpacing(0, terminalLineHeight);
terminalText.setGravity(Gravity.BOTTOM | Gravity.START); terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalText.setTextIsSelectable(terminalSelectionEnabled); terminalText.setTextIsSelectable(terminalSelectionEnabled);
terminalText.setPadding(dp(2), dp(8), dp(2), 0); terminalText.setPadding(dp(2), dp(8), dp(2), 0);
terminalText.setBackgroundColor(COLOR_TERMINAL_BG); terminalText.setBackgroundColor(terminalBackgroundColor());
terminalChatList = new LinearLayout(this); terminalChatList = new LinearLayout(this);
terminalChatList.setOrientation(LinearLayout.VERTICAL); terminalChatList.setOrientation(LinearLayout.VERTICAL);
terminalChatList.setPadding(0, dp(12), 0, 0); terminalChatList.setPadding(0, dp(12), 0, 0);
@@ -2067,8 +2225,8 @@ public final class MainActivity extends Activity {
TextView content = conversationContent(message, false); TextView content = conversationContent(message, false);
content.setText((user ? " " : "") + defaultValue(message.content, "(empty)")); content.setText((user ? " " : "") + defaultValue(message.content, "(empty)"));
content.setTextColor(user ? COLOR_ACCENT : COLOR_TEXT); content.setTextColor(user ? COLOR_ACCENT : COLOR_TEXT);
content.setTextSize(12); content.setTextSize(terminalFontSizeSp);
content.setTypeface(Typeface.MONOSPACE, user ? Typeface.BOLD : Typeface.NORMAL); content.setTypeface(terminalTypeface(), user ? Typeface.BOLD : Typeface.NORMAL);
content.setPadding(0, 0, 0, 0); content.setPadding(0, 0, 0, 0);
block.addView(content, matchWrap()); block.addView(content, matchWrap());
@@ -2143,8 +2301,8 @@ public final class MainActivity extends Activity {
TextView content = new TextView(this); TextView content = new TextView(this);
content.setText(message.content.isEmpty() ? "(empty)" : message.content); content.setText(message.content.isEmpty() ? "(empty)" : message.content);
content.setTextColor(COLOR_TEXT); content.setTextColor(COLOR_TEXT);
content.setTextSize(tool ? 11 : 14); content.setTextSize(terminalFontSizeSp);
content.setTypeface(tool ? Typeface.MONOSPACE : Typeface.DEFAULT); content.setTypeface(terminalTypeface());
content.setTextIsSelectable(true); content.setTextIsSelectable(true);
content.setLineSpacing(dp(2), 1f); content.setLineSpacing(dp(2), 1f);
content.setPadding(0, dp(6), 0, 0); content.setPadding(0, dp(6), 0, 0);
@@ -2193,13 +2351,13 @@ public final class MainActivity extends Activity {
terminalLiveOutputText = new TextView(this); terminalLiveOutputText = new TextView(this);
terminalLiveOutputText.setTextColor(COLOR_TEXT_MUTED); terminalLiveOutputText.setTextColor(COLOR_TEXT_MUTED);
terminalLiveOutputText.setTextSize(11); terminalLiveOutputText.setTextSize(terminalFontSizeSp);
terminalLiveOutputText.setTypeface(Typeface.MONOSPACE); terminalLiveOutputText.setTypeface(terminalTypeface());
terminalLiveOutputText.setIncludeFontPadding(false); terminalLiveOutputText.setIncludeFontPadding(false);
terminalLiveOutputText.setHorizontallyScrolling(false); terminalLiveOutputText.setHorizontallyScrolling(false);
terminalLiveOutputText.setLineSpacing(0, 1.05f); terminalLiveOutputText.setLineSpacing(0, terminalLineHeight);
terminalLiveOutputText.setGravity(Gravity.BOTTOM | Gravity.START); terminalLiveOutputText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalLiveOutputText.setBackgroundColor(COLOR_TERMINAL_BG); terminalLiveOutputText.setBackgroundColor(terminalBackgroundColor());
terminalLiveOutputText.setPadding(0, dp(5), 0, 0); terminalLiveOutputText.setPadding(0, dp(5), 0, 0);
terminalLiveOutputText.setMovementMethod(LinkMovementMethod.getInstance()); terminalLiveOutputText.setMovementMethod(LinkMovementMethod.getInstance());
terminalLiveOutputText.setHighlightColor(Color.TRANSPARENT); terminalLiveOutputText.setHighlightColor(Color.TRANSPARENT);
@@ -2392,152 +2550,71 @@ public final class MainActivity extends Activity {
} }
private void showSessionActions(String sessionName) { private void showSessionActions(String sessionName) {
String[] items = { Dialog dialog = new Dialog(this);
"Status", LinearLayout sheet = bottomSheetContent(dialog, sessionName, "Session actions");
"Rename", addSheetActionSection(dialog, sheet, "OPEN & MANAGE",
"Send command", new SheetAction("_", "Open", () -> openTerminal(sessionName)),
"Split horizontal", new SheetAction("", "Status", () -> showRaw("Session status", () -> api.sessionStatus(sessionName))),
"Split vertical", new SheetAction("", "Rename", () -> promptRenameSession(sessionName)),
"Select pane", new SheetAction("$", "Command", () -> promptSendCommand(sessionName)),
"Kill pane", new SheetAction("Aa", "Display", () -> promptSessionSettings(sessionName)),
"Pin session", new SheetAction("", "Project", () -> promptAddKanbanSession(sessionName))
"Unpin session", );
"Mute session", addSheetActionSection(dialog, sheet, "PANES",
"Unmute session", new SheetAction("", "Split H", () -> runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal"))),
"Session settings", new SheetAction("", "Split V", () -> runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"))),
"Add to kanban project", new SheetAction("", "Select", () -> promptPaneId("Select pane", paneId -> api.selectPane(sessionName, paneId))),
"Post hook event" new SheetAction("×", "Kill pane", () -> promptPaneId("Kill pane", paneId -> api.killPane(sessionName, paneId)), true)
}; );
new AlertDialog.Builder(this) addSheetActionSection(dialog, sheet, "PREFERENCES",
.setTitle(sessionName) new SheetAction("", "Pin", () -> runApiAction("Pin session", () -> api.setPinned(sessionName, true))),
.setItems(items, (dialog, which) -> { new SheetAction("", "Unpin", () -> runApiAction("Unpin session", () -> api.setPinned(sessionName, false))),
switch (which) { new SheetAction("", "Mute", () -> runApiAction("Mute session", () -> api.setMuted(sessionName, true))),
case 0: new SheetAction("", "Unmute", () -> runApiAction("Unmute session", () -> api.setMuted(sessionName, false))),
showRaw("Session status", () -> api.sessionStatus(sessionName)); new SheetAction("", "Hook", () -> promptPostHookEvent(sessionName))
break; );
case 1: showBottomSheet(dialog, sheet, null);
promptRenameSession(sessionName);
break;
case 2:
promptSendCommand(sessionName);
break;
case 3:
runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal"));
break;
case 4:
runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"));
break;
case 5:
promptPaneId("Select pane", paneId -> api.selectPane(sessionName, paneId));
break;
case 6:
promptPaneId("Kill pane", paneId -> api.killPane(sessionName, paneId));
break;
case 7:
runApiAction("Pin session", () -> api.setPinned(sessionName, true));
break;
case 8:
runApiAction("Unpin session", () -> api.setPinned(sessionName, false));
break;
case 9:
runApiAction("Mute session", () -> api.setMuted(sessionName, true));
break;
case 10:
runApiAction("Unmute session", () -> api.setMuted(sessionName, false));
break;
case 11:
promptSessionSettings(sessionName);
break;
case 12:
promptAddKanbanSession(sessionName);
break;
case 13:
promptPostHookEvent(sessionName);
break;
default:
break;
}
})
.show();
} }
private void showTerminalActions(String sessionName) { private void showTerminalActions(String sessionName) {
String[] items = { Dialog dialog = new Dialog(this);
terminalViewMode == TERMINAL_VIEW_CHAT ? "Open raw terminal" : "Open content view", LinearLayout sheet = bottomSheetContent(dialog, sessionName, "Terminal controls");
"Reconnect", addSheetActionSection(dialog, sheet, "VIEW",
"Clear local view and tmux history", new SheetAction("", terminalViewMode == TERMINAL_VIEW_CHAT ? "Raw" : "Content", () ->
"Split horizontal", showTerminalView(terminalViewMode == TERMINAL_VIEW_CHAT
"Split vertical", ? TERMINAL_VIEW_FULL : TERMINAL_VIEW_CHAT, true)),
"Zoom active pane", new SheetAction("", "Reconnect", () -> connectTerminal(sessionName)),
"Page up", new SheetAction("", "Status", () -> showRaw("Session status", () -> api.sessionStatus(sessionName))),
"Page down", new SheetAction("Aa", "Display", () -> promptSessionSettings(sessionName)),
"Session status", new SheetAction("", "Older", () -> scrollTerminalHistory(-terminalRows)),
"Send command", new SheetAction("", "Newer", () -> scrollTerminalHistory(terminalRows))
"Tmux prefix", );
"Detach tmux client", addSheetActionSection(dialog, sheet, "LAYOUT",
"New tmux window", new SheetAction("", "Split H", () -> runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal"))),
"Next tmux window", new SheetAction("", "Split V", () -> runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"))),
"Previous tmux window" new SheetAction("", "Zoom", () -> sendTerminalInput("\u0002z")),
}; new SheetAction("", "Clear", this::clearTerminalHistory, true)
new AlertDialog.Builder(this) );
.setTitle(sessionName) addSheetActionSection(dialog, sheet, "TMUX",
.setItems(items, (dialog, which) -> { new SheetAction("C-b", "Prefix", () -> sendTerminalInput("\u0002")),
switch (which) { new SheetAction("", "Detach", () -> sendTerminalInput("\u0002d")),
case 0: new SheetAction("", "New win", () -> sendTerminalInput("\u0002c")),
showTerminalView(terminalViewMode == TERMINAL_VIEW_CHAT new SheetAction("", "Next win", () -> sendTerminalInput("\u0002n")),
? TERMINAL_VIEW_FULL : TERMINAL_VIEW_CHAT, true); new SheetAction("", "Prev win", () -> sendTerminalInput("\u0002p")),
break; new SheetAction("$", "Command", () -> promptSendCommand(sessionName))
case 1: );
connectTerminal(sessionName); showBottomSheet(dialog, sheet, null);
break; }
case 2:
terminalScreen.clear(); private void clearTerminalHistory() {
terminalText.setText(""); terminalScreen.clear();
if (terminalSocket != null) { if (terminalText != null) {
terminalSocket.clearHistory(); terminalText.setText("");
} }
break; if (terminalSocket != null) {
case 3: terminalSocket.clearHistory();
runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal")); }
break; setStatus("Terminal history cleared");
case 4:
runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"));
break;
case 5:
sendTerminalInput("\u0002z");
break;
case 6:
scrollTerminalHistory(-terminalRows);
break;
case 7:
scrollTerminalHistory(terminalRows);
break;
case 8:
showRaw("Session status", () -> api.sessionStatus(sessionName));
break;
case 9:
promptSendCommand(sessionName);
break;
case 10:
sendTerminalInput("\u0002");
break;
case 11:
sendTerminalInput("\u0002d");
break;
case 12:
sendTerminalInput("\u0002c");
break;
case 13:
sendTerminalInput("\u0002n");
break;
case 14:
sendTerminalInput("\u0002p");
break;
default:
break;
}
})
.show();
} }
private LinearLayout createComposerBar() { private LinearLayout createComposerBar() {
@@ -2809,25 +2886,249 @@ public final class MainActivity extends Activity {
} }
private void promptSessionSettings(String sessionName) { private void promptSessionSettings(String sessionName) {
LinearLayout form = formRoot(); String key = sessionName.isEmpty() ? "default" : sessionName;
EditText fontSize = formField(form, "Font size", "14"); float currentSize = prefs.getFloat("terminal_font_size:" + key,
EditText fontFamily = formField(form, "Font family", "monospace"); prefs.getFloat("terminal_font_size:default", 11f));
EditText lineHeight = formField(form, "Line height", "1.25"); float currentLineHeight = prefs.getFloat("terminal_line_height:" + key,
EditText themeId = formField(form, "Theme ID", "dark"); prefs.getFloat("terminal_line_height:default", 1.05f));
new AlertDialog.Builder(this) String currentFamily = prefs.getString("terminal_font_family:" + key,
.setTitle("Session settings") prefs.getString("terminal_font_family:default", "monospace"));
.setView(form) String currentTheme = prefs.getString("terminal_theme:" + key,
.setNegativeButton("Cancel", null) prefs.getString("terminal_theme:default", "dark"));
.setPositiveButton("Save", (dialog, which) -> runApiAction("Session settings", () ->
Dialog dialog = new Dialog(this);
LinearLayout sheet = bottomSheetContent(
dialog,
sessionName.isEmpty() ? "Session display" : sessionName,
sessionName.isEmpty() ? "Default terminal typography" : "Terminal typography for this session"
);
TextView sizeValue = settingValue("" + Math.round(currentSize) + " sp");
sheet.addView(settingHeader("FONT SIZE", sizeValue), matchWrap());
SeekBar size = new SeekBar(this);
size.setMin(9);
size.setMax(20);
size.setProgress(Math.round(currentSize));
size.setOnSeekBarChangeListener(seekListener(value -> sizeValue.setText(value + " sp")));
sheet.addView(size, matchWrap());
TextView familyLabel = formLabel("FONT");
familyLabel.setPadding(0, dp(10), 0, dp(6));
sheet.addView(familyLabel, matchWrap());
String[] selectedFamily = {currentFamily};
LinearLayout familyRow = new LinearLayout(this);
familyRow.setOrientation(LinearLayout.HORIZONTAL);
Button mono = settingChoice("Mono", "monospace", selectedFamily);
Button sans = settingChoice("Sans", "sans", selectedFamily);
Button serif = settingChoice("Serif", "serif", selectedFamily);
Button[] familyButtons = {mono, sans, serif};
String[] familyValues = {"monospace", "sans", "serif"};
for (int index = 0; index < familyButtons.length; index++) {
Button button = familyButtons[index];
String value = familyValues[index];
button.setOnClickListener(view -> {
selectedFamily[0] = value;
for (int item = 0; item < familyButtons.length; item++) {
styleSettingChoice(familyButtons[item], familyValues[item].equals(selectedFamily[0]));
}
});
styleSettingChoice(button, value.equals(selectedFamily[0]));
LinearLayout.LayoutParams choiceParams = new LinearLayout.LayoutParams(0, dp(40), 1);
if (index > 0) {
choiceParams.leftMargin = dp(6);
}
familyRow.addView(button, choiceParams);
}
sheet.addView(familyRow, matchWrap());
TextView themeLabel = formLabel("THEME");
themeLabel.setPadding(0, dp(10), 0, dp(6));
sheet.addView(themeLabel, matchWrap());
String[] selectedTheme = {currentTheme};
LinearLayout themeRow = new LinearLayout(this);
themeRow.setOrientation(LinearLayout.HORIZONTAL);
Button dark = settingChoice("Dark", "dark", selectedTheme);
Button oled = settingChoice("OLED", "oled", selectedTheme);
Button[] themeButtons = {dark, oled};
String[] themeValues = {"dark", "oled"};
for (int index = 0; index < themeButtons.length; index++) {
Button button = themeButtons[index];
String value = themeValues[index];
button.setOnClickListener(view -> {
selectedTheme[0] = value;
for (int item = 0; item < themeButtons.length; item++) {
styleSettingChoice(themeButtons[item], themeValues[item].equals(selectedTheme[0]));
}
});
styleSettingChoice(button, value.equals(selectedTheme[0]));
LinearLayout.LayoutParams choiceParams = new LinearLayout.LayoutParams(0, dp(40), 1);
if (index > 0) {
choiceParams.leftMargin = dp(6);
}
themeRow.addView(button, choiceParams);
}
sheet.addView(themeRow, matchWrap());
TextView lineValue = settingValue(Math.round(currentLineHeight * 100f) + "%");
sheet.addView(settingHeader("LINE HEIGHT", lineValue), matchWrap());
SeekBar lineHeight = new SeekBar(this);
lineHeight.setMin(90);
lineHeight.setMax(160);
lineHeight.setProgress(Math.round(currentLineHeight * 100f));
lineHeight.setOnSeekBarChangeListener(seekListener(value -> lineValue.setText(value + "%")));
sheet.addView(lineHeight, matchWrap());
LinearLayout actions = new LinearLayout(this);
actions.setOrientation(LinearLayout.HORIZONTAL);
Button reset = compactButton("Reset", view -> {
prefs.edit()
.remove("terminal_font_size:" + key)
.remove("terminal_font_family:" + key)
.remove("terminal_line_height:" + key)
.remove("terminal_theme:" + key)
.apply();
dialog.dismiss();
if (sessionName.equals(activeSessionName) || sessionName.isEmpty()) {
loadTerminalDisplaySettings(defaultValue(activeSessionName, ""));
applyTerminalDisplaySettings();
}
});
actions.addView(reset, new LinearLayout.LayoutParams(0, dp(44), 1));
Button save = primaryButton("Save", view -> {
float selectedSize = size.getProgress();
float selectedHeight = lineHeight.getProgress() / 100f;
prefs.edit()
.putFloat("terminal_font_size:" + key, selectedSize)
.putString("terminal_font_family:" + key, selectedFamily[0])
.putFloat("terminal_line_height:" + key, selectedHeight)
.putString("terminal_theme:" + key, selectedTheme[0])
.apply();
dialog.dismiss();
if (sessionName.equals(activeSessionName) || sessionName.isEmpty()) {
loadTerminalDisplaySettings(defaultValue(activeSessionName, ""));
applyTerminalDisplaySettings();
}
if (!sessionName.isEmpty()) {
executor.execute(() -> {
try {
api.updateSessionSettings( api.updateSessionSettings(
sessionName, sessionName,
Integer.parseInt(defaultValue(fontSize.getText().toString().trim(), "14")), Math.round(selectedSize),
defaultValue(fontFamily.getText().toString().trim(), "monospace"), selectedFamily[0],
Double.parseDouble(defaultValue(lineHeight.getText().toString().trim(), "1.25")), selectedHeight,
defaultValue(themeId.getText().toString().trim(), "dark") selectedTheme[0]
) );
)) } catch (Exception error) {
.show(); runOnUiThread(() -> showMessage("Settings sync failed: " + error.getMessage()));
}
});
}
showMessage("Session display saved");
});
LinearLayout.LayoutParams saveParams = new LinearLayout.LayoutParams(0, dp(44), 1);
saveParams.leftMargin = dp(8);
actions.addView(save, saveParams);
sheet.addView(actions, spacedMatchWrap(16, 0));
showBottomSheet(dialog, sheet, null);
}
private void loadTerminalDisplaySettings(String sessionName) {
String key = sessionName == null || sessionName.isEmpty() ? "default" : sessionName;
terminalFontSizeSp = prefs.getFloat("terminal_font_size:" + key,
prefs.getFloat("terminal_font_size:default", 11f));
terminalLineHeight = prefs.getFloat("terminal_line_height:" + key,
prefs.getFloat("terminal_line_height:default", 1.05f));
terminalFontFamily = prefs.getString("terminal_font_family:" + key,
prefs.getString("terminal_font_family:default", "monospace"));
terminalThemeId = prefs.getString("terminal_theme:" + key,
prefs.getString("terminal_theme:default", "dark"));
}
private void applyTerminalDisplaySettings() {
Typeface typeface = terminalTypeface();
if (terminalText != null) {
terminalText.setTextSize(terminalFontSizeSp);
terminalText.setTypeface(typeface);
terminalText.setLineSpacing(0, terminalLineHeight);
terminalText.setBackgroundColor(terminalBackgroundColor());
}
if (terminalLiveOutputText != null) {
terminalLiveOutputText.setTextSize(terminalFontSizeSp);
terminalLiveOutputText.setTypeface(typeface);
terminalLiveOutputText.setLineSpacing(0, terminalLineHeight);
terminalLiveOutputText.setBackgroundColor(terminalBackgroundColor());
}
if (terminalScroll != null) {
terminalScroll.setBackgroundColor(terminalBackgroundColor());
}
if (terminalChatList != null && terminalViewMode == TERMINAL_VIEW_CHAT) {
renderTerminalConversation();
}
resizeTerminalToViewport(true);
}
private Typeface terminalTypeface() {
if ("sans".equals(terminalFontFamily)) {
return Typeface.SANS_SERIF;
}
if ("serif".equals(terminalFontFamily)) {
return Typeface.SERIF;
}
return Typeface.MONOSPACE;
}
private int terminalBackgroundColor() {
return "oled".equals(terminalThemeId) ? Color.BLACK : COLOR_TERMINAL_BG;
}
private TextView settingValue(String text) {
TextView value = new TextView(this);
value.setText(text);
value.setTextColor(COLOR_ACCENT);
value.setTextSize(11);
value.setTypeface(Typeface.MONOSPACE, Typeface.BOLD);
value.setGravity(Gravity.END);
return value;
}
private LinearLayout settingHeader(String label, TextView value) {
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(0, dp(12), 0, 0);
row.addView(formLabel(label), new LinearLayout.LayoutParams(0, dp(28), 1));
row.addView(value, new LinearLayout.LayoutParams(dp(80), dp(28)));
return row;
}
private SeekBar.OnSeekBarChangeListener seekListener(IntValueListener listener) {
return new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
listener.onValue(progress);
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
}
};
}
private Button settingChoice(String label, String value, String[] selected) {
Button button = toolbarButton(label, view -> selected[0] = value);
button.setTextSize(11);
return button;
}
private void styleSettingChoice(Button button, boolean selected) {
button.setTextColor(selected ? Color.rgb(14, 38, 24) : COLOR_TEXT_MUTED);
button.setBackground(selected
? rounded(COLOR_ACCENT, 7, COLOR_ACCENT, 1)
: rounded(COLOR_CARD_ALT, 7, COLOR_BORDER, 1));
} }
private void promptGroupMessages() { private void promptGroupMessages() {
@@ -3336,6 +3637,46 @@ public final class MainActivity extends Activity {
return sheet; return sheet;
} }
private void addSheetActionSection(
Dialog dialog,
LinearLayout sheet,
String title,
SheetAction... actions
) {
TextView label = formLabel(title);
label.setPadding(dp(2), dp(8), dp(2), dp(6));
sheet.addView(label, matchWrap());
for (int index = 0; index < actions.length; index += 3) {
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
for (int column = 0; column < 3; column++) {
int actionIndex = index + column;
if (actionIndex >= actions.length) {
row.addView(new View(this), new LinearLayout.LayoutParams(0, dp(54), 1));
continue;
}
SheetAction action = actions[actionIndex];
Button button = toolbarButton(action.icon + "\n" + action.title, view -> {
dialog.dismiss();
action.action.run();
});
button.setTextSize(action.icon.length() > 2 ? 9 : 11);
button.setGravity(Gravity.CENTER);
button.setPadding(dp(3), dp(3), dp(3), dp(3));
if (action.danger) {
button.setTextColor(COLOR_DANGER);
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, dp(54), 1);
if (column > 0) {
params.leftMargin = dp(6);
}
params.bottomMargin = dp(6);
row.addView(button, params);
}
sheet.addView(row, matchWrap());
}
}
private EditText sheetField(LinearLayout sheet, String labelText, String hint, String value) { private EditText sheetField(LinearLayout sheet, String labelText, String hint, String value) {
TextView label = formLabel(labelText); TextView label = formLabel(labelText);
label.setPadding(0, dp(10), 0, dp(6)); label.setPadding(0, dp(10), 0, dp(6));
@@ -3355,7 +3696,14 @@ public final class MainActivity extends Activity {
} }
private void showBottomSheet(Dialog dialog, LinearLayout sheet, EditText focus) { private void showBottomSheet(Dialog dialog, LinearLayout sheet, EditText focus) {
dialog.setContentView(sheet); ScrollView scroller = new ScrollView(this);
scroller.setFillViewport(false);
scroller.setOverScrollMode(View.OVER_SCROLL_IF_CONTENT_SCROLLS);
scroller.addView(sheet, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
dialog.setContentView(scroller);
dialog.setCanceledOnTouchOutside(true); dialog.setCanceledOnTouchOutside(true);
dialog.show(); dialog.show();
Window window = dialog.getWindow(); Window window = dialog.getWindow();
@@ -3696,6 +4044,16 @@ public final class MainActivity extends Activity {
} }
} }
private void copyText(String label, String text) {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard == null) {
showMessage("Clipboard unavailable");
return;
}
clipboard.setPrimaryClip(ClipData.newPlainText(label, text));
showMessage(label + " copied");
}
private void insertComposerText(String text) { private void insertComposerText(String text) {
if (inputField == null) { if (inputField == null) {
return; return;
@@ -4574,6 +4932,7 @@ public final class MainActivity extends Activity {
eventSocket = null; eventSocket = null;
} }
executor.shutdownNow(); executor.shutdownNow();
serverProbeExecutor.shutdownNow();
super.onDestroy(); super.onDestroy();
} }
@@ -4584,4 +4943,26 @@ public final class MainActivity extends Activity {
private interface TextApiAction { private interface TextApiAction {
void run(String text) throws Exception; void run(String text) throws Exception;
} }
private interface IntValueListener {
void onValue(int value);
}
private static final class SheetAction {
final String icon;
final String title;
final Runnable action;
final boolean danger;
SheetAction(String icon, String title, Runnable action) {
this(icon, title, action, false);
}
SheetAction(String icon, String title, Runnable action, boolean danger) {
this.icon = icon;
this.title = title;
this.action = action;
this.danger = danger;
}
}
} }