From dd056c80276c52ef8b85d50d7b4d7217e9665272 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 6 Jul 2026 18:49:28 +0000 Subject: [PATCH] Add projects page and smooth terminal input --- README.md | 22 +- .../neatstudio/tmuxandroid/MainActivity.java | 305 +++++++++++++++++- .../tmuxandroid/TerminalSocketClient.java | 6 +- docs/android-client-notes.md | 13 +- 4 files changed, 317 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index a69c619..2ecbbfc 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,16 @@ APIs directly: session counts - native tmux session list - Sessions page with current API/server and loaded session count +- native Projects page for kanban project grouping, project agents, project + messages, add/remove session, create, and delete actions - create, rename, send command, split pane, select pane, kill pane, pin, mute, and kill session through HTTP API - open one live terminal viewer through `/ws/terminal` - native `/ws/events` listener for session invalidation and hook notifications - selectable GitHub or Gitea update manifest source -- native Tools page for health, server status, timeline, preferences, - kanban projects, group messages, hook events, image file/URL upload, image - preview info, and native image preview display +- native Tools page for health, server status, timeline, preferences, hook + events, image file/URL upload, image preview info, and native image preview + display - mobile soft-key row for tmux-oriented input, including tmux prefix, detach, new window, previous/next window, Ctrl keys, arrows, page keys, and paste - automatic update checks against the selected release manifest @@ -111,17 +113,21 @@ messages unchanged: `attach`, `input`, `resize`, `scroll`, and `clear-history`. The first Android UI renders terminal output as monospace text with basic ANSI SGR color support. The terminal view stays bottom-aligned when output is short, auto-scrolls as data arrives, and adjusts its bottom inset when the soft keyboard -opens. It is enough for shell-oriented remote testing, but it is not yet a -complete xterm-compatible renderer for full-screen TUIs such as `vim` or `top`. +opens. Rendering is throttled and the local terminal buffer is capped so opening +busy sessions does not block the UI thread. Input typed before the terminal +attach message is sent is queued and flushed after the WebSocket client is ready. +It is enough for shell-oriented remote testing, but it is not yet a complete +xterm-compatible renderer for full-screen TUIs such as `vim` or `top`. The terminal toolbar and shortcut row include tmux prefix helpers. The app sends the same control bytes a keyboard would send, for example `Ctrl+B`, `Ctrl+B d`, `Ctrl+B c`, `Ctrl+B n`, and `Ctrl+B p`. All app features are native Android controls. Complex server objects such as -kanban projects, preferences, timeline events, group messages, and image metadata -currently use native forms plus native JSON detail dialogs; image preview uses a -native `ImageView`. The app does not load the browser UI. +preferences, timeline events, group messages, and image metadata currently use +native forms plus native JSON detail dialogs; kanban projects use a native +project/agent list; image preview uses a native `ImageView`. The app does not +load the browser UI. ## Permissions diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java index c203da7..64cf862 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java @@ -41,6 +41,7 @@ import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; +import org.json.JSONArray; import org.json.JSONObject; import java.io.BufferedInputStream; @@ -56,10 +57,12 @@ public final class MainActivity extends Activity { private static final long AUTO_UPDATE_INTERVAL_MS = 6L * 60L * 60L * 1000L; private static final int TERMINAL_COLS = 96; private static final int TERMINAL_ROWS = 32; - private static final int MAX_TERMINAL_CHARS = 120_000; + private static final int MAX_TERMINAL_CHARS = 40_000; + private static final long TERMINAL_RENDER_INTERVAL_MS = 80L; private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000"; private static final String DEFAULT_TAILSCALE_URL = "http://100.89.0.116:3000"; private static final String PAGE_SESSIONS = "Sessions"; + private static final String PAGE_PROJECTS = "Projects"; private static final String PAGE_TOOLS = "Tools"; private static final String PAGE_UPDATE = "Update"; private static final String PAGE_ABOUT = "About"; @@ -82,6 +85,7 @@ public final class MainActivity extends Activity { private ProgressBar progressBar; private TextView statusText; private TextView sessionSummaryText; + private LinearLayout projectList; private TextView terminalText; private ScrollView terminalScroll; private EditText inputField; @@ -89,6 +93,10 @@ public final class MainActivity extends Activity { private String activeMainPage = PAGE_SESSIONS; private String pendingImageUploadSession; private final StringBuilder terminalBuffer = new StringBuilder(); + private final StringBuilder queuedTerminalInput = new StringBuilder(); + private boolean terminalConnected; + private boolean terminalRenderPending; + private long lastTerminalRenderMs; @Override protected void onCreate(Bundle savedInstanceState) { @@ -282,6 +290,59 @@ public final class MainActivity extends Activity { setStatus("Tools"); } + private void renderProjectsScreen() { + closeTerminalSocket(); + activeSessionName = null; + activeMainPage = PAGE_PROJECTS; + root.removeAllViews(); + root.addView(createServerBar(), matchWrap()); + root.addView(createMainTabs(PAGE_PROJECTS), new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(48) + )); + root.addView(progressBar, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(3) + )); + + ScrollView scroll = new ScrollView(this); + LinearLayout content = pageContent(); + content.addView(infoBlock( + "Project groups", + "Kanban projects group tmux sessions and group messages through the server API." + )); + content.addView(sectionTitle("Projects")); + content.addView(actionPanel( + actionButton("Refresh", view -> refreshProjects()), + actionButton("New project", view -> promptCreateKanbanProject()), + actionButton("Delete project", view -> promptDeleteKanbanProject()), + actionButton("Remove session", view -> promptRemoveKanbanSession()) + )); + projectList = new LinearLayout(this); + projectList.setOrientation(LinearLayout.VERTICAL); + content.addView(projectList, matchWrap()); + + content.addView(sectionTitle("Group messages")); + content.addView(actionPanel( + actionButton("Messages", view -> promptGroupMessages()), + actionButton("Send message", view -> promptSendGroupMessage()), + actionButton("Scan message", view -> promptScanGroupMessage()), + actionButton("Post hook", view -> promptPostHookEvent()) + )); + scroll.addView(content); + root.addView(scroll, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1 + )); + root.addView(statusText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(28) + )); + setStatus("Projects"); + refreshProjects(); + } + private void renderUpdateScreen() { closeTerminalSocket(); activeSessionName = null; @@ -435,6 +496,11 @@ public final class MainActivity extends Activity { actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles())); return; } + if (PAGE_PROJECTS.equals(activeMainPage)) { + actionRow.addView(toolbarButton("Refresh", view -> refreshProjects())); + actionRow.addView(toolbarButton("New", view -> promptCreateKanbanProject())); + return; + } if (PAGE_TOOLS.equals(activeMainPage)) { actionRow.addView(toolbarButton("Health", view -> showRaw("Health", () -> api.health()))); actionRow.addView(toolbarButton("Probe", view -> probeServerProfiles())); @@ -496,6 +562,7 @@ public final class MainActivity extends Activity { renderSessionScreen(); refreshSessions(); })); + row.addView(navButton(PAGE_PROJECTS, selected, view -> renderProjectsScreen())); row.addView(navButton(PAGE_TOOLS, selected, view -> renderToolsScreen())); row.addView(navButton(PAGE_UPDATE, selected, view -> renderUpdateScreen())); row.addView(navButton(PAGE_ABOUT, selected, view -> renderAboutScreen())); @@ -617,6 +684,8 @@ public final class MainActivity extends Activity { connectAppEvents(); if (PAGE_SESSIONS.equals(activeMainPage)) { refreshSessions(); + } else if (PAGE_PROJECTS.equals(activeMainPage)) { + refreshProjects(); } } @@ -640,6 +709,141 @@ public final class MainActivity extends Activity { }); } + private void refreshProjects() { + if (projectList != null) { + projectList.removeAllViews(); + projectList.addView(projectStateText("Loading projects..."), matchWrap()); + } + setStatus("Loading projects from " + api.getBaseUrl()); + progressBar.setVisibility(View.VISIBLE); + executor.execute(() -> { + try { + String text = api.kanbanProjects(); + runOnUiThread(() -> { + renderProjectList(text); + setStatus("Loaded projects"); + }); + } catch (Exception error) { + runOnUiThread(() -> { + if (projectList != null) { + projectList.removeAllViews(); + projectList.addView(projectStateText("Project load failed:\n" + error.getMessage()), matchWrap()); + } + showMessage("Project load failed: " + error.getMessage()); + }); + } finally { + runOnUiThread(() -> progressBar.setVisibility(View.GONE)); + } + }); + } + + private void renderProjectList(String text) { + if (projectList == null) { + return; + } + projectList.removeAllViews(); + try { + JSONObject rootObject = new JSONObject(text == null || text.isEmpty() ? "{}" : text); + JSONArray projects = rootObject.optJSONArray("projects"); + if (projects == null || projects.length() == 0) { + projectList.addView(projectStateText("No projects"), matchWrap()); + return; + } + for (int index = 0; index < projects.length(); index++) { + projectList.addView(projectCard(projects.getJSONObject(index)), matchWrap()); + } + } catch (Exception error) { + projectList.addView(projectStateText(text == null || text.isEmpty() ? "(empty)" : text), matchWrap()); + } + } + + private TextView projectStateText(String text) { + TextView view = bodyText(text); + view.setTypeface(Typeface.MONOSPACE); + view.setTextIsSelectable(true); + view.setPadding(dp(12), dp(10), dp(12), dp(10)); + view.setBackground(rounded(Color.rgb(12, 17, 23), 8, Color.rgb(42, 51, 61), 1)); + return view; + } + + private View projectCard(JSONObject project) { + String name = project.optString("name", "(unnamed)"); + String path = project.optString("path", ""); + String server = project.isNull("server") ? "" : project.optString("server", ""); + JSONArray agents = project.optJSONArray("agents"); + int agentCount = agents == null ? 0 : agents.length(); + + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.VERTICAL); + card.setPadding(dp(12), dp(11), dp(12), dp(11)); + card.setBackground(rounded(Color.rgb(27, 33, 40), 8, Color.rgb(45, 54, 64), 1)); + + TextView title = new TextView(this); + title.setText(name); + title.setTextColor(Color.WHITE); + title.setTextSize(17); + title.setTypeface(Typeface.DEFAULT_BOLD); + + String detail = "path:" + defaultValue(path, "~") + + (server.isEmpty() ? "" : " server:" + server) + + " agents:" + agentCount; + TextView meta = bodyText(detail); + meta.setPadding(0, dp(4), 0, dp(8)); + + LinearLayout actions = new LinearLayout(this); + actions.setOrientation(LinearLayout.HORIZONTAL); + actions.addView(toolbarButton("Messages", view -> showRaw("Group messages: " + name, () -> api.groupMessages(name)))); + actions.addView(toolbarButton("Add", view -> promptAddKanbanSessionToProject(name))); + actions.addView(toolbarButton("Delete", view -> runApiAction("Delete kanban project", () -> api.deleteKanbanProject(name)))); + + card.addView(title); + card.addView(meta); + card.addView(actions); + + if (agents != null && agents.length() > 0) { + TextView agentsTitle = bodyText("Agents"); + agentsTitle.setTextColor(Color.rgb(139, 148, 158)); + agentsTitle.setTypeface(Typeface.DEFAULT_BOLD); + agentsTitle.setPadding(0, dp(10), 0, dp(4)); + card.addView(agentsTitle); + for (int index = 0; index < agents.length(); index++) { + JSONObject agent = agents.optJSONObject(index); + if (agent != null) { + card.addView(projectAgentRow(name, agent), matchWrap()); + } + } + } + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + params.bottomMargin = dp(8); + card.setLayoutParams(params); + return card; + } + + private View projectAgentRow(String projectName, JSONObject agent) { + String sessionName = defaultValue(agent.optString("sessionName", ""), agent.optString("name", "")); + String label = defaultValue(agent.optString("name", ""), sessionName); + String kind = agent.optString("kind", "session"); + + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(0, dp(3), 0, dp(3)); + + TextView name = bodyText(kind + " " + label); + name.setSingleLine(true); + row.addView(name, new LinearLayout.LayoutParams(0, dp(38), 1)); + if (!sessionName.isEmpty()) { + row.addView(toolbarButton("Open", view -> openTerminal(sessionName))); + row.addView(toolbarButton("Remove", view -> runApiAction("Remove kanban session", () -> + api.removeKanbanSession(projectName, sessionName, false)))); + } + return row; + } + private void renderSessionList(List sessions) { LinearLayout list = root.findViewWithTag("session-list"); if (list == null) { @@ -753,6 +957,10 @@ public final class MainActivity extends Activity { private void openTerminal(String sessionName) { activeSessionName = sessionName; terminalBuffer.setLength(0); + queuedTerminalInput.setLength(0); + terminalConnected = false; + terminalRenderPending = false; + lastTerminalRenderMs = 0L; root.removeAllViews(); root.addView(createTerminalTopBar(sessionName), matchWrap()); @@ -1023,6 +1231,10 @@ public final class MainActivity extends Activity { promptText("Add to kanban project", "project", "", projectName -> api.addKanbanSession(projectName, sessionName)); } + private void promptAddKanbanSessionToProject(String projectName) { + promptText("Add session to " + projectName, "session", "", sessionName -> api.addKanbanSession(projectName, sessionName)); + } + private void promptRemoveKanbanSession() { LinearLayout form = formRoot(); EditText project = formField(form, "Project", ""); @@ -1405,7 +1617,9 @@ public final class MainActivity extends Activity { runOnUiThread(() -> { progressBar.setVisibility(View.GONE); showMessage(label + " done"); - if (activeSessionName == null) { + if (PAGE_PROJECTS.equals(activeMainPage)) { + refreshProjects(); + } else if (activeSessionName == null) { refreshSessions(); } }); @@ -1525,12 +1739,18 @@ public final class MainActivity extends Activity { private void connectTerminal(String sessionName) { closeTerminalSocket(); + queuedTerminalInput.setLength(0); + terminalConnected = false; setStatus("Connecting " + sessionName); appendTerminal("[connecting]\r\n"); terminalSocket = new TerminalSocketClient(new TerminalSocketClient.Listener() { @Override public void onConnected() { - runOnUiThread(() -> setStatus("Connected " + sessionName)); + runOnUiThread(() -> { + terminalConnected = true; + setStatus("Connected " + sessionName); + flushQueuedTerminalInput(); + }); } @Override @@ -1548,7 +1768,10 @@ public final class MainActivity extends Activity { @Override public void onClosed() { - runOnUiThread(() -> setStatus("Disconnected " + sessionName)); + runOnUiThread(() -> { + terminalConnected = false; + setStatus("Disconnected " + sessionName); + }); } }); terminalSocket.connect(api.getBaseUrl(), sessionName, TERMINAL_COLS, TERMINAL_ROWS); @@ -1572,10 +1795,15 @@ public final class MainActivity extends Activity { return; } TerminalSocketClient socket = terminalSocket; - if (socket != null) { + if (socket != null && !socket.isClosed() && terminalConnected) { socket.sendInput(data); return; } + if (socket != null && !socket.isClosed()) { + queuedTerminalInput.append(data); + setStatus("Queued input until terminal connects"); + return; + } executor.execute(() -> { try { for (int i = 0; i < data.length(); i += 200) { @@ -1587,6 +1815,15 @@ public final class MainActivity extends Activity { }); } + private void flushQueuedTerminalInput() { + if (!terminalConnected || terminalSocket == null || queuedTerminalInput.length() == 0) { + return; + } + String data = queuedTerminalInput.toString(); + queuedTerminalInput.setLength(0); + terminalSocket.sendInput(data); + } + private void pasteClipboard() { ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); if (clipboard == null || !clipboard.hasPrimaryClip()) { @@ -1607,6 +1844,27 @@ public final class MainActivity extends Activity { if (terminalBuffer.length() > MAX_TERMINAL_CHARS) { terminalBuffer.delete(0, terminalBuffer.length() - MAX_TERMINAL_CHARS); } + scheduleTerminalRender(); + } + + private void scheduleTerminalRender() { + if (terminalRenderPending || terminalText == null) { + return; + } + terminalRenderPending = true; + long now = System.currentTimeMillis(); + long delay = Math.max(0L, TERMINAL_RENDER_INTERVAL_MS - (now - lastTerminalRenderMs)); + terminalText.postDelayed(() -> { + terminalRenderPending = false; + renderTerminalNow(); + }, delay); + } + + private void renderTerminalNow() { + if (terminalText == null || terminalScroll == null) { + return; + } + lastTerminalRenderMs = System.currentTimeMillis(); terminalText.setText(renderAnsiForTerminal(terminalBuffer.toString())); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); } @@ -1639,21 +1897,35 @@ public final class MainActivity extends Activity { continue; } - int start = output.length(); - output.append(item); - int finish = output.length(); - output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - if (bg != Color.TRANSPARENT) { - output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + int runStart = index; + while (index < text.length()) { + char runItem = text.charAt(index); + if (runItem == '\r' || (runItem == '\u001b' && index + 1 < text.length() && text.charAt(index + 1) == '[')) { + break; + } + index++; } - if (bold) { - output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - index++; + appendTerminalRun(output, text.substring(runStart, index), fg, bg, bold); } return output; } + private void appendTerminalRun(SpannableStringBuilder output, String text, int fg, int bg, boolean bold) { + if (text.isEmpty()) { + return; + } + int start = output.length(); + output.append(text); + int finish = output.length(); + output.setSpan(new ForegroundColorSpan(fg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (bg != Color.TRANSPARENT) { + output.setSpan(new BackgroundColorSpan(bg), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (bold) { + output.setSpan(new StyleSpan(Typeface.BOLD), start, finish, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + private int findAnsiEnd(String text, int start) { for (int index = start; index < text.length(); index++) { char item = text.charAt(index); @@ -1885,6 +2157,9 @@ public final class MainActivity extends Activity { } private void closeTerminalSocket() { + terminalConnected = false; + queuedTerminalInput.setLength(0); + terminalRenderPending = false; if (terminalSocket != null) { terminalSocket.close(); terminalSocket = null; diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java index cc2b05b..b5216b8 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java @@ -71,6 +71,10 @@ final class TerminalSocketClient { } } + boolean isClosed() { + return closed; + } + private void run(String baseUrl, String sessionName, int cols, int rows) { try { URI uri = buildWsUri(baseUrl); @@ -78,7 +82,6 @@ final class TerminalSocketClient { input = new BufferedInputStream(socket.getInputStream()); output = new BufferedOutputStream(socket.getOutputStream()); handshake(uri); - listener.onConnected(); sendMessage( "attach", "tabId", "android-" + System.currentTimeMillis(), @@ -86,6 +89,7 @@ final class TerminalSocketClient { "cols", cols, "rows", rows ); + listener.onConnected(); readLoop(); } catch (Exception error) { if (!closed) { diff --git a/docs/android-client-notes.md b/docs/android-client-notes.md index 14f0572..c706055 100644 --- a/docs/android-client-notes.md +++ b/docs/android-client-notes.md @@ -70,7 +70,8 @@ Implemented now: - configurable base URL, defaulting to `http://100.89.0.116:3000` - support for Tailscale URLs such as `http://100.x.y.z:3000` -- native multi-page shell with `Sessions`, `Tools`, `Update`, and `About` +- native multi-page shell with `Sessions`, `Projects`, `Tools`, `Update`, and + `About` - native session list from `GET /api/sessions` - create, rename, command send, split, pane select, pane kill, pin, mute, and kill session through documented session/preference endpoints @@ -79,9 +80,11 @@ Implemented now: - bottom shortcut bar for `Esc`, `Tab`, `Ctrl+C`, `Ctrl+V`, arrows, page keys, tmux prefix actions, and paste - shortcut delivery through the terminal WebSocket `input` message -- native Tools page for health, server status, timeline, preferences, kanban - projects, group messages, hook events, image file/URL upload, image preview - metadata, and native image preview display +- native Projects page for kanban project grouping, project agents, project + messages, add/remove session, create, and delete actions +- native Tools page for health, server status, timeline, preferences, hook + events, image file/URL upload, image preview metadata, and native image + preview display - GitHub Actions APK build - release manifest `latest.json` - selected-source update checks; GitHub and Gitea are not probed in the same @@ -121,6 +124,6 @@ is a useful feature or test batch for phone-side validation. To converge with the upstream mobile design, the next implementation should add native Android modules in this order: -1. richer native layouts for kanban, group messages, timeline, and preferences +1. richer native layouts for group messages, timeline, and preferences 2. `TerminalCore` with ANSI parsing, cursor state, colors, and dirty rows 3. configurable shortcut bar backed directly by WebSocket `input`