Compare commits

...
12 Commits
Author SHA1 Message Date
Codex 41e50322be Refine chat and terminal presentation
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 12m12s
2026-07-12 10:12:09 +00:00
Codex f1db46ecbe Replace heuristic reading with chat toggle
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m3s
2026-07-12 09:45:38 +00:00
Codex c0904e6aaa Show live terminal status in chat
Gitea Smoke / smoke (push) Successful in 1s
Gitea Android APK / build (push) Successful in 12m9s
2026-07-12 09:03:14 +00:00
Codex 1d9492aff5 Add structured session chat view
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m24s
2026-07-12 07:24:58 +00:00
Codex 9416fa6518 Show version in terminal header
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m25s
2026-07-12 02:26:32 +00:00
Codex 360e2f3af0 Top align terminal reading mode
Gitea Android APK / build (push) Failing after 57s
Gitea Smoke / smoke (push) Successful in 0s
2026-07-12 02:19:06 +00:00
Codex 0119c7cb0e Add expandable terminal reading summaries
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m54s
2026-07-12 02:14:25 +00:00
Codex daf2990b15 Clear stale terminal frames on resize
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m24s
2026-07-12 02:00:55 +00:00
Codex 96fe5ecb8f Improve mobile session reading and pane layout
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Has been cancelled
2026-07-12 01:59:55 +00:00
Codex f103e9e065 Render ANSI dim terminal text
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m47s
2026-07-11 08:41:33 +00:00
Codex 6a1052d437 Insert uploaded image paths
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 11m50s
2026-07-11 08:11:46 +00:00
Codex 7e028b09d4 Reduce terminal key overflow
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Failing after 8m17s
2026-07-11 08:01:21 +00:00
3 changed files with 858 additions and 70 deletions
@@ -0,0 +1,85 @@
package com.neatstudio.tmuxandroid;
import org.json.JSONObject;
final class ConversationMessage {
final String messageId;
final String sessionName;
final String role;
final String contentType;
final String content;
final String status;
final String toolName;
final String parentMessageId;
final String createdAt;
final boolean local;
ConversationMessage(
String messageId,
String sessionName,
String role,
String contentType,
String content,
String status,
String toolName,
String parentMessageId,
String createdAt,
boolean local
) {
this.messageId = messageId;
this.sessionName = sessionName;
this.role = role;
this.contentType = contentType;
this.content = content;
this.status = status;
this.toolName = toolName;
this.parentMessageId = parentMessageId;
this.createdAt = createdAt;
this.local = local;
}
static ConversationMessage fromJson(JSONObject object) {
return new ConversationMessage(
value(object, "messageId", object.optString("id", "")),
object.optString("sessionName", ""),
object.optString("role", "assistant"),
object.optString("contentType", "text"),
object.optString("content", ""),
object.optString("status", "complete"),
object.optString("toolName", ""),
value(object, "parentMessageId", ""),
object.optString("createdAt", ""),
false
);
}
static ConversationMessage localUser(String sessionName, String content) {
long now = System.currentTimeMillis();
return new ConversationMessage(
"local-" + now,
sessionName,
"user",
"text",
content,
"sending",
"",
"",
"~" + now,
true
);
}
boolean isTool() {
return "tool".equals(role)
|| "tool".equals(contentType)
|| "command".equals(contentType)
|| "code".equals(contentType);
}
private static String value(JSONObject object, String key, String fallback) {
if (!object.has(key) || object.isNull(key)) {
return fallback;
}
return object.optString(key, fallback);
}
}
@@ -71,6 +71,8 @@ public final class MainActivity extends Activity {
private static final int STATUS_BUSY = 1; private static final int STATUS_BUSY = 1;
private static final int STATUS_SUCCESS = 2; private static final int STATUS_SUCCESS = 2;
private static final int STATUS_ERROR = 3; private static final int STATUS_ERROR = 3;
private static final int TERMINAL_VIEW_CHAT = 0;
private static final int TERMINAL_VIEW_FULL = 1;
private static final long TERMINAL_RENDER_INTERVAL_MS = 80L; private static final long TERMINAL_RENDER_INTERVAL_MS = 80L;
private static final long[] SOCKET_RECONNECT_DELAYS_MS = {1000L, 2000L, 4000L, 8000L, 15000L}; private static final long[] SOCKET_RECONNECT_DELAYS_MS = {1000L, 2000L, 4000L, 8000L, 15000L};
private static final int COLOR_APP_BG = Color.rgb(18, 20, 24); private static final int COLOR_APP_BG = Color.rgb(18, 20, 24);
@@ -124,11 +126,22 @@ public final class MainActivity extends Activity {
private LinearLayout sessionGroupList; private LinearLayout sessionGroupList;
private TextView terminalText; private TextView terminalText;
private TextView terminalMetaText; private TextView terminalMetaText;
private TextView terminalConnectionText;
private Button terminalReadingButton;
private Button terminalFullButton;
private ScrollView terminalScroll; private ScrollView terminalScroll;
private LinearLayout terminalChatList;
private TextView terminalLiveStatusText;
private TextView terminalLiveOutputText;
private EditText inputField; private EditText inputField;
private final Button[] terminalAccessoryTabButtons = new Button[4]; private final Button[] terminalAccessoryTabButtons = new Button[4];
private View terminalAccessoryBar; private View terminalAccessoryBar;
private View terminalComposerBar; private View terminalComposerBar;
private View terminalComposerTabs;
private LinearLayout terminalImagePreviewBar;
private ImageView terminalImagePreview;
private TextView terminalImagePreviewPath;
private String terminalImagePath = "";
private LinearLayout terminalGroupRow; private LinearLayout terminalGroupRow;
private String activeSessionName; private String activeSessionName;
private String terminalPathStatus = ""; private String terminalPathStatus = "";
@@ -140,11 +153,14 @@ public final class MainActivity extends Activity {
private int lastActiveSessionCount = -1; private int lastActiveSessionCount = -1;
private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS); private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS);
private final StringBuilder queuedTerminalInput = new StringBuilder(); private final StringBuilder queuedTerminalInput = new StringBuilder();
private final Set<String> terminalExpandedMessages = new HashSet<>();
private final List<ConversationMessage> terminalConversationMessages = new ArrayList<>();
private boolean terminalConnected; private boolean terminalConnected;
private boolean terminalConnecting; private boolean terminalConnecting;
private boolean terminalRenderPending; private boolean terminalRenderPending;
private boolean terminalSelectionEnabled; private boolean terminalSelectionEnabled;
private boolean terminalFollowOutput = true; private boolean terminalFollowOutput = true;
private int terminalViewMode = TERMINAL_VIEW_CHAT;
private int terminalKeyPage; private int terminalKeyPage;
private int terminalReconnectAttempt; private int terminalReconnectAttempt;
private int terminalConnectionGeneration; private int terminalConnectionGeneration;
@@ -1596,6 +1612,14 @@ public final class MainActivity extends Activity {
terminalSelectionEnabled = false; terminalSelectionEnabled = false;
terminalFollowOutput = true; terminalFollowOutput = true;
terminalKeyPage = 0; terminalKeyPage = 0;
terminalExpandedMessages.clear();
terminalConversationMessages.clear();
terminalLiveStatusText = null;
terminalLiveOutputText = null;
terminalImagePath = "";
terminalImagePreviewBar = null;
terminalImagePreview = null;
terminalImagePreviewPath = null;
terminalPathStatus = "path loading"; terminalPathStatus = "path loading";
terminalSocketStatus = "terminal idle"; terminalSocketStatus = "terminal idle";
terminalEventStatus = "events listening"; terminalEventStatus = "events listening";
@@ -1614,18 +1638,20 @@ public final class MainActivity extends Activity {
terminalScroll.setBackgroundColor(COLOR_TERMINAL_BG); terminalScroll.setBackgroundColor(COLOR_TERMINAL_BG);
terminalText = new TextView(this); terminalText = new TextView(this);
terminalText.setTextColor(COLOR_TEXT); terminalText.setTextColor(COLOR_TEXT);
terminalText.setTextSize(13); terminalText.setTextSize(11);
terminalText.setTypeface(Typeface.MONOSPACE); terminalText.setTypeface(Typeface.MONOSPACE);
terminalText.setIncludeFontPadding(false); terminalText.setIncludeFontPadding(false);
terminalText.setHorizontallyScrolling(true);
terminalText.setLineSpacing(0, 1.05f); terminalText.setLineSpacing(0, 1.05f);
terminalText.setGravity(Gravity.BOTTOM | Gravity.START); terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalText.setTextIsSelectable(terminalSelectionEnabled); terminalText.setTextIsSelectable(terminalSelectionEnabled);
terminalText.setPadding(dp(2), dp(8), dp(2), dp(8)); terminalText.setPadding(dp(2), dp(8), dp(2), dp(8));
terminalText.setBackgroundColor(COLOR_TERMINAL_BG); terminalText.setBackgroundColor(COLOR_TERMINAL_BG);
terminalScroll.addView(terminalText, new ScrollView.LayoutParams( terminalChatList = new LinearLayout(this);
ViewGroup.LayoutParams.MATCH_PARENT, terminalChatList.setOrientation(LinearLayout.VERTICAL);
ViewGroup.LayoutParams.WRAP_CONTENT terminalChatList.setPadding(dp(10), dp(12), dp(10), dp(18));
)); terminalChatList.addView(projectStateText("Loading conversation..."), matchWrap());
showTerminalView(terminalViewMode, false);
terminalScroll.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> terminalScroll.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
resizeTerminalToViewport(false)); resizeTerminalToViewport(false));
terminalScroll.setOnScrollChangeListener((view, scrollX, scrollY, oldScrollX, oldScrollY) -> terminalScroll.setOnScrollChangeListener((view, scrollX, scrollY, oldScrollX, oldScrollY) ->
@@ -1636,6 +1662,7 @@ public final class MainActivity extends Activity {
1 1
)); ));
terminalAccessoryBar = createAccessoryBar(); terminalAccessoryBar = createAccessoryBar();
terminalAccessoryBar.setVisibility(terminalViewMode == TERMINAL_VIEW_CHAT ? View.GONE : View.VISIBLE);
root.addView(terminalAccessoryBar, new LinearLayout.LayoutParams( root.addView(terminalAccessoryBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
dp(TERMINAL_KEYS_HEIGHT_DP) dp(TERMINAL_KEYS_HEIGHT_DP)
@@ -1647,6 +1674,7 @@ public final class MainActivity extends Activity {
connectTerminal(sessionName); connectTerminal(sessionName);
refreshTerminalGroupSessions(sessionName); refreshTerminalGroupSessions(sessionName);
refreshTerminalSessionMeta(sessionName); refreshTerminalSessionMeta(sessionName);
refreshTerminalConversation(sessionName);
}); });
inputField.post(() -> { inputField.post(() -> {
inputField.requestFocus(); inputField.requestFocus();
@@ -1667,6 +1695,10 @@ public final class MainActivity extends Activity {
titleBlock.setGravity(Gravity.CENTER_VERTICAL); titleBlock.setGravity(Gravity.CENTER_VERTICAL);
titleBlock.setPadding(dp(9), 0, dp(7), 0); titleBlock.setPadding(dp(9), 0, dp(7), 0);
LinearLayout titleRow = new LinearLayout(this);
titleRow.setOrientation(LinearLayout.HORIZONTAL);
titleRow.setGravity(Gravity.CENTER_VERTICAL);
TextView title = new TextView(this); TextView title = new TextView(this);
title.setText(sessionName); title.setText(sessionName);
title.setTextColor(COLOR_TEXT); title.setTextColor(COLOR_TEXT);
@@ -1675,6 +1707,22 @@ public final class MainActivity extends Activity {
title.setSingleLine(true); title.setSingleLine(true);
title.setEllipsize(TextUtils.TruncateAt.END); title.setEllipsize(TextUtils.TruncateAt.END);
title.setIncludeFontPadding(false); title.setIncludeFontPadding(false);
titleRow.addView(title, new LinearLayout.LayoutParams(
0,
dp(17),
1
));
terminalConnectionText = new TextView(this);
terminalConnectionText.setTextSize(9);
terminalConnectionText.setTypeface(Typeface.MONOSPACE);
terminalConnectionText.setSingleLine(true);
terminalConnectionText.setIncludeFontPadding(false);
terminalConnectionText.setPadding(dp(7), 0, 0, 0);
titleRow.addView(terminalConnectionText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(17)
));
terminalMetaText = new TextView(this); terminalMetaText = new TextView(this);
terminalMetaText.setTextColor(COLOR_TEXT_DIM); terminalMetaText.setTextColor(COLOR_TEXT_DIM);
@@ -1685,19 +1733,101 @@ public final class MainActivity extends Activity {
terminalMetaText.setIncludeFontPadding(false); terminalMetaText.setIncludeFontPadding(false);
updateTerminalMeta(); updateTerminalMeta();
titleBlock.addView(title, new LinearLayout.LayoutParams( TextView version = new TextView(this);
version.setText("v" + BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")");
version.setTextColor(COLOR_TEXT_DIM);
version.setTextSize(8);
version.setTypeface(Typeface.MONOSPACE);
version.setSingleLine(true);
version.setIncludeFontPadding(false);
LinearLayout metaRow = new LinearLayout(this);
metaRow.setOrientation(LinearLayout.HORIZONTAL);
metaRow.setGravity(Gravity.CENTER_VERTICAL);
metaRow.addView(terminalMetaText, new LinearLayout.LayoutParams(
0,
dp(13),
1
));
LinearLayout.LayoutParams versionParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(13)
);
versionParams.leftMargin = dp(6);
metaRow.addView(version, versionParams);
titleBlock.addView(titleRow, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
dp(17) dp(17)
)); ));
titleBlock.addView(terminalMetaText, new LinearLayout.LayoutParams( titleBlock.addView(metaRow, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
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;
} }
private void showTerminalView(int mode, boolean announce) {
terminalViewMode = mode;
if (terminalScroll == null || terminalText == null || terminalChatList == null) {
styleTerminalReadingButton();
return;
}
terminalScroll.removeAllViews();
if (mode == TERMINAL_VIEW_CHAT) {
terminalScroll.addView(terminalChatList, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
renderTerminalConversation();
} else {
terminalText.setHorizontallyScrolling(true);
terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalText.setMovementMethod(null);
terminalScroll.addView(terminalText, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
renderTerminalNow();
}
styleTerminalReadingButton();
boolean chat = mode == TERMINAL_VIEW_CHAT;
if (terminalAccessoryBar != null) {
terminalAccessoryBar.setVisibility(chat ? View.GONE : View.VISIBLE);
}
if (terminalComposerTabs != null) {
terminalComposerTabs.setVisibility(chat ? View.GONE : View.VISIBLE);
}
if (inputField != null) {
inputField.setHint(chat ? "Message" : "type command or text");
}
if (announce) {
setStatus(mode == TERMINAL_VIEW_CHAT ? "Chat view" : "Full terminal mode");
}
}
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);
@@ -1737,18 +1867,13 @@ public final class MainActivity extends Activity {
try { try {
List<SessionSummary> sessions = api.getSessions(); List<SessionSummary> sessions = api.getSessions();
String path = ""; String path = "";
String command = "";
for (SessionSummary session : sessions) { for (SessionSummary session : sessions) {
if (sessionName.equals(session.name)) { if (sessionName.equals(session.name)) {
path = defaultValue(session.currentPath, ""); path = defaultValue(session.currentPath, "");
command = defaultValue(session.currentCommand, "");
break; break;
} }
} }
String meta = path.isEmpty() ? "path unavailable" : path; String meta = path.isEmpty() ? "path unavailable" : compactTerminalPath(path);
if (!command.isEmpty()) {
meta = meta + " · " + command;
}
String finalMeta = meta; String finalMeta = meta;
runOnUiThread(() -> { runOnUiThread(() -> {
if (sessionName.equals(activeSessionName)) { if (sessionName.equals(activeSessionName)) {
@@ -1767,19 +1892,266 @@ public final class MainActivity extends Activity {
}); });
} }
private void updateTerminalMeta() { private void refreshTerminalConversation(String sessionName) {
if (terminalMetaText == null) { executor.execute(() -> {
try {
JSONObject rootObject = new JSONObject(api.timeline(200));
JSONArray events = rootObject.optJSONArray("events");
List<ConversationMessage> loaded = new ArrayList<>();
for (int index = 0; events != null && index < events.length(); index++) {
JSONObject event = events.optJSONObject(index);
if (event != null
&& "conversation-message".equals(event.optString("type"))
&& sessionName.equals(event.optString("sessionName"))) {
loaded.add(ConversationMessage.fromJson(event));
}
}
Collections.sort(loaded, (left, right) -> left.createdAt.compareTo(right.createdAt));
runOnUiThread(() -> {
if (!sessionName.equals(activeSessionName)) {
return; return;
} }
StringBuilder meta = new StringBuilder(); terminalConversationMessages.clear();
meta.append(defaultValue(terminalPathStatus, "path loading")); terminalConversationMessages.addAll(loaded);
if (!terminalSocketStatus.isEmpty()) { renderTerminalConversation();
meta.append(" · ").append(terminalSocketStatus); });
} catch (Exception error) {
runOnUiThread(() -> {
if (sessionName.equals(activeSessionName) && terminalChatList != null) {
terminalChatList.removeAllViews();
terminalChatList.addView(projectStateText(
"Conversation unavailable\n" + error.getMessage()), matchWrap());
} }
if (!terminalEventStatus.isEmpty()) { });
meta.append(" · ").append(terminalEventStatus);
} }
terminalMetaText.setText(meta.toString()); });
}
private void upsertConversationMessage(ConversationMessage message) {
if (message.sessionName.isEmpty() || !message.sessionName.equals(activeSessionName)) {
return;
}
if (!message.local && "user".equals(message.role)) {
for (int index = terminalConversationMessages.size() - 1; index >= 0; index--) {
ConversationMessage existing = terminalConversationMessages.get(index);
if (existing.local && existing.content.equals(message.content)) {
terminalConversationMessages.remove(index);
break;
}
}
}
for (int index = 0; index < terminalConversationMessages.size(); index++) {
if (terminalConversationMessages.get(index).messageId.equals(message.messageId)) {
terminalConversationMessages.set(index, message);
renderTerminalConversation();
return;
}
}
terminalConversationMessages.add(message);
Collections.sort(terminalConversationMessages,
(left, right) -> left.createdAt.compareTo(right.createdAt));
renderTerminalConversation();
}
private void renderTerminalConversation() {
if (terminalChatList == null) {
return;
}
terminalChatList.removeAllViews();
if (terminalConversationMessages.isEmpty()) {
TextView empty = bodyText("No structured messages yet");
empty.setGravity(Gravity.CENTER);
empty.setPadding(dp(12), dp(24), dp(12), dp(24));
terminalChatList.addView(empty, matchWrap());
} else {
for (ConversationMessage message : terminalConversationMessages) {
terminalChatList.addView(conversationMessageRow(message));
}
}
terminalChatList.addView(conversationLiveTerminalPanel(), matchWrap());
updateTerminalLivePanel();
if (terminalViewMode == TERMINAL_VIEW_CHAT && terminalFollowOutput && terminalScroll != null) {
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
}
}
private View conversationMessageRow(ConversationMessage message) {
boolean user = "user".equals(message.role);
boolean tool = message.isTool();
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(user ? Gravity.END : Gravity.START);
LinearLayout bubble = new LinearLayout(this);
bubble.setOrientation(LinearLayout.VERTICAL);
bubble.setPadding(user || tool ? dp(12) : dp(2), dp(9), user || tool ? dp(12) : dp(4), dp(10));
bubble.setBackground(tool
? rounded(COLOR_PANEL, 8, COLOR_BORDER, 1)
: user
? rounded(COLOR_ACCENT_DARK, 8, COLOR_ACCENT, 1)
: rounded(Color.TRANSPARENT, 0, Color.TRANSPARENT, 0));
TextView meta = new TextView(this);
meta.setTextColor(user ? COLOR_ACCENT : tool ? COLOR_ACCENT_WARM : COLOR_TEXT_MUTED);
meta.setTextSize(9);
meta.setTypeface(Typeface.MONOSPACE, Typeface.BOLD);
meta.setSingleLine(true);
meta.setEllipsize(TextUtils.TruncateAt.END);
meta.setText(tool ? conversationToolTitle(message) : conversationStatusPart(message).replaceFirst("^ · ", ""));
if (tool || (!"complete".equals(message.status) && !message.status.isEmpty())) {
bubble.addView(meta, matchWrap());
}
boolean expanded = terminalExpandedMessages.contains(message.messageId);
if (!tool || expanded) {
bubble.addView(conversationContent(message, tool), matchWrap());
} else {
TextView collapsed = bodyText("Tap to show " + message.content.length() + " characters");
collapsed.setTextSize(10);
collapsed.setPadding(0, dp(5), 0, 0);
bubble.addView(collapsed, matchWrap());
}
if (tool) {
bubble.setOnClickListener(view -> {
if (!terminalExpandedMessages.add(message.messageId)) {
terminalExpandedMessages.remove(message.messageId);
}
renderTerminalConversation();
});
}
LinearLayout.LayoutParams bubbleParams = new LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
tool ? 1f : user ? 0.82f : 0.94f
);
if (!tool) {
View spacer = new View(this);
LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(
0,
1,
user ? 0.18f : 0.06f
);
if (user) {
row.addView(spacer, spacerParams);
row.addView(bubble, bubbleParams);
} else {
row.addView(bubble, bubbleParams);
row.addView(spacer, spacerParams);
}
} else {
row.addView(bubble, bubbleParams);
}
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
rowParams.bottomMargin = dp(9);
row.setLayoutParams(rowParams);
return row;
}
private TextView conversationContent(ConversationMessage message, boolean tool) {
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.setTextIsSelectable(true);
content.setLineSpacing(dp(2), 1f);
content.setPadding(0, dp(6), 0, 0);
return content;
}
private String conversationToolTitle(ConversationMessage message) {
String name = defaultValue(message.toolName, message.contentType);
return defaultValue(name, "Tool output") + conversationStatusPart(message);
}
private String conversationStatusPart(ConversationMessage message) {
String status = message.status.trim();
return status.isEmpty() ? "" : " · " + status;
}
private View conversationLiveTerminalPanel() {
LinearLayout panel = new LinearLayout(this);
panel.setOrientation(LinearLayout.VERTICAL);
panel.setPadding(dp(12), dp(9), dp(12), dp(10));
panel.setBackground(rounded(COLOR_FIELD, 8, COLOR_BORDER, 1));
panel.setOnClickListener(view -> showTerminalView(TERMINAL_VIEW_FULL, true));
LinearLayout heading = new LinearLayout(this);
heading.setOrientation(LinearLayout.HORIZONTAL);
heading.setGravity(Gravity.CENTER_VERTICAL);
TextView title = new TextView(this);
title.setText("Live terminal");
title.setTextColor(COLOR_TEXT);
title.setTextSize(11);
title.setTypeface(Typeface.DEFAULT_BOLD);
heading.addView(title, new LinearLayout.LayoutParams(0, dp(22), 1));
terminalLiveStatusText = new TextView(this);
terminalLiveStatusText.setTextSize(9);
terminalLiveStatusText.setTypeface(Typeface.MONOSPACE);
terminalLiveStatusText.setSingleLine(true);
heading.addView(terminalLiveStatusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(22)
));
panel.addView(heading, matchWrap());
terminalLiveOutputText = new TextView(this);
terminalLiveOutputText.setTextColor(COLOR_TEXT_MUTED);
terminalLiveOutputText.setTextSize(10);
terminalLiveOutputText.setTypeface(Typeface.MONOSPACE);
terminalLiveOutputText.setMaxLines(6);
terminalLiveOutputText.setEllipsize(TextUtils.TruncateAt.END);
terminalLiveOutputText.setPadding(0, dp(5), 0, 0);
panel.addView(terminalLiveOutputText, matchWrap());
TextView hint = bodyText("Tap for full terminal");
hint.setTextSize(9);
hint.setTextColor(COLOR_TEXT_DIM);
hint.setPadding(0, dp(6), 0, 0);
panel.addView(hint, matchWrap());
return panel;
}
private void updateTerminalLivePanel() {
if (terminalLiveStatusText == null || terminalLiveOutputText == null) {
return;
}
String socket = terminalSocketStatus.toLowerCase(java.util.Locale.ROOT);
boolean connected = socket.contains("connected") && !socket.contains("disconnected");
terminalLiveStatusText.setText(connected ? "● connected" : "" + defaultValue(socket, "connecting"));
terminalLiveStatusText.setTextColor(connected ? COLOR_SUCCESS
: socket.contains("error") || socket.contains("disconnected") ? COLOR_DANGER : COLOR_ACCENT_WARM);
terminalLiveOutputText.setText(terminalScreen.renderTail(6));
}
private void updateTerminalMeta() {
if (terminalMetaText == null || terminalConnectionText == null) {
return;
}
terminalMetaText.setText(defaultValue(terminalPathStatus, "path loading"));
String socket = terminalSocketStatus.toLowerCase(java.util.Locale.ROOT);
if (socket.contains("connected") && !socket.contains("disconnected")) {
terminalConnectionText.setText("● connected");
terminalConnectionText.setTextColor(COLOR_SUCCESS);
} else if (socket.contains("error") || socket.contains("disconnected")) {
terminalConnectionText.setText("● disconnected");
terminalConnectionText.setTextColor(COLOR_DANGER);
} else {
terminalConnectionText.setText("● connecting");
terminalConnectionText.setTextColor(COLOR_ACCENT_WARM);
}
updateTerminalLivePanel();
}
private String compactTerminalPath(String path) {
String compact = path.trim();
compact = compact.replaceFirst("^/home/[^/]+(?=/|$)", "~");
compact = compact.replaceFirst("^/Users/[^/]+(?=/|$)", "~");
return compact;
} }
private void renderTerminalGroupSessions(String sessionName, String text) { private void renderTerminalGroupSessions(String sessionName, String text) {
@@ -1992,6 +2364,7 @@ public final class MainActivity extends Activity {
"Clear local view and tmux history", "Clear local view and tmux history",
"Split horizontal", "Split horizontal",
"Split vertical", "Split vertical",
"Zoom active pane",
"Page up", "Page up",
"Page down", "Page down",
"Session status", "Session status",
@@ -2023,34 +2396,37 @@ public final class MainActivity extends Activity {
runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical")); runApiAction("Split vertical", () -> api.splitPane(sessionName, "vertical"));
break; break;
case 4: case 4:
sendTerminalInput("\u0002z");
break;
case 5:
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.scroll(-terminalRows); terminalSocket.scroll(-terminalRows);
} }
break; break;
case 5: case 6:
if (terminalSocket != null) { if (terminalSocket != null) {
terminalSocket.scroll(terminalRows); terminalSocket.scroll(terminalRows);
} }
break; break;
case 6: case 7:
showRaw("Session status", () -> api.sessionStatus(sessionName)); showRaw("Session status", () -> api.sessionStatus(sessionName));
break; break;
case 7: case 8:
promptSendCommand(sessionName); promptSendCommand(sessionName);
break; break;
case 8: case 9:
sendTerminalInput("\u0002"); sendTerminalInput("\u0002");
break; break;
case 9: case 10:
sendTerminalInput("\u0002d"); sendTerminalInput("\u0002d");
break; break;
case 10: case 11:
sendTerminalInput("\u0002c"); sendTerminalInput("\u0002c");
break; break;
case 11: case 12:
sendTerminalInput("\u0002n"); sendTerminalInput("\u0002n");
break; break;
case 12: case 13:
sendTerminalInput("\u0002p"); sendTerminalInput("\u0002p");
break; break;
default: default:
@@ -2066,12 +2442,20 @@ public final class MainActivity extends Activity {
bar.setPadding(dp(8), dp(5), dp(8), dp(7)); bar.setPadding(dp(8), dp(5), dp(8), dp(7));
bar.setBackgroundColor(COLOR_BAR); bar.setBackgroundColor(COLOR_BAR);
terminalImagePreviewBar = createComposerImagePreviewBar();
bar.addView(terminalImagePreviewBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(64)
));
LinearLayout row = new LinearLayout(this); LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL); row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL); row.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout tabGrid = new LinearLayout(this); LinearLayout tabGrid = new LinearLayout(this);
tabGrid.setOrientation(LinearLayout.VERTICAL); tabGrid.setOrientation(LinearLayout.VERTICAL);
terminalComposerTabs = tabGrid;
tabGrid.setVisibility(terminalViewMode == TERMINAL_VIEW_CHAT ? View.GONE : View.VISIBLE);
LinearLayout firstTabRow = terminalKeyRow(); LinearLayout firstTabRow = terminalKeyRow();
LinearLayout secondTabRow = terminalKeyRow(); LinearLayout secondTabRow = terminalKeyRow();
addAccessoryTab(firstTabRow, "Edit", 0); addAccessoryTab(firstTabRow, "Edit", 0);
@@ -2098,7 +2482,7 @@ public final class MainActivity extends Activity {
inputField = new EditText(this); inputField = new EditText(this);
inputField.setTextColor(COLOR_TEXT); inputField.setTextColor(COLOR_TEXT);
inputField.setHintTextColor(COLOR_TEXT_DIM); inputField.setHintTextColor(COLOR_TEXT_DIM);
inputField.setHint("type command or text"); inputField.setHint(terminalViewMode == TERMINAL_VIEW_CHAT ? "Message" : "type command or text");
inputField.setSingleLine(false); inputField.setSingleLine(false);
inputField.setMinLines(3); inputField.setMinLines(3);
inputField.setMaxLines(5); inputField.setMaxLines(5);
@@ -2161,6 +2545,87 @@ public final class MainActivity extends Activity {
return bar; return bar;
} }
private LinearLayout createComposerImagePreviewBar() {
LinearLayout previewBar = new LinearLayout(this);
previewBar.setOrientation(LinearLayout.HORIZONTAL);
previewBar.setGravity(Gravity.CENTER_VERTICAL);
previewBar.setPadding(dp(4), dp(3), dp(2), dp(7));
previewBar.setVisibility(View.GONE);
terminalImagePreview = new ImageView(this);
terminalImagePreview.setScaleType(ImageView.ScaleType.CENTER_CROP);
terminalImagePreview.setAdjustViewBounds(false);
terminalImagePreview.setBackground(rounded(COLOR_FIELD, 6, COLOR_BORDER, 1));
terminalImagePreview.setContentDescription("Open uploaded image preview");
terminalImagePreview.setOnClickListener(view -> {
if (!terminalImagePath.isEmpty()) {
showImagePreview(terminalImagePath, "");
}
});
previewBar.addView(terminalImagePreview, new LinearLayout.LayoutParams(dp(52), dp(52)));
terminalImagePreviewPath = bodyText("Loading image preview...");
terminalImagePreviewPath.setSingleLine(true);
terminalImagePreviewPath.setEllipsize(TextUtils.TruncateAt.MIDDLE);
terminalImagePreviewPath.setTextSize(10);
LinearLayout.LayoutParams pathParams = new LinearLayout.LayoutParams(0, dp(52), 1);
pathParams.leftMargin = dp(9);
previewBar.addView(terminalImagePreviewPath, pathParams);
Button clear = terminalToolButton("×", view -> clearComposerImagePreview());
clear.setContentDescription("Clear image preview");
previewBar.addView(clear);
return previewBar;
}
private void loadComposerImagePreview(String path) {
if (terminalImagePreviewBar == null || path == null || path.isEmpty()) {
return;
}
terminalImagePath = path;
terminalImagePreview.setImageDrawable(null);
terminalImagePreviewPath.setText("Loading " + path);
terminalImagePreviewBar.setVisibility(View.VISIBLE);
int generation = terminalConnectionGeneration;
ImageView preview = terminalImagePreview;
TextView previewPath = terminalImagePreviewPath;
executor.execute(() -> {
try {
byte[] bytes = api.imagePreview(path, "");
Bitmap bitmap = decodePreviewBitmap(bytes, 1200);
if (bitmap == null) {
throw new IllegalStateException("Preview is not a supported bitmap");
}
runOnUiThread(() -> {
if (generation == terminalConnectionGeneration
&& path.equals(terminalImagePath)
&& preview == terminalImagePreview) {
preview.setImageBitmap(bitmap);
previewPath.setText(path);
}
});
} catch (Exception error) {
runOnUiThread(() -> {
if (generation == terminalConnectionGeneration
&& path.equals(terminalImagePath)
&& previewPath == terminalImagePreviewPath) {
previewPath.setText("Preview unavailable " + path);
}
});
}
});
}
private void clearComposerImagePreview() {
terminalImagePath = "";
if (terminalImagePreview != null) {
terminalImagePreview.setImageDrawable(null);
}
if (terminalImagePreviewBar != null) {
terminalImagePreviewBar.setVisibility(View.GONE);
}
}
private void promptRenameSession(String sessionName) { private void promptRenameSession(String sessionName) {
promptText("Rename session", sessionName, sessionName, nextName -> { promptText("Rename session", sessionName, sessionName, nextName -> {
if (!nextName.matches("[A-Za-z0-9._-]+")) { if (!nextName.matches("[A-Za-z0-9._-]+")) {
@@ -2589,7 +3054,7 @@ public final class MainActivity extends Activity {
executor.execute(() -> { executor.execute(() -> {
try { try {
byte[] bytes = api.imagePreview(path, basePath); byte[] bytes = api.imagePreview(path, basePath);
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); Bitmap bitmap = decodePreviewBitmap(bytes, 1800);
if (bitmap == null) { if (bitmap == null) {
throw new IllegalStateException("Preview is not a supported bitmap"); throw new IllegalStateException("Preview is not a supported bitmap");
} }
@@ -2614,6 +3079,23 @@ public final class MainActivity extends Activity {
}); });
} }
private Bitmap decodePreviewBitmap(byte[] bytes, int maxDimension) {
BitmapFactory.Options bounds = new BitmapFactory.Options();
bounds.inJustDecodeBounds = true;
BitmapFactory.decodeByteArray(bytes, 0, bytes.length, bounds);
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
return null;
}
int sampleSize = 1;
while (bounds.outWidth / sampleSize > maxDimension
|| bounds.outHeight / sampleSize > maxDimension) {
sampleSize *= 2;
}
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = sampleSize;
return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
}
private void promptText(String title, String hint, String value, TextApiAction action) { private void promptText(String title, String hint, String value, TextApiAction action) {
EditText input = new EditText(this); EditText input = new EditText(this);
input.setSingleLine(true); input.setSingleLine(true);
@@ -2722,7 +3204,7 @@ public final class MainActivity extends Activity {
private LinearLayout createAccessoryBar() { private LinearLayout createAccessoryBar() {
LinearLayout panel = new LinearLayout(this); LinearLayout panel = new LinearLayout(this);
panel.setOrientation(LinearLayout.VERTICAL); panel.setOrientation(LinearLayout.VERTICAL);
panel.setPadding(dp(6), dp(3), dp(6), dp(3)); panel.setPadding(dp(4), dp(3), dp(4), dp(3));
panel.setBackgroundColor(COLOR_PANEL); panel.setBackgroundColor(COLOR_PANEL);
HorizontalScrollView keyScroller = new HorizontalScrollView(this); HorizontalScrollView keyScroller = new HorizontalScrollView(this);
@@ -2773,8 +3255,6 @@ public final class MainActivity extends Activity {
addSoftKey(row, "End", "\u001b[F"); addSoftKey(row, "End", "\u001b[F");
addSoftKey(row, "Pg↑", "\u001b[5~"); addSoftKey(row, "Pg↑", "\u001b[5~");
addSoftKey(row, "Pg↓", "\u001b[6~"); addSoftKey(row, "Pg↓", "\u001b[6~");
addComposerButton(row, "Prev", () -> setTerminalKeyPage(terminalKeyPage - 1));
addComposerButton(row, "Next", () -> setTerminalKeyPage(terminalKeyPage + 1));
addSoftKey(row, "Clear", "\u000c"); addSoftKey(row, "Clear", "\u000c");
addSoftKey(row, "Detach", "\u0002d"); addSoftKey(row, "Detach", "\u0002d");
break; break;
@@ -2800,12 +3280,12 @@ public final class MainActivity extends Activity {
addSoftKey(row, "", "\u001b[B"); addSoftKey(row, "", "\u001b[B");
addSoftKey(row, "Esc", "\u001b"); addSoftKey(row, "Esc", "\u001b");
addSoftKey(row, "Tab", "\t"); addSoftKey(row, "Tab", "\t");
addSoftKey(row, "Enter", TERMINAL_ENTER); addSoftKey(row, "", TERMINAL_ENTER);
addSoftButton(row, "Paste", view -> pasteClipboard()); addSoftButton(row, "Paste", view -> pasteClipboard());
addComposerButton(row, "", this::backspaceComposerText); addComposerButton(row, "", this::backspaceComposerText);
addTextKey(row, "NL", "\n"); addTextKey(row, "NL", "\n");
addComposerButton(row, "Bottom", this::scrollTerminalBottom); addComposerButton(row, "Bot", this::scrollTerminalBottom);
addComposerButton(row, "Select", () -> { addComposerButton(row, "Sel", () -> {
terminalSelectionEnabled = !terminalSelectionEnabled; terminalSelectionEnabled = !terminalSelectionEnabled;
if (terminalText != null) { if (terminalText != null) {
terminalText.setTextIsSelectable(terminalSelectionEnabled); terminalText.setTextIsSelectable(terminalSelectionEnabled);
@@ -2908,7 +3388,7 @@ public final class MainActivity extends Activity {
} }
int horizontalPadding = terminalText.getPaddingLeft() + terminalText.getPaddingRight(); int horizontalPadding = terminalText.getPaddingLeft() + terminalText.getPaddingRight();
int verticalPadding = terminalText.getPaddingTop() + terminalText.getPaddingBottom(); int verticalPadding = terminalText.getPaddingTop() + terminalText.getPaddingBottom();
float charWidth = terminalText.getPaint().measureText("W"); float charWidth = terminalText.getPaint().measureText("0000000000") / 10f;
if (charWidth <= 0f) { if (charWidth <= 0f) {
charWidth = dp(8); charWidth = dp(8);
} }
@@ -2946,6 +3426,9 @@ public final class MainActivity extends Activity {
normalized = normalized + TERMINAL_ENTER; normalized = normalized + TERMINAL_ENTER;
} }
terminalFollowOutput = true; terminalFollowOutput = true;
if (activeSessionName != null) {
upsertConversationMessage(ConversationMessage.localUser(activeSessionName, text));
}
sendTerminalInput(normalized); sendTerminalInput(normalized);
inputField.setText(""); inputField.setText("");
setStatus("Sent " + text.length() + " chars"); setStatus("Sent " + text.length() + " chars");
@@ -3113,7 +3596,7 @@ public final class MainActivity extends Activity {
terminalRenderPending = true; terminalRenderPending = true;
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
long delay = Math.max(0L, TERMINAL_RENDER_INTERVAL_MS - (now - lastTerminalRenderMs)); long delay = Math.max(0L, TERMINAL_RENDER_INTERVAL_MS - (now - lastTerminalRenderMs));
terminalText.postDelayed(() -> { mainHandler.postDelayed(() -> {
terminalRenderPending = false; terminalRenderPending = false;
renderTerminalNow(); renderTerminalNow();
}, delay); }, delay);
@@ -3124,8 +3607,10 @@ public final class MainActivity extends Activity {
return; return;
} }
lastTerminalRenderMs = System.currentTimeMillis(); lastTerminalRenderMs = System.currentTimeMillis();
terminalText.setMovementMethod(null);
terminalText.setText(terminalScreen.render()); terminalText.setText(terminalScreen.render());
if (terminalFollowOutput) { updateTerminalLivePanel();
if (terminalViewMode != TERMINAL_VIEW_CHAT && terminalFollowOutput) {
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
} }
} }
@@ -3339,9 +3824,9 @@ public final class MainActivity extends Activity {
private void addSoftButton(LinearLayout row, String label, View.OnClickListener listener) { private void addSoftButton(LinearLayout row, String label, View.OnClickListener listener) {
Button button = toolbarButton(label, listener); Button button = toolbarButton(label, listener);
button.setTextSize(isArrowLabel(label) ? 14 : 9); button.setTextSize(isArrowLabel(label) ? 14 : 9);
button.setPadding(dp(5), 0, dp(5), 0); button.setPadding(dp(2), 0, dp(2), 0);
button.setMinWidth(dp(38)); button.setMinWidth(dp(24));
button.setMinimumWidth(dp(38)); button.setMinimumWidth(dp(24));
button.setMinHeight(0); button.setMinHeight(0);
button.setMinimumHeight(0); button.setMinimumHeight(0);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
@@ -3349,7 +3834,6 @@ public final class MainActivity extends Activity {
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
0 0
); );
params.leftMargin = dp(1);
params.rightMargin = dp(1); params.rightMargin = dp(1);
row.addView(button, params); row.addView(button, params);
} }
@@ -3662,6 +4146,12 @@ public final class MainActivity extends Activity {
} }
return; return;
} }
if ("conversation-message".equals(type)) {
upsertConversationMessage(ConversationMessage.fromJson(event));
terminalEventStatus = "message received";
updateTerminalMeta();
return;
}
if ("hook-event".equals(type)) { if ("hook-event".equals(type)) {
terminalEventStatus = "hook event"; terminalEventStatus = "hook event";
updateTerminalMeta(); updateTerminalMeta();
@@ -3698,9 +4188,15 @@ public final class MainActivity extends Activity {
throw new IllegalStateException("Cannot open selected image"); throw new IllegalStateException("Cannot open selected image");
} }
String response = api.uploadImage(sessionName, readAllBytes(input)); String response = api.uploadImage(sessionName, readAllBytes(input));
String imagePath = new JSONObject(response).optString("absolutePath", "").trim();
if (imagePath.isEmpty()) {
throw new IllegalStateException("Upload response did not include absolutePath");
}
runOnUiThread(() -> { runOnUiThread(() -> {
progressBar.setVisibility(View.GONE); progressBar.setVisibility(View.GONE);
showTextDialog("Image upload", response); insertComposerPath(imagePath);
loadComposerImagePreview(imagePath);
setStatus("Image path inserted");
}); });
} catch (Exception error) { } catch (Exception error) {
runOnUiThread(() -> { runOnUiThread(() -> {
@@ -3722,6 +4218,21 @@ public final class MainActivity extends Activity {
return output.toByteArray(); return output.toByteArray();
} }
private void insertComposerPath(String path) {
if (inputField == null) {
return;
}
int start = Math.max(0, inputField.getSelectionStart());
int end = Math.max(0, inputField.getSelectionEnd());
int from = Math.min(start, end);
int to = Math.max(start, end);
CharSequence current = inputField.getText();
String prefix = from > 0 && !Character.isWhitespace(current.charAt(from - 1)) ? " " : "";
String suffix = to < current.length() && Character.isWhitespace(current.charAt(to)) ? "" : " ";
inputField.getText().replace(from, to, prefix + path + suffix);
inputField.requestFocus();
}
@Override @Override
public void onBackPressed() { public void onBackPressed() {
if (activeSessionName != null) { if (activeSessionName != null) {
@@ -4,16 +4,22 @@ import android.graphics.Color;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextPaint;
import android.text.style.BackgroundColorSpan; import android.text.style.BackgroundColorSpan;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan; import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan; import android.text.style.StyleSpan;
import android.view.View;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Set;
final class TerminalScreenBuffer { final class TerminalScreenBuffer {
private static final int DEFAULT_FG = 0xffe6ebf2; private static final int DEFAULT_FG = 0xffe6ebf2;
private static final int DEFAULT_BG = Color.TRANSPARENT; private static final int DEFAULT_BG = Color.TRANSPARENT;
private static final int TERMINAL_BG = 0xff0b0e13;
private int cols; private int cols;
private int rows; private int rows;
@@ -27,6 +33,11 @@ final class TerminalScreenBuffer {
private int fg = DEFAULT_FG; private int fg = DEFAULT_FG;
private int bg = DEFAULT_BG; private int bg = DEFAULT_BG;
private boolean bold; private boolean bold;
private boolean dim;
interface FocusToggle {
void toggle(String key);
}
TerminalScreenBuffer(int cols, int rows) { TerminalScreenBuffer(int cols, int rows) {
resize(cols, rows); resize(cols, rows);
@@ -35,9 +46,6 @@ final class TerminalScreenBuffer {
void resize(int nextCols, int nextRows) { void resize(int nextCols, int nextRows) {
nextCols = Math.max(1, nextCols); nextCols = Math.max(1, nextCols);
nextRows = Math.max(1, nextRows); nextRows = Math.max(1, nextRows);
Cell[][] previous = cells;
int previousRows = rows;
int previousCols = cols;
cols = nextCols; cols = nextCols;
rows = nextRows; rows = nextRows;
cells = new Cell[rows][cols]; cells = new Cell[rows][cols];
@@ -46,21 +54,11 @@ final class TerminalScreenBuffer {
cells[row][col] = new Cell(); cells[row][col] = new Cell();
} }
} }
if (previous != null) { cursorRow = 0;
int copyRows = Math.min(previousRows, rows); cursorCol = 0;
int copyCols = Math.min(previousCols, cols); savedRow = 0;
int previousStart = Math.max(0, previousRows - copyRows); savedCol = 0;
int nextStart = Math.max(0, rows - copyRows); wrapPending = false;
for (int row = 0; row < copyRows; row++) {
for (int col = 0; col < copyCols; col++) {
cells[nextStart + row][col].copyFrom(previous[previousStart + row][col]);
}
}
}
cursorRow = clamp(cursorRow, 0, rows - 1);
cursorCol = clamp(cursorCol, 0, cols - 1);
savedRow = clamp(savedRow, 0, rows - 1);
savedCol = clamp(savedCol, 0, cols - 1);
} }
void clear() { void clear() {
@@ -74,6 +72,7 @@ final class TerminalScreenBuffer {
fg = DEFAULT_FG; fg = DEFAULT_FG;
bg = DEFAULT_BG; bg = DEFAULT_BG;
bold = false; bold = false;
dim = false;
} }
void write(String text) { void write(String text) {
@@ -131,6 +130,120 @@ final class TerminalScreenBuffer {
return output; return output;
} }
String renderTail(int maxRows) {
List<String> visible = new ArrayList<>();
for (int row = rows - 1; row >= 0 && visible.size() < maxRows; row--) {
String text = rowText(row).trim();
if (!text.isEmpty()) {
visible.add(0, text);
}
}
if (visible.isEmpty()) {
return "Waiting for terminal output";
}
return String.join("\n", visible);
}
CharSequence renderFocused(Set<String> expandedBlocks, FocusToggle toggle) {
SpannableStringBuilder output = new SpannableStringBuilder();
List<String> hiddenRows = new ArrayList<>();
int hiddenStart = -1;
for (int row = 0; row < rows; row++) {
String text = rowText(row).trim();
if (containsHan(text)) {
if (!hiddenRows.isEmpty()) {
appendFocusBlock(output, hiddenRows, hiddenStart, row - 1, expandedBlocks, toggle);
hiddenRows.clear();
hiddenStart = -1;
}
appendLine(output, text);
} else if (!text.isEmpty()) {
if (hiddenStart < 0) {
hiddenStart = row;
}
hiddenRows.add(text);
}
}
if (!hiddenRows.isEmpty()) {
appendFocusBlock(output, hiddenRows, hiddenStart, rows - 1, expandedBlocks, toggle);
}
if (output.length() == 0) {
output.append("暂无可读内容");
}
return output;
}
private void appendFocusBlock(
SpannableStringBuilder output,
List<String> lines,
int startRow,
int endRow,
Set<String> expandedBlocks,
FocusToggle toggle
) {
String key = startRow + ":" + endRow;
boolean expanded = expandedBlocks.contains(key);
String summary = focusSummary(lines);
int actionStart = output.length();
appendLine(output, (expanded ? "" : "") + summary);
int actionEnd = output.length();
output.setSpan(new ClickableSpan() {
@Override
public void onClick(View widget) {
toggle.toggle(key);
}
@Override
public void updateDrawState(TextPaint paint) {
paint.setColor(0xff67da91);
paint.setUnderlineText(false);
paint.setFakeBoldText(true);
}
}, actionStart, actionEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (expanded) {
for (String line : lines) {
appendLine(output, " " + line);
}
}
}
private String focusSummary(List<String> lines) {
String joined = String.join(" ", lines).toLowerCase(Locale.ROOT);
String type;
if (joined.contains("error") || joined.contains("failed") || joined.contains("exception")) {
type = "错误输出";
} else if (joined.contains("working") || joined.contains("running")
|| joined.contains("waiting") || joined.contains("interrupt")) {
type = "运行状态";
} else if (joined.contains("test") || joined.contains("build") || joined.contains("compile")
|| joined.contains("gradle") || joined.contains("webpack") || joined.contains("npm")) {
type = "构建/测试";
} else if (joined.contains("git ") || joined.contains("commit") || joined.contains("push")) {
type = "Git 操作";
} else if (lines.get(0).startsWith(">") || lines.get(0).startsWith("$")
|| lines.get(0).startsWith("!")) {
type = "命令与输出";
} else {
type = "终端细节";
}
return type + " · " + lines.size() + " 行 · " + compactSummary(lines.get(0), 42);
}
private String compactSummary(String text, int maxChars) {
String compact = text.replaceAll("\\s+", " ").trim();
if (compact.length() <= maxChars) {
return compact;
}
return compact.substring(0, maxChars - 1) + "";
}
private void appendLine(SpannableStringBuilder output, String text) {
if (output.length() > 0) {
output.append('\n');
}
output.append(text);
}
private int handleEscape(String text, int index) { private int handleEscape(String text, int index) {
if (index + 1 >= text.length()) { if (index + 1 >= text.length()) {
return -1; return -1;
@@ -257,10 +370,14 @@ final class TerminalScreenBuffer {
fg = DEFAULT_FG; fg = DEFAULT_FG;
bg = DEFAULT_BG; bg = DEFAULT_BG;
bold = false; bold = false;
dim = false;
} else if (value == 1) { } else if (value == 1) {
bold = true; bold = true;
} else if (value == 2) {
dim = true;
} else if (value == 22) { } else if (value == 22) {
bold = false; bold = false;
dim = false;
} else if (value == 39) { } else if (value == 39) {
fg = DEFAULT_FG; fg = DEFAULT_FG;
} else if (value == 49) { } else if (value == 49) {
@@ -302,14 +419,34 @@ final class TerminalScreenBuffer {
wrapPending = false; wrapPending = false;
newLine(); newLine();
} }
cells[cursorRow][cursorCol].set(value, fg, bg, bold); int width = isWideCharacter(value) ? 2 : 1;
if (cursorCol == cols - 1) { if (width == 2 && cursorCol == cols - 1) {
newLine();
}
cells[cursorRow][cursorCol].set(value, fg, bg, bold, dim);
if (width == 2) {
cells[cursorRow][cursorCol + 1].setContinuation(fg, bg, bold, dim);
}
if (cursorCol + width >= cols) {
cursorCol = cols - 1;
wrapPending = true; wrapPending = true;
} else { } else {
cursorCol++; cursorCol += width;
} }
} }
private boolean isWideCharacter(char value) {
return value >= '\u1100' && (value <= '\u115f'
|| value == '\u2329' || value == '\u232a'
|| (value >= '\u2e80' && value <= '\ua4cf' && value != '\u303f')
|| (value >= '\uac00' && value <= '\ud7a3')
|| (value >= '\uf900' && value <= '\ufaff')
|| (value >= '\ufe10' && value <= '\ufe19')
|| (value >= '\ufe30' && value <= '\ufe6f')
|| (value >= '\uff00' && value <= '\uff60')
|| (value >= '\uffe0' && value <= '\uffe6'));
}
private void newLine() { private void newLine() {
wrapPending = false; wrapPending = false;
cursorRow++; cursorRow++;
@@ -453,15 +590,21 @@ final class TerminalScreenBuffer {
int fgColor = first.fg; int fgColor = first.fg;
int bgColor = first.bg; int bgColor = first.bg;
boolean isBold = first.bold; boolean isBold = first.bold;
boolean isDim = first.dim;
while (col < cols) { while (col < cols) {
Cell cell = cells[row][col]; Cell cell = cells[row][col];
if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold) { if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold || cell.dim != isDim) {
break; break;
} }
if (!cell.continuation) {
output.append(cell.value); output.append(cell.value);
}
col++; col++;
} }
int end = output.length(); int end = output.length();
if (isDim) {
fgColor = blendColor(fgColor, bgColor == DEFAULT_BG ? TERMINAL_BG : bgColor, 0.55f);
}
output.setSpan(new ForegroundColorSpan(fgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); output.setSpan(new ForegroundColorSpan(fgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (bgColor != DEFAULT_BG) { if (bgColor != DEFAULT_BG) {
output.setSpan(new BackgroundColorSpan(bgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); output.setSpan(new BackgroundColorSpan(bgColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
@@ -472,6 +615,29 @@ final class TerminalScreenBuffer {
} }
} }
private String rowText(int row) {
StringBuilder text = new StringBuilder(cols);
for (int col = 0; col < cols; col++) {
Cell cell = cells[row][col];
if (!cell.continuation) {
text.append(cell.value);
}
}
return text.toString();
}
private boolean containsHan(String text) {
for (int index = 0; index < text.length(); index++) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(text.charAt(index));
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS) {
return true;
}
}
return false;
}
private void saveCursor() { private void saveCursor() {
savedRow = cursorRow; savedRow = cursorRow;
savedCol = cursorCol; savedCol = cursorCol;
@@ -613,6 +779,15 @@ final class TerminalScreenBuffer {
return value == 0 ? 0 : 55 + value * 40; return value == 0 ? 0 : 55 + value * 40;
} }
private static int blendColor(int foreground, int background, float foregroundRatio) {
float backgroundRatio = 1f - foregroundRatio;
return Color.rgb(
Math.round(Color.red(foreground) * foregroundRatio + Color.red(background) * backgroundRatio),
Math.round(Color.green(foreground) * foregroundRatio + Color.green(background) * backgroundRatio),
Math.round(Color.blue(foreground) * foregroundRatio + Color.blue(background) * backgroundRatio)
);
}
private static int clamp(int value, int min, int max) { private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
@@ -622,19 +797,34 @@ final class TerminalScreenBuffer {
int fg = DEFAULT_FG; int fg = DEFAULT_FG;
int bg = DEFAULT_BG; int bg = DEFAULT_BG;
boolean bold; boolean bold;
boolean dim;
boolean continuation;
void clear() { void clear() {
value = ' '; value = ' ';
fg = DEFAULT_FG; fg = DEFAULT_FG;
bg = DEFAULT_BG; bg = DEFAULT_BG;
bold = false; bold = false;
dim = false;
continuation = false;
} }
void set(char nextValue, int nextFg, int nextBg, boolean nextBold) { void set(char nextValue, int nextFg, int nextBg, boolean nextBold, boolean nextDim) {
value = nextValue; value = nextValue;
fg = nextFg; fg = nextFg;
bg = nextBg; bg = nextBg;
bold = nextBold; bold = nextBold;
dim = nextDim;
continuation = false;
}
void setContinuation(int nextFg, int nextBg, boolean nextBold, boolean nextDim) {
value = ' ';
fg = nextFg;
bg = nextBg;
bold = nextBold;
dim = nextDim;
continuation = true;
} }
void copyFrom(Cell other) { void copyFrom(Cell other) {
@@ -642,6 +832,8 @@ final class TerminalScreenBuffer {
fg = other.fg; fg = other.fg;
bg = other.bg; bg = other.bg;
bold = other.bold; bold = other.bold;
dim = other.dim;
continuation = other.continuation;
} }
} }
} }