From 2824fafc3251a58c85e45218feb17b970ee384a3 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 16:16:42 +0000 Subject: [PATCH] Polish session controls and server management --- .../neatstudio/tmuxandroid/MainActivity.java | 755 +++++++++++++----- 1 file changed, 568 insertions(+), 187 deletions(-) diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java index 20dae46..982344d 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java @@ -44,6 +44,7 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.ScrollView; +import android.widget.SeekBar; import android.widget.TextView; import android.widget.Toast; @@ -56,7 +57,9 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; 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_ABOUT = "About"; 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 = { "http://100.89.0.116: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 serverProbeExecutor = Executors.newFixedThreadPool(5); private final Handler mainHandler = new Handler(Looper.getMainLooper()); private SharedPreferences prefs; private UpdateManager updateManager; @@ -154,6 +162,9 @@ public final class MainActivity extends Activity { private String pendingImageUploadSession; private int lastSessionCount = -1; private int lastActiveSessionCount = -1; + private final Map serverSessionCounts = new HashMap<>(); + private final Map serverActiveSessionCounts = new HashMap<>(); + private final Set serverCountFailures = new HashSet<>(); private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS); private final StringBuilder queuedTerminalInput = new StringBuilder(); private final Set terminalExpandedBlocks = new HashSet<>(); @@ -167,6 +178,10 @@ public final class MainActivity extends Activity { private int terminalViewMode = TERMINAL_VIEW_CHAT; private int terminalKeyPage; private int terminalHistoryOffset; + private float terminalFontSizeSp = 11f; + private float terminalLineHeight = 1.05f; + private String terminalFontFamily = "monospace"; + private String terminalThemeId = "dark"; private float terminalTouchStartY; private int terminalTouchStartScrollY; private int terminalReconnectAttempt; @@ -295,6 +310,7 @@ public final class MainActivity extends Activity { 1 )); setStatus("Servers"); + refreshServerSummaries(serverUrls); } private View serverProfileCard(String url) { @@ -351,15 +367,21 @@ public final class MainActivity extends Activity { stateBlock.setOrientation(LinearLayout.VERTICAL); stateBlock.setGravity(Gravity.END); 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.setTextSize(13); active.setTypeface(Typeface.MONOSPACE); + active.setTag("server-active:" + url); 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.setTextSize(9); total.setTypeface(Typeface.MONOSPACE); + total.setTag("server-total:" + url); stateBlock.addView(active); stateBlock.addView(total); card.addView(stateBlock); @@ -378,6 +400,51 @@ public final class MainActivity extends Activity { return card; } + private void refreshServerSummaries(List urls) { + for (String url : urls) { + serverProbeExecutor.execute(() -> { + try { + List 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() { closeTerminalSocket(); activeSessionName = null; @@ -510,6 +577,9 @@ public final class MainActivity extends Activity { LinearLayout utilities = new LinearLayout(this); utilities.setOrientation(LinearLayout.HORIZONTAL); 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()); refresh.setContentDescription("Refresh sessions"); utilities.addView(refresh); @@ -846,24 +916,26 @@ public final class MainActivity extends Activity { ScrollView scroll = new ScrollView(this); LinearLayout content = pageContent(); - content.addView(infoBlock( - "Installed", - appIdentityText() + "\n" - + "Update source: " + updateSourceHost() + "\n" - + prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL) + content.addView(createPageHeader( + "release channel", + "Updates", + "Check", + view -> updateManager.checkSelected(true) )); - content.addView(sectionTitle("Update")); + content.addView(updateSummaryPanel()); + content.addView(sectionTitle("Release source")); content.addView(actionPanel( - actionButton("Auto check", view -> updateManager.check(true)), actionButton("Gitea", view -> updateManager.checkGitea(true)), actionButton("GitHub", view -> updateManager.checkGithub(true)), actionButton("Preview", view -> updateManager.checkPreview(true)), actionButton("Selected", view -> updateManager.checkSelected(true)), - actionButton("Source", view -> showUpdateSourcePicker()), - actionButton("APK", view -> updateManager.openApkDownload()) + actionButton("Choose source", view -> showUpdateSourcePicker()), + actionButton("Download APK", view -> updateManager.openApkDownload()) )); - content.addView(sectionTitle("Permissions")); - content.addView(infoBlock("Android", permissionSummary())); + content.addView(sectionTitle("Server installation")); + content.addView(serverInstallPanel()); + content.addView(sectionTitle("Android access")); + content.addView(infoBlock("Permissions", permissionSummary())); content.addView(actionPanel( actionButton("Install permission", view -> openInstallPermissionSettings()), actionButton("Notifications", view -> requestNotificationPermission()), @@ -883,6 +955,91 @@ public final class MainActivity extends Activity { 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() { closeTerminalSocket(); activeSessionName = null; @@ -1692,6 +1849,7 @@ public final class MainActivity extends Activity { private void openTerminal(String sessionName) { closeTerminalSocket(); activeSessionName = sessionName; + loadTerminalDisplaySettings(sessionName); terminalViewMode = TERMINAL_VIEW_CHAT; projectList = null; sessionGroupList = null; @@ -1727,18 +1885,18 @@ public final class MainActivity extends Activity { terminalScroll = new ScrollView(this); terminalScroll.setFillViewport(true); - terminalScroll.setBackgroundColor(COLOR_TERMINAL_BG); + terminalScroll.setBackgroundColor(terminalBackgroundColor()); terminalText = new TextView(this); terminalText.setTextColor(COLOR_TEXT); - terminalText.setTextSize(11); - terminalText.setTypeface(Typeface.MONOSPACE); + terminalText.setTextSize(terminalFontSizeSp); + terminalText.setTypeface(terminalTypeface()); terminalText.setIncludeFontPadding(false); terminalText.setHorizontallyScrolling(true); - terminalText.setLineSpacing(0, 1.05f); + terminalText.setLineSpacing(0, terminalLineHeight); terminalText.setGravity(Gravity.BOTTOM | Gravity.START); terminalText.setTextIsSelectable(terminalSelectionEnabled); terminalText.setPadding(dp(2), dp(8), dp(2), 0); - terminalText.setBackgroundColor(COLOR_TERMINAL_BG); + terminalText.setBackgroundColor(terminalBackgroundColor()); terminalChatList = new LinearLayout(this); terminalChatList.setOrientation(LinearLayout.VERTICAL); terminalChatList.setPadding(0, dp(12), 0, 0); @@ -2067,8 +2225,8 @@ public final class MainActivity extends Activity { TextView content = conversationContent(message, false); content.setText((user ? "› " : "• ") + defaultValue(message.content, "(empty)")); content.setTextColor(user ? COLOR_ACCENT : COLOR_TEXT); - content.setTextSize(12); - content.setTypeface(Typeface.MONOSPACE, user ? Typeface.BOLD : Typeface.NORMAL); + content.setTextSize(terminalFontSizeSp); + content.setTypeface(terminalTypeface(), user ? Typeface.BOLD : Typeface.NORMAL); content.setPadding(0, 0, 0, 0); block.addView(content, matchWrap()); @@ -2143,8 +2301,8 @@ public final class MainActivity extends Activity { TextView content = new TextView(this); content.setText(message.content.isEmpty() ? "(empty)" : message.content); content.setTextColor(COLOR_TEXT); - content.setTextSize(tool ? 11 : 14); - content.setTypeface(tool ? Typeface.MONOSPACE : Typeface.DEFAULT); + content.setTextSize(terminalFontSizeSp); + content.setTypeface(terminalTypeface()); content.setTextIsSelectable(true); content.setLineSpacing(dp(2), 1f); content.setPadding(0, dp(6), 0, 0); @@ -2193,13 +2351,13 @@ public final class MainActivity extends Activity { terminalLiveOutputText = new TextView(this); terminalLiveOutputText.setTextColor(COLOR_TEXT_MUTED); - terminalLiveOutputText.setTextSize(11); - terminalLiveOutputText.setTypeface(Typeface.MONOSPACE); + terminalLiveOutputText.setTextSize(terminalFontSizeSp); + terminalLiveOutputText.setTypeface(terminalTypeface()); terminalLiveOutputText.setIncludeFontPadding(false); terminalLiveOutputText.setHorizontallyScrolling(false); - terminalLiveOutputText.setLineSpacing(0, 1.05f); + terminalLiveOutputText.setLineSpacing(0, terminalLineHeight); terminalLiveOutputText.setGravity(Gravity.BOTTOM | Gravity.START); - terminalLiveOutputText.setBackgroundColor(COLOR_TERMINAL_BG); + terminalLiveOutputText.setBackgroundColor(terminalBackgroundColor()); terminalLiveOutputText.setPadding(0, dp(5), 0, 0); terminalLiveOutputText.setMovementMethod(LinkMovementMethod.getInstance()); terminalLiveOutputText.setHighlightColor(Color.TRANSPARENT); @@ -2392,152 +2550,71 @@ public final class MainActivity extends Activity { } private void showSessionActions(String sessionName) { - String[] items = { - "Status", - "Rename", - "Send command", - "Split horizontal", - "Split vertical", - "Select pane", - "Kill pane", - "Pin session", - "Unpin session", - "Mute session", - "Unmute session", - "Session settings", - "Add to kanban project", - "Post hook event" - }; - new AlertDialog.Builder(this) - .setTitle(sessionName) - .setItems(items, (dialog, which) -> { - switch (which) { - case 0: - showRaw("Session status", () -> api.sessionStatus(sessionName)); - break; - case 1: - 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(); + Dialog dialog = new Dialog(this); + LinearLayout sheet = bottomSheetContent(dialog, sessionName, "Session actions"); + addSheetActionSection(dialog, sheet, "OPEN & MANAGE", + new SheetAction("›_", "Open", () -> openTerminal(sessionName)), + new SheetAction("◎", "Status", () -> showRaw("Session status", () -> api.sessionStatus(sessionName))), + new SheetAction("✎", "Rename", () -> promptRenameSession(sessionName)), + new SheetAction("$", "Command", () -> promptSendCommand(sessionName)), + new SheetAction("Aa", "Display", () -> promptSessionSettings(sessionName)), + new SheetAction("▰", "Project", () -> promptAddKanbanSession(sessionName)) + ); + addSheetActionSection(dialog, sheet, "PANES", + new SheetAction("↔", "Split H", () -> runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal"))), + new SheetAction("↕", "Split V", () -> runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"))), + new SheetAction("◫", "Select", () -> promptPaneId("Select pane", paneId -> api.selectPane(sessionName, paneId))), + new SheetAction("×", "Kill pane", () -> promptPaneId("Kill pane", paneId -> api.killPane(sessionName, paneId)), true) + ); + addSheetActionSection(dialog, sheet, "PREFERENCES", + new SheetAction("★", "Pin", () -> runApiAction("Pin session", () -> api.setPinned(sessionName, true))), + new SheetAction("☆", "Unpin", () -> runApiAction("Unpin session", () -> api.setPinned(sessionName, false))), + new SheetAction("◉", "Mute", () -> runApiAction("Mute session", () -> api.setMuted(sessionName, true))), + new SheetAction("○", "Unmute", () -> runApiAction("Unmute session", () -> api.setMuted(sessionName, false))), + new SheetAction("⚑", "Hook", () -> promptPostHookEvent(sessionName)) + ); + showBottomSheet(dialog, sheet, null); } private void showTerminalActions(String sessionName) { - String[] items = { - terminalViewMode == TERMINAL_VIEW_CHAT ? "Open raw terminal" : "Open content view", - "Reconnect", - "Clear local view and tmux history", - "Split horizontal", - "Split vertical", - "Zoom active pane", - "Page up", - "Page down", - "Session status", - "Send command", - "Tmux prefix", - "Detach tmux client", - "New tmux window", - "Next tmux window", - "Previous tmux window" - }; - new AlertDialog.Builder(this) - .setTitle(sessionName) - .setItems(items, (dialog, which) -> { - switch (which) { - case 0: - showTerminalView(terminalViewMode == TERMINAL_VIEW_CHAT - ? TERMINAL_VIEW_FULL : TERMINAL_VIEW_CHAT, true); - break; - case 1: - connectTerminal(sessionName); - break; - case 2: - terminalScreen.clear(); - terminalText.setText(""); - if (terminalSocket != null) { - terminalSocket.clearHistory(); - } - break; - case 3: - runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal")); - break; - 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(); + Dialog dialog = new Dialog(this); + LinearLayout sheet = bottomSheetContent(dialog, sessionName, "Terminal controls"); + addSheetActionSection(dialog, sheet, "VIEW", + new SheetAction("◫", terminalViewMode == TERMINAL_VIEW_CHAT ? "Raw" : "Content", () -> + showTerminalView(terminalViewMode == TERMINAL_VIEW_CHAT + ? TERMINAL_VIEW_FULL : TERMINAL_VIEW_CHAT, true)), + new SheetAction("↻", "Reconnect", () -> connectTerminal(sessionName)), + new SheetAction("◎", "Status", () -> showRaw("Session status", () -> api.sessionStatus(sessionName))), + new SheetAction("Aa", "Display", () -> promptSessionSettings(sessionName)), + new SheetAction("↑", "Older", () -> scrollTerminalHistory(-terminalRows)), + new SheetAction("↓", "Newer", () -> scrollTerminalHistory(terminalRows)) + ); + addSheetActionSection(dialog, sheet, "LAYOUT", + new SheetAction("↔", "Split H", () -> runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal"))), + new SheetAction("↕", "Split V", () -> runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"))), + new SheetAction("▣", "Zoom", () -> sendTerminalInput("\u0002z")), + new SheetAction("⌫", "Clear", this::clearTerminalHistory, true) + ); + addSheetActionSection(dialog, sheet, "TMUX", + new SheetAction("C-b", "Prefix", () -> sendTerminalInput("\u0002")), + new SheetAction("⇥", "Detach", () -> sendTerminalInput("\u0002d")), + new SheetAction("+", "New win", () -> sendTerminalInput("\u0002c")), + new SheetAction("→", "Next win", () -> sendTerminalInput("\u0002n")), + new SheetAction("←", "Prev win", () -> sendTerminalInput("\u0002p")), + new SheetAction("$", "Command", () -> promptSendCommand(sessionName)) + ); + showBottomSheet(dialog, sheet, null); + } + + private void clearTerminalHistory() { + terminalScreen.clear(); + if (terminalText != null) { + terminalText.setText(""); + } + if (terminalSocket != null) { + terminalSocket.clearHistory(); + } + setStatus("Terminal history cleared"); } private LinearLayout createComposerBar() { @@ -2809,25 +2886,249 @@ public final class MainActivity extends Activity { } private void promptSessionSettings(String sessionName) { - LinearLayout form = formRoot(); - EditText fontSize = formField(form, "Font size", "14"); - EditText fontFamily = formField(form, "Font family", "monospace"); - EditText lineHeight = formField(form, "Line height", "1.25"); - EditText themeId = formField(form, "Theme ID", "dark"); - new AlertDialog.Builder(this) - .setTitle("Session settings") - .setView(form) - .setNegativeButton("Cancel", null) - .setPositiveButton("Save", (dialog, which) -> runApiAction("Session settings", () -> + String key = sessionName.isEmpty() ? "default" : sessionName; + float currentSize = prefs.getFloat("terminal_font_size:" + key, + prefs.getFloat("terminal_font_size:default", 11f)); + float currentLineHeight = prefs.getFloat("terminal_line_height:" + key, + prefs.getFloat("terminal_line_height:default", 1.05f)); + String currentFamily = prefs.getString("terminal_font_family:" + key, + prefs.getString("terminal_font_family:default", "monospace")); + String currentTheme = prefs.getString("terminal_theme:" + key, + prefs.getString("terminal_theme:default", "dark")); + + 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( sessionName, - Integer.parseInt(defaultValue(fontSize.getText().toString().trim(), "14")), - defaultValue(fontFamily.getText().toString().trim(), "monospace"), - Double.parseDouble(defaultValue(lineHeight.getText().toString().trim(), "1.25")), - defaultValue(themeId.getText().toString().trim(), "dark") - ) - )) - .show(); + Math.round(selectedSize), + selectedFamily[0], + selectedHeight, + selectedTheme[0] + ); + } catch (Exception error) { + 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() { @@ -3336,6 +3637,46 @@ public final class MainActivity extends Activity { 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) { TextView label = formLabel(labelText); 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) { - 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.show(); 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) { if (inputField == null) { return; @@ -4574,6 +4932,7 @@ public final class MainActivity extends Activity { eventSocket = null; } executor.shutdownNow(); + serverProbeExecutor.shutdownNow(); super.onDestroy(); } @@ -4584,4 +4943,26 @@ public final class MainActivity extends Activity { private interface TextApiAction { 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; + } + } }