Compare commits

..

1 Commits
v0.2.0 ... main

Author SHA1 Message Date
Codex
dd056c8027 Add projects page and smooth terminal input
All checks were successful
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m19s
2026-07-06 18:49:28 +00:00
4 changed files with 317 additions and 29 deletions

View File

@ -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

View File

@ -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<SessionSummary> 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;

View File

@ -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) {

View File

@ -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`