Polish server and session management UI
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 53s

This commit is contained in:
Codex
2026-07-13 06:58:51 +00:00
parent 41e50322be
commit a88b6cf98d
@@ -3,6 +3,7 @@ package com.neatstudio.tmuxandroid;
import android.Manifest; import android.Manifest;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.app.Dialog;
import android.content.ClipData; import android.content.ClipData;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
@@ -14,6 +15,7 @@ import android.graphics.BitmapFactory;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.StateListDrawable; import android.graphics.drawable.StateListDrawable;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
@@ -29,6 +31,7 @@ import android.view.KeyEvent;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowInsets; import android.view.WindowInsets;
import android.view.Window;
import android.view.WindowManager; import android.view.WindowManager;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodManager;
@@ -127,8 +130,6 @@ public final class MainActivity extends Activity {
private TextView terminalText; private TextView terminalText;
private TextView terminalMetaText; private TextView terminalMetaText;
private TextView terminalConnectionText; private TextView terminalConnectionText;
private Button terminalReadingButton;
private Button terminalFullButton;
private ScrollView terminalScroll; private ScrollView terminalScroll;
private LinearLayout terminalChatList; private LinearLayout terminalChatList;
private TextView terminalLiveStatusText; private TextView terminalLiveStatusText;
@@ -277,7 +278,7 @@ public final class MainActivity extends Activity {
view -> promptCustomServer() view -> promptCustomServer()
)); ));
for (String url : serverUrls) { for (String url : serverUrls) {
content.addView(serverProfileCard(url, !isPresetServer(url)), matchWrap()); content.addView(serverProfileCard(url), spacedHeight(72, 0, 12));
} }
content.addView(createServerUtilityRow()); content.addView(createServerUtilityRow());
@@ -290,7 +291,7 @@ public final class MainActivity extends Activity {
setStatus("Servers"); setStatus("Servers");
} }
private View serverProfileCard(String url, boolean removable) { private View serverProfileCard(String url) {
String label = url.replace("http://", "").replace(":3000", ""); String label = url.replace("http://", "").replace(":3000", "");
boolean selected = url.equals(getServerUrl()); boolean selected = url.equals(getServerUrl());
LinearLayout card = new LinearLayout(this); LinearLayout card = new LinearLayout(this);
@@ -356,13 +357,6 @@ public final class MainActivity extends Activity {
stateBlock.addView(active); stateBlock.addView(active);
stateBlock.addView(total); stateBlock.addView(total);
card.addView(stateBlock); card.addView(stateBlock);
if (removable) {
Button remove = terminalToolButton("×", view -> confirmRemoveSavedServer(url));
remove.setContentDescription("Remove " + label);
remove.setTextColor(COLOR_DANGER);
card.addView(remove);
}
TextView chevron = new TextView(this); TextView chevron = new TextView(this);
chevron.setText(""); chevron.setText("");
chevron.setTextColor(COLOR_TEXT_DIM); chevron.setTextColor(COLOR_TEXT_DIM);
@@ -370,12 +364,11 @@ public final class MainActivity extends Activity {
chevron.setGravity(Gravity.CENTER); chevron.setGravity(Gravity.CENTER);
card.addView(chevron, new LinearLayout.LayoutParams(dp(24), dp(36))); card.addView(chevron, new LinearLayout.LayoutParams(dp(24), dp(36)));
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( Button remove = terminalToolButton("×", view -> confirmRemoveSavedServer(url));
ViewGroup.LayoutParams.MATCH_PARENT, remove.setContentDescription("Remove " + label);
dp(68) remove.setTextColor(COLOR_DANGER);
); card.addView(remove);
params.bottomMargin = dp(10);
card.setLayoutParams(params);
return card; return card;
} }
@@ -397,7 +390,6 @@ public final class MainActivity extends Activity {
sessionGroupList.setOrientation(LinearLayout.VERTICAL); sessionGroupList.setOrientation(LinearLayout.VERTICAL);
sessionGroupList.addView(projectStateText("Loading sessions and groups..."), matchWrap()); sessionGroupList.addView(projectStateText("Loading sessions and groups..."), matchWrap());
content.addView(sessionGroupList, matchWrap()); content.addView(sessionGroupList, matchWrap());
content.addView(createNewGroupCard(), matchWrap());
scroll.addView(content); scroll.addView(content);
root.addView(scroll, new LinearLayout.LayoutParams( root.addView(scroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -503,65 +495,25 @@ public final class MainActivity extends Activity {
titleBlock.addView(title); titleBlock.addView(title);
row.addView(titleBlock, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); row.addView(titleBlock, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
Button refresh = terminalToolButton("", view -> refreshSessions());
refresh.setContentDescription("Refresh sessions");
row.addView(refresh);
row.addView(primaryButton(" Session", view -> promptCreateSession()), new LinearLayout.LayoutParams( row.addView(primaryButton(" Session", view -> promptCreateSession()), new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT,
dp(38) dp(38)
)); ));
header.addView(row, matchWrap()); header.addView(row, matchWrap());
LinearLayout utilities = new LinearLayout(this);
utilities.setOrientation(LinearLayout.HORIZONTAL);
utilities.setGravity(Gravity.END | Gravity.CENTER_VERTICAL);
Button refresh = compactButton("↻ Refresh", view -> refreshSessions());
refresh.setContentDescription("Refresh sessions");
utilities.addView(refresh);
Button addGroup = compactButton(" New group", view -> promptCreateKanbanProject());
addGroup.setContentDescription("Add group");
utilities.addView(addGroup);
header.addView(utilities, spacedMatchWrap(8, 0));
return header; return header;
} }
private View createNewGroupCard() {
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.setPadding(dp(12), dp(11), dp(12), dp(12));
card.setBackground(cardBackground());
TextView label = new TextView(this);
label.setText("NEW GROUP");
label.setTextColor(COLOR_TEXT_DIM);
label.setTextSize(9);
label.setTypeface(Typeface.MONOSPACE);
card.addView(label);
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setPadding(0, dp(7), 0, 0);
EditText input = new EditText(this);
input.setHint("group name");
input.setSingleLine(true);
input.setInputType(InputType.TYPE_CLASS_TEXT);
styleInput(input);
row.addView(input, new LinearLayout.LayoutParams(0, dp(40), 1));
Button create = compactButton("Create", view -> {
String name = input.getText().toString().trim();
if (name.isEmpty()) {
showMessage("Group name is empty");
return;
}
runApiAction("Create kanban project", () -> api.createKanbanProject(name, "~", ""));
});
LinearLayout.LayoutParams createParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(40)
);
createParams.leftMargin = dp(8);
row.addView(create, createParams);
card.addView(row, matchWrap());
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.topMargin = dp(8);
params.bottomMargin = dp(10);
card.setLayoutParams(params);
return card;
}
private Button primaryButton(String label, View.OnClickListener listener) { private Button primaryButton(String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener); Button button = toolbarButton(label, listener);
button.setTextColor(Color.rgb(14, 38, 24)); button.setTextColor(Color.rgb(14, 38, 24));
@@ -572,6 +524,10 @@ public final class MainActivity extends Activity {
} }
private String serverDisplayName(String url) { private String serverDisplayName(String url) {
String savedName = prefs.getString("server_name:" + url, "").trim();
if (!savedName.isEmpty()) {
return savedName;
}
Uri parsed = Uri.parse(url); Uri parsed = Uri.parse(url);
String host = parsed.getHost(); String host = parsed.getHost();
if (host == null || host.isEmpty()) { if (host == null || host.isEmpty()) {
@@ -585,28 +541,71 @@ public final class MainActivity extends Activity {
} }
private void promptCustomServer() { private void promptCustomServer() {
EditText input = new EditText(this); Dialog dialog = new Dialog(this);
input.setSingleLine(true); LinearLayout sheet = bottomSheetContent(dialog, "Add server", "Save a Tailscale host and connect now");
input.setHint("100.64.0.x:3000"); EditText name = sheetField(sheet, "NAME", "e.g. build-box", "");
input.setText(getServerUrl());
input.setSelectAllOnFocus(true); LinearLayout endpointLabels = new LinearLayout(this);
styleInput(input); endpointLabels.setOrientation(LinearLayout.HORIZONTAL);
new AlertDialog.Builder(this) endpointLabels.setPadding(0, dp(14), 0, dp(6));
.setTitle("Add server") TextView hostLabel = formLabel("HOST (TAILSCALE IP)");
.setView(input) endpointLabels.addView(hostLabel, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
.setNegativeButton("Cancel", null) TextView portLabel = formLabel("PORT");
.setPositiveButton("Connect", (dialog, which) -> { LinearLayout.LayoutParams portLabelParams = new LinearLayout.LayoutParams(dp(82), ViewGroup.LayoutParams.WRAP_CONTENT);
String url = normalizeServerUrl(input.getText().toString()); portLabelParams.leftMargin = dp(8);
saveCustomServer(url); endpointLabels.addView(portLabel, portLabelParams);
selectServer(url); sheet.addView(endpointLabels, matchWrap());
openSessionPage(); LinearLayout endpoint = new LinearLayout(this);
}) endpoint.setOrientation(LinearLayout.HORIZONTAL);
.show(); EditText host = new EditText(this);
host.setHint("100.64.0.x");
host.setSingleLine(true);
host.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
styleInput(host);
endpoint.addView(host, new LinearLayout.LayoutParams(0, dp(44), 1));
EditText port = new EditText(this);
port.setText("3000");
port.setSelectAllOnFocus(true);
port.setInputType(InputType.TYPE_CLASS_NUMBER);
styleInput(port);
LinearLayout.LayoutParams portParams = new LinearLayout.LayoutParams(dp(82), dp(44));
portParams.leftMargin = dp(8);
endpoint.addView(port, portParams);
sheet.addView(endpoint, matchWrap());
Button connect = primaryButton("Connect", null);
connect.setOnClickListener(view -> {
String rawHost = host.getText().toString().trim();
String rawPort = port.getText().toString().trim();
if (rawHost.isEmpty()) {
host.setError("Host is required");
return;
}
if (!rawPort.matches("[0-9]{1,5}")) {
port.setError("Invalid port");
return;
}
String url = normalizeServerUrl(rawHost + ":" + rawPort);
saveCustomServer(url, name.getText().toString().trim());
dialog.dismiss();
selectServer(url);
openSessionPage();
});
LinearLayout.LayoutParams connectParams = matchWrap();
connectParams.height = dp(46);
connectParams.topMargin = dp(18);
sheet.addView(connect, connectParams);
showBottomSheet(dialog, sheet, host);
} }
private List<String> savedServerUrls() { private List<String> savedServerUrls() {
List<String> urls = new ArrayList<>(); List<String> urls = new ArrayList<>();
Collections.addAll(urls, SERVER_PROFILES); Set<String> hidden = prefs.getStringSet("hidden_server_urls", Collections.emptySet());
for (String preset : SERVER_PROFILES) {
if (!hidden.contains(preset)) {
urls.add(preset);
}
}
Set<String> custom = prefs.getStringSet("custom_server_urls", Collections.emptySet()); Set<String> custom = prefs.getStringSet("custom_server_urls", Collections.emptySet());
List<String> sortedCustom = new ArrayList<>(custom); List<String> sortedCustom = new ArrayList<>(custom);
Collections.sort(sortedCustom); Collections.sort(sortedCustom);
@@ -616,7 +615,7 @@ public final class MainActivity extends Activity {
} }
} }
String active = getServerUrl(); String active = getServerUrl();
if (!urls.contains(active)) { if (!urls.contains(active) && !hidden.contains(active)) {
urls.add(0, active); urls.add(0, active);
} }
return urls; return urls;
@@ -631,15 +630,24 @@ public final class MainActivity extends Activity {
return false; return false;
} }
private void saveCustomServer(String url) { private void saveCustomServer(String url, String name) {
if (isPresetServer(url)) {
return;
}
Set<String> custom = new HashSet<>( Set<String> custom = new HashSet<>(
prefs.getStringSet("custom_server_urls", Collections.emptySet()) prefs.getStringSet("custom_server_urls", Collections.emptySet())
); );
custom.add(url); Set<String> hidden = new HashSet<>(
prefs.edit().putStringSet("custom_server_urls", custom).apply(); prefs.getStringSet("hidden_server_urls", Collections.emptySet())
);
hidden.remove(url);
if (!isPresetServer(url)) {
custom.add(url);
}
SharedPreferences.Editor editor = prefs.edit()
.putStringSet("custom_server_urls", custom)
.putStringSet("hidden_server_urls", hidden);
if (!name.isEmpty()) {
editor.putString("server_name:" + url, name);
}
editor.apply();
} }
private void confirmRemoveSavedServer(String url) { private void confirmRemoveSavedServer(String url) {
@@ -651,12 +659,23 @@ public final class MainActivity extends Activity {
Set<String> custom = new HashSet<>( Set<String> custom = new HashSet<>(
prefs.getStringSet("custom_server_urls", Collections.emptySet()) prefs.getStringSet("custom_server_urls", Collections.emptySet())
); );
Set<String> hidden = new HashSet<>(
prefs.getStringSet("hidden_server_urls", Collections.emptySet())
);
custom.remove(url); custom.remove(url);
SharedPreferences.Editor editor = prefs.edit() SharedPreferences.Editor editor = prefs.edit()
.putStringSet("custom_server_urls", custom); .putStringSet("custom_server_urls", custom)
.remove("server_name:" + url);
if (isPresetServer(url)) {
hidden.add(url);
editor.putStringSet("hidden_server_urls", hidden);
}
if (url.equals(getServerUrl())) { if (url.equals(getServerUrl())) {
editor.putString("server_url", DEFAULT_TAILSCALE_URL); List<String> remaining = savedServerUrls();
api = new SessionApiClient(DEFAULT_TAILSCALE_URL); remaining.remove(url);
String fallback = remaining.isEmpty() ? BuildConfig.DEFAULT_SERVER_URL : remaining.get(0);
editor.putString("server_url", fallback);
api = new SessionApiClient(fallback);
connectAppEvents(); connectAppEvents();
} }
editor.apply(); editor.apply();
@@ -1243,7 +1262,7 @@ public final class MainActivity extends Activity {
project.optString("name", "Project"), project.optString("name", "Project"),
groupSessions, groupSessions,
true true
), matchWrap()); ), spacedMatchWrap(4, 16));
} }
} catch (Exception ignored) { } catch (Exception ignored) {
} }
@@ -1254,7 +1273,10 @@ public final class MainActivity extends Activity {
ungrouped.add(session); ungrouped.add(session);
} }
} }
sessionGroupList.addView(sessionGroupSection("Ungrouped", ungrouped, false), matchWrap()); sessionGroupList.addView(
sessionGroupSection("Ungrouped", ungrouped, false),
spacedMatchWrap(4, 16)
);
setStatus("Loaded " + sessions.size() + " sessions"); setStatus("Loaded " + sessions.size() + " sessions");
} }
@@ -1303,16 +1325,10 @@ public final class MainActivity extends Activity {
section.addView(empty, matchWrap()); section.addView(empty, matchWrap());
} else { } else {
for (SessionSummary session : sessions) { for (SessionSummary session : sessions) {
section.addView(sessionRow(session), matchWrap()); section.addView(sessionRow(session), spacedMatchWrap(0, 10));
} }
} }
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(7);
section.setLayoutParams(params);
return section; return section;
} }
@@ -1357,7 +1373,7 @@ public final class MainActivity extends Activity {
return; return;
} }
for (int index = 0; index < projects.length(); index++) { for (int index = 0; index < projects.length(); index++) {
projectList.addView(projectCard(projects.getJSONObject(index)), matchWrap()); projectList.addView(projectCard(projects.getJSONObject(index)));
} }
} catch (Exception error) { } catch (Exception error) {
projectList.addView(projectStateText(text == null || text.isEmpty() ? "(empty)" : text), matchWrap()); projectList.addView(projectStateText(text == null || text.isEmpty() ? "(empty)" : text), matchWrap());
@@ -1463,7 +1479,7 @@ public final class MainActivity extends Activity {
list.addView(empty); list.addView(empty);
} }
for (SessionSummary session : sessions) { for (SessionSummary session : sessions) {
list.addView(sessionRow(session), matchWrap()); list.addView(sessionRow(session), spacedMatchWrap(0, 10));
} }
if (sessionSummaryText != null) { if (sessionSummaryText != null) {
sessionSummaryText.setText("Server: " + getServerUrl() + "\nSessions: " + sessions.size()); sessionSummaryText.setText("Server: " + getServerUrl() + "\nSessions: " + sessions.size());
@@ -1542,43 +1558,110 @@ public final class MainActivity extends Activity {
kill.setTextColor(COLOR_DANGER); kill.setTextColor(COLOR_DANGER);
row.addView(kill); row.addView(kill);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(7);
row.setLayoutParams(params);
return row; return row;
} }
private void promptCreateSession() { private void promptCreateSession() {
EditText input = new EditText(this); progressBar.setVisibility(View.VISIBLE);
input.setSingleLine(true); executor.execute(() -> {
input.setHint("session-name"); List<String> groups = new ArrayList<>();
input.setInputType(InputType.TYPE_CLASS_TEXT); try {
new AlertDialog.Builder(this) JSONObject object = new JSONObject(api.kanbanProjects());
.setTitle("Create tmux session") JSONArray projects = object.optJSONArray("projects");
.setView(input) for (int index = 0; projects != null && index < projects.length(); index++) {
.setNegativeButton("Cancel", null) JSONObject project = projects.optJSONObject(index);
.setPositiveButton("Create", (dialog, which) -> { if (project != null && !project.optString("name").isEmpty()) {
String name = input.getText().toString().trim(); groups.add(project.optString("name"));
if (!name.matches("[A-Za-z0-9._-]+")) {
showMessage("Invalid session name");
return;
} }
executor.execute(() -> { }
try { } catch (Exception ignored) {
api.createSession(name); }
runOnUiThread(() -> { runOnUiThread(() -> {
showMessage("Created " + name); progressBar.setVisibility(View.GONE);
refreshSessions(); showCreateSessionDialog(groups);
}); });
} catch (Exception error) { });
runOnUiThread(() -> showMessage("Create failed: " + error.getMessage())); }
}
private void showCreateSessionDialog(List<String> groups) {
Dialog dialog = new Dialog(this);
LinearLayout sheet = bottomSheetContent(
dialog,
"New session",
"Create a tmux session and optionally place it in a group"
);
EditText input = sheetField(sheet, "NAME", "session-name", "");
TextView groupLabel = formLabel("GROUP");
groupLabel.setPadding(0, dp(14), 0, dp(6));
sheet.addView(groupLabel, matchWrap());
HorizontalScrollView groupScroller = new HorizontalScrollView(this);
groupScroller.setHorizontalScrollBarEnabled(false);
LinearLayout groupRow = new LinearLayout(this);
groupRow.setOrientation(LinearLayout.HORIZONTAL);
List<Button> groupButtons = new ArrayList<>();
List<String> choices = new ArrayList<>();
choices.add("");
choices.addAll(groups);
String[] selectedGroup = {""};
for (String group : choices) {
String label = group.isEmpty() ? "Ungrouped" : group;
Button choice = compactButton(label, null);
choice.setOnClickListener(view -> {
selectedGroup[0] = group;
for (int index = 0; index < groupButtons.size(); index++) {
boolean selected = choices.get(index).equals(group);
groupButtons.get(index).setTextColor(selected ? COLOR_ACCENT : COLOR_TEXT_MUTED);
groupButtons.get(index).setBackground(selected
? rounded(COLOR_ACCENT_DARK, 8, COLOR_ACCENT, 1)
: buttonBackground());
}
});
groupButtons.add(choice);
groupRow.addView(choice);
}
groupButtons.get(0).performClick();
groupScroller.addView(groupRow, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(40)
));
sheet.addView(groupScroller, matchWrap());
Button create = primaryButton("Create session", null);
create.setOnClickListener(view -> {
String name = input.getText().toString().trim();
if (!name.matches("[A-Za-z0-9._-]+")) {
input.setError("Use letters, numbers, dot, dash, or underscore");
return;
}
create.setEnabled(false);
executor.execute(() -> {
try {
api.createSession(name);
if (!selectedGroup[0].isEmpty()) {
api.addKanbanSession(selectedGroup[0], name);
}
runOnUiThread(() -> {
dialog.dismiss();
showMessage("Created " + name);
refreshSessions();
}); });
}) } catch (Exception error) {
.show(); runOnUiThread(() -> {
create.setEnabled(true);
showMessage("Create failed: " + error.getMessage());
});
}
});
});
LinearLayout.LayoutParams createParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(46)
);
createParams.topMargin = dp(16);
sheet.addView(create, createParams);
showBottomSheet(dialog, sheet, input);
} }
private void confirmKill(String sessionName) { private void confirmKill(String sessionName) {
@@ -1603,6 +1686,7 @@ public final class MainActivity extends Activity {
private void openTerminal(String sessionName) { private void openTerminal(String sessionName) {
closeTerminalSocket(); closeTerminalSocket();
activeSessionName = sessionName; activeSessionName = sessionName;
terminalViewMode = TERMINAL_VIEW_CHAT;
projectList = null; projectList = null;
sessionGroupList = null; sessionGroupList = null;
terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS); terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
@@ -1765,13 +1849,6 @@ public final class MainActivity extends Activity {
dp(13) dp(13)
)); ));
bar.addView(titleBlock, new LinearLayout.LayoutParams(0, dp(32), 1)); bar.addView(titleBlock, new LinearLayout.LayoutParams(0, dp(32), 1));
terminalReadingButton = terminalToolButton("", view -> showTerminalView(TERMINAL_VIEW_CHAT, true));
terminalReadingButton.setContentDescription("Chat view");
terminalFullButton = terminalToolButton("", view -> showTerminalView(TERMINAL_VIEW_FULL, true));
terminalFullButton.setContentDescription("Full terminal view");
styleTerminalReadingButton();
bar.addView(terminalReadingButton);
bar.addView(terminalFullButton);
bar.addView(terminalToolButton("", view -> showTerminalActions(sessionName))); bar.addView(terminalToolButton("", view -> showTerminalActions(sessionName)));
return bar; return bar;
} }
@@ -1779,7 +1856,6 @@ public final class MainActivity extends Activity {
private void showTerminalView(int mode, boolean announce) { private void showTerminalView(int mode, boolean announce) {
terminalViewMode = mode; terminalViewMode = mode;
if (terminalScroll == null || terminalText == null || terminalChatList == null) { if (terminalScroll == null || terminalText == null || terminalChatList == null) {
styleTerminalReadingButton();
return; return;
} }
terminalScroll.removeAllViews(); terminalScroll.removeAllViews();
@@ -1799,7 +1875,6 @@ public final class MainActivity extends Activity {
)); ));
renderTerminalNow(); renderTerminalNow();
} }
styleTerminalReadingButton();
boolean chat = mode == TERMINAL_VIEW_CHAT; boolean chat = mode == TERMINAL_VIEW_CHAT;
if (terminalAccessoryBar != null) { if (terminalAccessoryBar != null) {
terminalAccessoryBar.setVisibility(chat ? View.GONE : View.VISIBLE); terminalAccessoryBar.setVisibility(chat ? View.GONE : View.VISIBLE);
@@ -1815,19 +1890,6 @@ public final class MainActivity extends Activity {
} }
} }
private void styleTerminalReadingButton() {
if (terminalReadingButton == null || terminalFullButton == null) {
return;
}
styleTerminalViewButton(terminalReadingButton, terminalViewMode == TERMINAL_VIEW_CHAT);
styleTerminalViewButton(terminalFullButton, terminalViewMode == TERMINAL_VIEW_FULL);
}
private void styleTerminalViewButton(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) : buttonBackground());
}
private HorizontalScrollView createTerminalGroupBar() { private HorizontalScrollView createTerminalGroupBar() {
HorizontalScrollView scroller = new HorizontalScrollView(this); HorizontalScrollView scroller = new HorizontalScrollView(this);
scroller.setHorizontalScrollBarEnabled(false); scroller.setHorizontalScrollBarEnabled(false);
@@ -1964,8 +2026,19 @@ public final class MainActivity extends Activity {
empty.setPadding(dp(12), dp(24), dp(12), dp(24)); empty.setPadding(dp(12), dp(24), dp(12), dp(24));
terminalChatList.addView(empty, matchWrap()); terminalChatList.addView(empty, matchWrap());
} else { } else {
for (ConversationMessage message : terminalConversationMessages) { for (int index = 0; index < terminalConversationMessages.size();) {
terminalChatList.addView(conversationMessageRow(message)); ConversationMessage message = terminalConversationMessages.get(index);
if (!message.isTool()) {
terminalChatList.addView(conversationMessageRow(message));
index++;
continue;
}
List<ConversationMessage> tools = new ArrayList<>();
while (index < terminalConversationMessages.size()
&& terminalConversationMessages.get(index).isTool()) {
tools.add(terminalConversationMessages.get(index++));
}
terminalChatList.addView(conversationToolGroup(tools));
} }
} }
terminalChatList.addView(conversationLiveTerminalPanel(), matchWrap()); terminalChatList.addView(conversationLiveTerminalPanel(), matchWrap());
@@ -2020,27 +2093,24 @@ public final class MainActivity extends Activity {
}); });
} }
LinearLayout.LayoutParams bubbleParams = new LinearLayout.LayoutParams( if (!tool && !user) {
0, row.addView(bubble, matchWrap());
ViewGroup.LayoutParams.WRAP_CONTENT, } else if (!tool) {
tool ? 1f : user ? 0.82f : 0.94f LinearLayout.LayoutParams bubbleParams = new LinearLayout.LayoutParams(
); 0,
if (!tool) { ViewGroup.LayoutParams.WRAP_CONTENT,
0.82f
);
View spacer = new View(this); View spacer = new View(this);
LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams( LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(
0, 0,
1, 1,
user ? 0.18f : 0.06f 0.18f
); );
if (user) { row.addView(spacer, spacerParams);
row.addView(spacer, spacerParams);
row.addView(bubble, bubbleParams);
} else {
row.addView(bubble, bubbleParams);
row.addView(spacer, spacerParams);
}
} else {
row.addView(bubble, bubbleParams); row.addView(bubble, bubbleParams);
} else {
row.addView(bubble, matchWrap());
} }
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams( LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -2051,6 +2121,64 @@ public final class MainActivity extends Activity {
return row; return row;
} }
private View conversationToolGroup(List<ConversationMessage> tools) {
LinearLayout group = new LinearLayout(this);
group.setOrientation(LinearLayout.VERTICAL);
HorizontalScrollView scroller = new HorizontalScrollView(this);
scroller.setHorizontalScrollBarEnabled(false);
LinearLayout chips = new LinearLayout(this);
chips.setOrientation(LinearLayout.HORIZONTAL);
chips.setGravity(Gravity.CENTER_VERTICAL);
for (ConversationMessage message : tools) {
String label = compactLabel(conversationToolTitle(message), 18);
Button chip = compactButton(label, view -> {
if (!terminalExpandedMessages.add(message.messageId)) {
terminalExpandedMessages.remove(message.messageId);
}
renderTerminalConversation();
});
chip.setTextSize(9);
chip.setContentDescription("Toggle " + label);
chips.addView(chip);
}
scroller.addView(chips, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(34)
));
group.addView(scroller, matchWrap());
for (ConversationMessage message : tools) {
if (!terminalExpandedMessages.contains(message.messageId)) {
continue;
}
LinearLayout detail = new LinearLayout(this);
detail.setOrientation(LinearLayout.VERTICAL);
detail.setPadding(dp(10), dp(7), dp(10), dp(9));
detail.setBackground(rounded(COLOR_FIELD, 7, COLOR_BORDER_SOFT, 1));
TextView title = bodyText(conversationToolTitle(message));
title.setTextColor(COLOR_ACCENT_WARM);
title.setTextSize(9);
title.setTypeface(Typeface.MONOSPACE, Typeface.BOLD);
detail.addView(title, matchWrap());
detail.addView(conversationContent(message, true), matchWrap());
LinearLayout.LayoutParams detailParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
detailParams.topMargin = dp(5);
group.addView(detail, detailParams);
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.bottomMargin = dp(8);
group.setLayoutParams(params);
return group;
}
private TextView conversationContent(ConversationMessage message, boolean tool) { private TextView conversationContent(ConversationMessage message, boolean tool) {
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);
@@ -2360,6 +2488,7 @@ public final class MainActivity extends Activity {
private void showTerminalActions(String sessionName) { private void showTerminalActions(String sessionName) {
String[] items = { String[] items = {
terminalViewMode == TERMINAL_VIEW_CHAT ? "Open raw terminal" : "Open content view",
"Reconnect", "Reconnect",
"Clear local view and tmux history", "Clear local view and tmux history",
"Split horizontal", "Split horizontal",
@@ -2380,53 +2509,57 @@ public final class MainActivity extends Activity {
.setItems(items, (dialog, which) -> { .setItems(items, (dialog, which) -> {
switch (which) { switch (which) {
case 0: case 0:
connectTerminal(sessionName); showTerminalView(terminalViewMode == TERMINAL_VIEW_CHAT
? TERMINAL_VIEW_FULL : TERMINAL_VIEW_CHAT, true);
break; break;
case 1: case 1:
connectTerminal(sessionName);
break;
case 2:
terminalScreen.clear(); terminalScreen.clear();
terminalText.setText(""); terminalText.setText("");
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.clearHistory(); terminalSocket.clearHistory();
} }
break; break;
case 2: case 3:
runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal")); runApiAction("Split horizontal", () -> api.splitPane(sessionName, "horizontal"));
break; break;
case 3: case 4:
runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical")); runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"));
break; break;
case 4: case 5:
sendTerminalInput("\u0002z"); sendTerminalInput("\u0002z");
break; break;
case 5: case 6:
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.scroll(-terminalRows); terminalSocket.scroll(-terminalRows);
} }
break; break;
case 6: case 7:
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.scroll(terminalRows); terminalSocket.scroll(terminalRows);
} }
break; break;
case 7: case 8:
showRaw("Session status", () -> api.sessionStatus(sessionName)); showRaw("Session status", () -> api.sessionStatus(sessionName));
break; break;
case 8: case 9:
promptSendCommand(sessionName); promptSendCommand(sessionName);
break; break;
case 9: case 10:
sendTerminalInput("\u0002"); sendTerminalInput("\u0002");
break; break;
case 10: case 11:
sendTerminalInput("\u0002d"); sendTerminalInput("\u0002d");
break; break;
case 11: case 12:
sendTerminalInput("\u0002c"); sendTerminalInput("\u0002c");
break; break;
case 12: case 13:
sendTerminalInput("\u0002n"); sendTerminalInput("\u0002n");
break; break;
case 13: case 14:
sendTerminalInput("\u0002p"); sendTerminalInput("\u0002p");
break; break;
default: default:
@@ -2644,22 +2777,34 @@ public final class MainActivity extends Activity {
} }
private void promptCreateKanbanProject() { private void promptCreateKanbanProject() {
LinearLayout form = formRoot(); Dialog dialog = new Dialog(this);
EditText name = formField(form, "Project name", "project"); LinearLayout sheet = bottomSheetContent(
EditText path = formField(form, "Path", "~"); dialog,
EditText server = formField(form, "SSH server optional", ""); "New group",
new AlertDialog.Builder(this) "Organize related tmux sessions under one project"
.setTitle("Create kanban project") );
.setView(form) EditText name = sheetField(sheet, "NAME", "group name", "");
.setNegativeButton("Cancel", null) EditText path = sheetField(sheet, "WORKING PATH", "~", "~");
.setPositiveButton("Create", (dialog, which) -> runApiAction("Create kanban project", () -> EditText server = sheetField(sheet, "SSH SERVER (OPTIONAL)", "user@host", "");
api.createKanbanProject( Button create = primaryButton("Create group", null);
name.getText().toString().trim(), create.setOnClickListener(view -> {
defaultValue(path.getText().toString().trim(), "~"), String groupName = name.getText().toString().trim();
server.getText().toString().trim() if (groupName.isEmpty()) {
) name.setError("Group name is required");
)) return;
.show(); }
dialog.dismiss();
runApiAction("Create group", () -> api.createKanbanProject(
groupName,
defaultValue(path.getText().toString().trim(), "~"),
server.getText().toString().trim()
));
});
LinearLayout.LayoutParams createParams = matchWrap();
createParams.height = dp(46);
createParams.topMargin = dp(18);
sheet.addView(create, createParams);
showBottomSheet(dialog, sheet, name);
} }
private void promptDeleteKanbanProject() { private void promptDeleteKanbanProject() {
@@ -2969,7 +3114,7 @@ public final class MainActivity extends Activity {
setStatus("Probing Tailscale APIs..."); setStatus("Probing Tailscale APIs...");
executor.execute(() -> { executor.execute(() -> {
StringBuilder text = new StringBuilder(); StringBuilder text = new StringBuilder();
for (String url : SERVER_PROFILES) { for (String url : savedServerUrls()) {
text.append(url).append('\n'); text.append(url).append('\n');
try { try {
SessionApiClient client = new SessionApiClient(url); SessionApiClient client = new SessionApiClient(url);
@@ -3183,6 +3328,81 @@ public final class MainActivity extends Activity {
return form; return form;
} }
private TextView formLabel(String text) {
TextView label = new TextView(this);
label.setText(text);
label.setTextColor(COLOR_TEXT_DIM);
label.setTextSize(9);
label.setTypeface(Typeface.MONOSPACE);
return label;
}
private LinearLayout bottomSheetContent(Dialog dialog, String titleText, String subtitleText) {
LinearLayout sheet = new LinearLayout(this);
sheet.setOrientation(LinearLayout.VERTICAL);
sheet.setPadding(dp(18), dp(14), dp(18), dp(24));
sheet.setBackground(rounded(COLOR_CARD, 8, COLOR_BORDER, 1));
LinearLayout header = new LinearLayout(this);
header.setOrientation(LinearLayout.HORIZONTAL);
header.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout titleBlock = new LinearLayout(this);
titleBlock.setOrientation(LinearLayout.VERTICAL);
TextView title = new TextView(this);
title.setText(titleText);
title.setTextColor(COLOR_TEXT);
title.setTextSize(16);
title.setTypeface(Typeface.DEFAULT_BOLD);
TextView subtitle = bodyText(subtitleText);
subtitle.setTextSize(10);
subtitle.setPadding(0, dp(3), dp(8), 0);
titleBlock.addView(title, matchWrap());
titleBlock.addView(subtitle, matchWrap());
header.addView(titleBlock, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
Button close = terminalToolButton("×", view -> dialog.dismiss());
close.setContentDescription("Close " + titleText);
header.addView(close);
sheet.addView(header, spacedMatchWrap(0, 10));
return sheet;
}
private EditText sheetField(LinearLayout sheet, String labelText, String hint, String value) {
TextView label = formLabel(labelText);
label.setPadding(0, dp(10), 0, dp(6));
sheet.addView(label, matchWrap());
EditText input = new EditText(this);
input.setSingleLine(true);
input.setHint(hint);
input.setText(value);
input.setSelectAllOnFocus(!value.isEmpty());
input.setInputType(InputType.TYPE_CLASS_TEXT);
styleInput(input);
sheet.addView(input, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(44)
));
return input;
}
private void showBottomSheet(Dialog dialog, LinearLayout sheet, EditText focus) {
dialog.setContentView(sheet);
dialog.setCanceledOnTouchOutside(true);
dialog.show();
Window window = dialog.getWindow();
if (window != null) {
window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
WindowManager.LayoutParams attributes = window.getAttributes();
attributes.gravity = Gravity.BOTTOM;
attributes.dimAmount = 0.65f;
window.setAttributes(attributes);
window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
}
if (focus != null) {
focus.requestFocus();
}
}
private EditText formField(LinearLayout form, String label, String value) { private EditText formField(LinearLayout form, String label, String value) {
TextView title = new TextView(this); TextView title = new TextView(this);
title.setText(label); title.setText(label);
@@ -3913,6 +4133,23 @@ public final class MainActivity extends Activity {
); );
} }
private LinearLayout.LayoutParams spacedMatchWrap(int topDp, int bottomDp) {
LinearLayout.LayoutParams params = matchWrap();
params.topMargin = dp(topDp);
params.bottomMargin = dp(bottomDp);
return params;
}
private LinearLayout.LayoutParams spacedHeight(int heightDp, int topDp, int bottomDp) {
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(heightDp)
);
params.topMargin = dp(topDp);
params.bottomMargin = dp(bottomDp);
return params;
}
private void showMessage(String message) { private void showMessage(String message) {
if (message == null || message.trim().isEmpty()) { if (message == null || message.trim().isEmpty()) {
return; return;