Compare commits

...
3 Commits
Author SHA1 Message Date
Codex 0c7dba0ef1 Fix terminal composer and status layout
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m20s
2026-07-14 15:23:22 +00:00
Codex f1eb30cea0 Restore terminal history navigation
Gitea Smoke / smoke (push) Successful in 0s
Gitea Android APK / build (push) Successful in 12m20s
2026-07-14 14:28:04 +00:00
Codex a25a2d16b6 Make terminal content view selective
Gitea Smoke / smoke (push) Successful in 2s
Gitea Android APK / build (push) Failing after 54s
2026-07-14 05:22:38 +00:00
2 changed files with 274 additions and 196 deletions
@@ -25,6 +25,7 @@ import android.os.Looper;
import android.provider.Settings; import android.provider.Settings;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.InputType; import android.text.InputType;
import android.text.method.LinkMovementMethod;
import android.view.Gravity; import android.view.Gravity;
import android.view.HapticFeedbackConstants; import android.view.HapticFeedbackConstants;
import android.view.KeyEvent; import android.view.KeyEvent;
@@ -154,6 +155,7 @@ 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> terminalExpandedBlocks = new HashSet<>();
private final Set<String> terminalExpandedMessages = new HashSet<>(); private final Set<String> terminalExpandedMessages = new HashSet<>();
private final List<ConversationMessage> terminalConversationMessages = new ArrayList<>(); private final List<ConversationMessage> terminalConversationMessages = new ArrayList<>();
private boolean terminalConnected; private boolean terminalConnected;
@@ -163,6 +165,7 @@ public final class MainActivity extends Activity {
private boolean terminalFollowOutput = true; private boolean terminalFollowOutput = true;
private int terminalViewMode = TERMINAL_VIEW_CHAT; private int terminalViewMode = TERMINAL_VIEW_CHAT;
private int terminalKeyPage; private int terminalKeyPage;
private int terminalHistoryOffset;
private int terminalReconnectAttempt; private int terminalReconnectAttempt;
private int terminalConnectionGeneration; private int terminalConnectionGeneration;
private int eventReconnectAttempt; private int eventReconnectAttempt;
@@ -1696,6 +1699,8 @@ public final class MainActivity extends Activity {
terminalSelectionEnabled = false; terminalSelectionEnabled = false;
terminalFollowOutput = true; terminalFollowOutput = true;
terminalKeyPage = 0; terminalKeyPage = 0;
terminalHistoryOffset = 0;
terminalExpandedBlocks.clear();
terminalExpandedMessages.clear(); terminalExpandedMessages.clear();
terminalConversationMessages.clear(); terminalConversationMessages.clear();
terminalLiveStatusText = null; terminalLiveStatusText = null;
@@ -1733,7 +1738,7 @@ public final class MainActivity extends Activity {
terminalText.setBackgroundColor(COLOR_TERMINAL_BG); terminalText.setBackgroundColor(COLOR_TERMINAL_BG);
terminalChatList = new LinearLayout(this); terminalChatList = new LinearLayout(this);
terminalChatList.setOrientation(LinearLayout.VERTICAL); terminalChatList.setOrientation(LinearLayout.VERTICAL);
terminalChatList.setPadding(dp(10), dp(12), dp(10), dp(18)); terminalChatList.setPadding(0, dp(12), 0, dp(18));
terminalChatList.addView(projectStateText("Loading conversation..."), matchWrap()); terminalChatList.addView(projectStateText("Loading conversation..."), matchWrap());
showTerminalView(terminalViewMode, false); 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) ->
@@ -1746,7 +1751,6 @@ 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)
@@ -1762,6 +1766,8 @@ public final class MainActivity extends Activity {
}); });
inputField.post(() -> { inputField.post(() -> {
inputField.requestFocus(); inputField.requestFocus();
inputField.setSelection(inputField.length());
inputField.setCursorVisible(true);
hideKeyboard(); hideKeyboard();
}); });
} }
@@ -1867,26 +1873,25 @@ public final class MainActivity extends Activity {
renderTerminalConversation(); renderTerminalConversation();
} else { } else {
terminalText.setHorizontallyScrolling(true); terminalText.setHorizontallyScrolling(true);
terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalText.setMovementMethod(null); terminalText.setMovementMethod(null);
terminalText.setGravity(Gravity.BOTTOM | Gravity.START);
terminalScroll.addView(terminalText, new ScrollView.LayoutParams( terminalScroll.addView(terminalText, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
)); ));
renderTerminalNow(); renderTerminalNow();
} }
boolean chat = mode == TERMINAL_VIEW_CHAT;
if (terminalAccessoryBar != null) { if (terminalAccessoryBar != null) {
terminalAccessoryBar.setVisibility(chat ? View.GONE : View.VISIBLE); terminalAccessoryBar.setVisibility(View.VISIBLE);
} }
if (terminalComposerTabs != null) { if (terminalComposerTabs != null) {
terminalComposerTabs.setVisibility(chat ? View.GONE : View.VISIBLE); terminalComposerTabs.setVisibility(View.VISIBLE);
} }
if (inputField != null) { if (inputField != null) {
inputField.setHint(chat ? "Message" : "type command or text"); inputField.setHint(mode == TERMINAL_VIEW_CHAT ? "message or command" : "type command or text");
} }
if (announce) { if (announce) {
setStatus(mode == TERMINAL_VIEW_CHAT ? "Chat view" : "Full terminal mode"); setStatus(mode == TERMINAL_VIEW_CHAT ? "Content view" : "Raw terminal mode");
} }
} }
@@ -2050,124 +2055,74 @@ public final class MainActivity extends Activity {
private View conversationMessageRow(ConversationMessage message) { private View conversationMessageRow(ConversationMessage message) {
boolean user = "user".equals(message.role); boolean user = "user".equals(message.role);
boolean tool = message.isTool(); LinearLayout block = new LinearLayout(this);
LinearLayout row = new LinearLayout(this); block.setOrientation(LinearLayout.VERTICAL);
row.setOrientation(LinearLayout.HORIZONTAL); block.setPadding(dp(10), dp(5), dp(10), dp(7));
row.setGravity(user ? Gravity.END : Gravity.START);
LinearLayout bubble = new LinearLayout(this); TextView content = conversationContent(message, false);
bubble.setOrientation(LinearLayout.VERTICAL); content.setText((user ? " " : "") + defaultValue(message.content, "(empty)"));
bubble.setPadding(user || tool ? dp(12) : dp(2), dp(9), user || tool ? dp(12) : dp(4), dp(10)); content.setTextColor(user ? COLOR_ACCENT : COLOR_TEXT);
bubble.setBackground(tool content.setTextSize(12);
? rounded(COLOR_PANEL, 8, COLOR_BORDER, 1) content.setTypeface(Typeface.MONOSPACE, user ? Typeface.BOLD : Typeface.NORMAL);
: user content.setPadding(0, 0, 0, 0);
? rounded(COLOR_ACCENT_DARK, 8, COLOR_ACCENT, 1) block.addView(content, matchWrap());
: rounded(Color.TRANSPARENT, 0, Color.TRANSPARENT, 0));
TextView meta = new TextView(this); if (!"complete".equals(message.status) && !message.status.isEmpty()) {
meta.setTextColor(user ? COLOR_ACCENT : tool ? COLOR_ACCENT_WARM : COLOR_TEXT_MUTED); TextView status = bodyText(message.status);
meta.setTextSize(9); status.setTextColor(message.status.toLowerCase(java.util.Locale.ROOT).contains("error")
meta.setTypeface(Typeface.MONOSPACE, Typeface.BOLD); ? COLOR_DANGER : COLOR_TEXT_DIM);
meta.setSingleLine(true); status.setTextSize(9);
meta.setEllipsize(TextUtils.TruncateAt.END); status.setTypeface(Typeface.MONOSPACE);
meta.setText(tool ? conversationToolTitle(message) : conversationStatusPart(message).replaceFirst("^ · ", "")); status.setPadding(dp(14), dp(3), 0, 0);
if (tool || (!"complete".equals(message.status) && !message.status.isEmpty())) { block.addView(status, matchWrap());
bubble.addView(meta, matchWrap());
} }
boolean expanded = terminalExpandedMessages.contains(message.messageId); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
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();
});
}
if (!tool && !user) {
row.addView(bubble, matchWrap());
} else if (!tool) {
LinearLayout.LayoutParams bubbleParams = new LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
0.82f
);
View spacer = new View(this);
LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(
0,
1,
0.18f
);
row.addView(spacer, spacerParams);
row.addView(bubble, bubbleParams);
} else {
row.addView(bubble, matchWrap());
}
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
); );
rowParams.bottomMargin = dp(9); params.bottomMargin = dp(5);
row.setLayoutParams(rowParams); block.setLayoutParams(params);
return row; return block;
} }
private View conversationToolGroup(List<ConversationMessage> tools) { private View conversationToolGroup(List<ConversationMessage> tools) {
LinearLayout group = new LinearLayout(this); LinearLayout group = new LinearLayout(this);
group.setOrientation(LinearLayout.VERTICAL); group.setOrientation(LinearLayout.VERTICAL);
String firstId = tools.isEmpty() ? "empty" : tools.get(0).messageId;
String lastId = tools.isEmpty() ? "empty" : tools.get(tools.size() - 1).messageId;
String groupKey = "tools:" + firstId + ":" + lastId;
boolean expanded = terminalExpandedMessages.contains(groupKey);
String firstTitle = tools.isEmpty() ? "Tool activity" : conversationToolTitle(tools.get(0));
TextView summary = new TextView(this);
summary.setText((expanded ? "" : "") + tools.size() + " tool "
+ (tools.size() == 1 ? "activity" : "activities") + " · " + compactLabel(firstTitle, 44));
summary.setTextColor(COLOR_ACCENT_WARM);
summary.setTextSize(10);
summary.setTypeface(Typeface.MONOSPACE, Typeface.BOLD);
summary.setPadding(dp(10), dp(6), dp(10), dp(6));
summary.setOnClickListener(view -> {
if (!terminalExpandedMessages.add(groupKey)) {
terminalExpandedMessages.remove(groupKey);
}
renderTerminalConversation();
});
summary.setContentDescription((expanded ? "Collapse " : "Expand ") + tools.size() + " tool activities");
group.addView(summary, matchWrap());
HorizontalScrollView scroller = new HorizontalScrollView(this); if (expanded) {
scroller.setHorizontalScrollBarEnabled(false); for (ConversationMessage message : tools) {
LinearLayout chips = new LinearLayout(this); LinearLayout detail = new LinearLayout(this);
chips.setOrientation(LinearLayout.HORIZONTAL); detail.setOrientation(LinearLayout.VERTICAL);
chips.setGravity(Gravity.CENTER_VERTICAL); detail.setPadding(dp(18), dp(3), dp(10), dp(7));
for (ConversationMessage message : tools) { TextView title = bodyText(conversationToolTitle(message));
String label = compactLabel(conversationToolTitle(message), 18); title.setTextColor(COLOR_ACCENT_WARM);
Button chip = compactButton(label, view -> { title.setTextSize(9);
if (!terminalExpandedMessages.add(message.messageId)) { title.setTypeface(Typeface.MONOSPACE, Typeface.BOLD);
terminalExpandedMessages.remove(message.messageId); detail.addView(title, matchWrap());
} detail.addView(conversationContent(message, true), matchWrap());
renderTerminalConversation(); group.addView(detail, matchWrap());
});
chip.setTextSize(9);
chip.setContentDescription("Toggle " + label);
chips.addView(chip);
}
scroller.addView(chips, new HorizontalScrollView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(34)
));
group.addView(scroller, matchWrap());
for (ConversationMessage message : tools) {
if (!terminalExpandedMessages.contains(message.messageId)) {
continue;
} }
LinearLayout detail = new LinearLayout(this);
detail.setOrientation(LinearLayout.VERTICAL);
detail.setPadding(dp(10), dp(7), dp(10), dp(9));
detail.setBackground(rounded(COLOR_FIELD, 7, COLOR_BORDER_SOFT, 1));
TextView title = bodyText(conversationToolTitle(message));
title.setTextColor(COLOR_ACCENT_WARM);
title.setTextSize(9);
title.setTypeface(Typeface.MONOSPACE, Typeface.BOLD);
detail.addView(title, matchWrap());
detail.addView(conversationContent(message, true), matchWrap());
LinearLayout.LayoutParams detailParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
detailParams.topMargin = dp(5);
group.addView(detail, detailParams);
} }
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
@@ -2204,43 +2159,48 @@ public final class MainActivity extends Activity {
private View conversationLiveTerminalPanel() { private View conversationLiveTerminalPanel() {
LinearLayout panel = new LinearLayout(this); LinearLayout panel = new LinearLayout(this);
panel.setOrientation(LinearLayout.VERTICAL); panel.setOrientation(LinearLayout.VERTICAL);
panel.setPadding(dp(12), dp(9), dp(12), dp(10)); panel.setPadding(dp(2), dp(10), dp(2), dp(8));
panel.setBackground(rounded(COLOR_FIELD, 8, COLOR_BORDER, 1));
panel.setOnClickListener(view -> showTerminalView(TERMINAL_VIEW_FULL, true));
LinearLayout heading = new LinearLayout(this); LinearLayout heading = new LinearLayout(this);
heading.setOrientation(LinearLayout.HORIZONTAL); heading.setOrientation(LinearLayout.HORIZONTAL);
heading.setGravity(Gravity.CENTER_VERTICAL); heading.setGravity(Gravity.CENTER_VERTICAL);
TextView title = new TextView(this); TextView title = new TextView(this);
title.setText("Live terminal"); title.setText("LIVE TERMINAL");
title.setTextColor(COLOR_TEXT); title.setTextColor(COLOR_TEXT_DIM);
title.setTextSize(11); title.setTextSize(9);
title.setTypeface(Typeface.DEFAULT_BOLD); title.setTypeface(Typeface.MONOSPACE, Typeface.BOLD);
heading.addView(title, new LinearLayout.LayoutParams(0, dp(22), 1)); heading.addView(title, new LinearLayout.LayoutParams(0, dp(32), 1));
terminalLiveStatusText = new TextView(this); terminalLiveStatusText = new TextView(this);
terminalLiveStatusText.setTextSize(9); terminalLiveStatusText.setTextSize(9);
terminalLiveStatusText.setTypeface(Typeface.MONOSPACE); terminalLiveStatusText.setTypeface(Typeface.MONOSPACE);
terminalLiveStatusText.setSingleLine(true); terminalLiveStatusText.setSingleLine(true);
heading.addView(terminalLiveStatusText, new LinearLayout.LayoutParams( heading.addView(terminalLiveStatusText, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT,
dp(22) dp(32)
)); ));
Button older = terminalToolButton("", view -> scrollTerminalHistory(-terminalRows));
older.setContentDescription("Load older terminal history");
heading.addView(older);
Button newer = terminalToolButton("", view -> scrollTerminalHistory(terminalRows));
newer.setContentDescription("Return toward live terminal output");
heading.addView(newer);
panel.addView(heading, matchWrap()); panel.addView(heading, matchWrap());
terminalLiveOutputText = new TextView(this); terminalLiveOutputText = new TextView(this);
terminalLiveOutputText.setTextColor(COLOR_TEXT_MUTED); terminalLiveOutputText.setTextColor(COLOR_TEXT_MUTED);
terminalLiveOutputText.setTextSize(10); terminalLiveOutputText.setTextSize(11);
terminalLiveOutputText.setTypeface(Typeface.MONOSPACE); terminalLiveOutputText.setTypeface(Typeface.MONOSPACE);
terminalLiveOutputText.setMaxLines(6); terminalLiveOutputText.setIncludeFontPadding(false);
terminalLiveOutputText.setEllipsize(TextUtils.TruncateAt.END); terminalLiveOutputText.setHorizontallyScrolling(true);
terminalLiveOutputText.setLineSpacing(0, 1.05f);
terminalLiveOutputText.setBackgroundColor(COLOR_TERMINAL_BG);
terminalLiveOutputText.setPadding(0, dp(5), 0, 0); terminalLiveOutputText.setPadding(0, dp(5), 0, 0);
terminalLiveOutputText.setMovementMethod(LinkMovementMethod.getInstance());
terminalLiveOutputText.setHighlightColor(Color.TRANSPARENT);
terminalLiveOutputText.setTextIsSelectable(true);
terminalLiveOutputText.addOnLayoutChangeListener((view, left, top, right, bottom,
oldLeft, oldTop, oldRight, oldBottom) -> resizeTerminalToViewport(false));
panel.addView(terminalLiveOutputText, matchWrap()); 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; return panel;
} }
@@ -2250,10 +2210,19 @@ public final class MainActivity extends Activity {
} }
String socket = terminalSocketStatus.toLowerCase(java.util.Locale.ROOT); String socket = terminalSocketStatus.toLowerCase(java.util.Locale.ROOT);
boolean connected = socket.contains("connected") && !socket.contains("disconnected"); boolean connected = socket.contains("connected") && !socket.contains("disconnected");
terminalLiveStatusText.setText(connected ? "● connected" : "" + defaultValue(socket, "connecting")); terminalLiveStatusText.setText(terminalHistoryOffset > 0
terminalLiveStatusText.setTextColor(connected ? COLOR_SUCCESS ? "" + terminalHistoryOffset + " back"
: connected ? "● live" : "" + defaultValue(socket, "connecting"));
terminalLiveStatusText.setTextColor(terminalHistoryOffset > 0 ? COLOR_ACCENT_WARM : connected ? COLOR_SUCCESS
: socket.contains("error") || socket.contains("disconnected") ? COLOR_DANGER : COLOR_ACCENT_WARM); : socket.contains("error") || socket.contains("disconnected") ? COLOR_DANGER : COLOR_ACCENT_WARM);
terminalLiveOutputText.setText(terminalScreen.renderTail(6)); terminalLiveOutputText.setMovementMethod(LinkMovementMethod.getInstance());
terminalLiveOutputText.setHighlightColor(Color.TRANSPARENT);
terminalLiveOutputText.setText(terminalScreen.renderFocused(terminalExpandedBlocks, key -> {
if (!terminalExpandedBlocks.add(key)) {
terminalExpandedBlocks.remove(key);
}
renderTerminalNow();
}));
} }
private void updateTerminalMeta() { private void updateTerminalMeta() {
@@ -2532,14 +2501,10 @@ public final class MainActivity extends Activity {
sendTerminalInput("\u0002z"); sendTerminalInput("\u0002z");
break; break;
case 6: case 6:
if (terminalSocket != null) { scrollTerminalHistory(-terminalRows);
terminalSocket.scroll(-terminalRows);
}
break; break;
case 7: case 7:
if (terminalSocket != null) { scrollTerminalHistory(terminalRows);
terminalSocket.scroll(terminalRows);
}
break; break;
case 8: case 8:
showRaw("Session status", () -> api.sessionStatus(sessionName)); showRaw("Session status", () -> api.sessionStatus(sessionName));
@@ -2588,7 +2553,6 @@ public final class MainActivity extends Activity {
LinearLayout tabGrid = new LinearLayout(this); LinearLayout tabGrid = new LinearLayout(this);
tabGrid.setOrientation(LinearLayout.VERTICAL); tabGrid.setOrientation(LinearLayout.VERTICAL);
terminalComposerTabs = tabGrid; 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);
@@ -3548,6 +3512,8 @@ public final class MainActivity extends Activity {
terminalConnected = true; terminalConnected = true;
terminalConnecting = false; terminalConnecting = false;
terminalReconnectAttempt = 0; terminalReconnectAttempt = 0;
terminalHistoryOffset = 0;
terminalFollowOutput = true;
terminalSocketStatus = "terminal connected"; terminalSocketStatus = "terminal connected";
updateTerminalMeta(); updateTerminalMeta();
setStatus("Connected " + sessionName); setStatus("Connected " + sessionName);
@@ -3601,18 +3567,23 @@ public final class MainActivity extends Activity {
if (terminalText == null || terminalScroll == null) { if (terminalText == null || terminalScroll == null) {
return; return;
} }
int width = terminalScroll.getWidth(); TextView viewportText = terminalViewMode == TERMINAL_VIEW_CHAT && terminalLiveOutputText != null
? terminalLiveOutputText : terminalText;
int width = viewportText.getWidth();
if (width <= 0) {
width = terminalScroll.getWidth();
}
int height = terminalScroll.getHeight(); int height = terminalScroll.getHeight();
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
return; return;
} }
int horizontalPadding = terminalText.getPaddingLeft() + terminalText.getPaddingRight(); int horizontalPadding = viewportText.getPaddingLeft() + viewportText.getPaddingRight();
int verticalPadding = terminalText.getPaddingTop() + terminalText.getPaddingBottom(); int verticalPadding = viewportText.getPaddingTop() + viewportText.getPaddingBottom();
float charWidth = terminalText.getPaint().measureText("0000000000") / 10f; float charWidth = viewportText.getPaint().measureText("0000000000") / 10f;
if (charWidth <= 0f) { if (charWidth <= 0f) {
charWidth = dp(8); charWidth = dp(8);
} }
int lineHeight = terminalText.getLineHeight(); int lineHeight = viewportText.getLineHeight();
if (lineHeight <= 0) { if (lineHeight <= 0) {
lineHeight = dp(16); lineHeight = dp(16);
} }
@@ -3661,6 +3632,11 @@ public final class MainActivity extends Activity {
} }
TerminalSocketClient socket = terminalSocket; TerminalSocketClient socket = terminalSocket;
if (socket != null && !socket.isClosed() && terminalConnected) { if (socket != null && !socket.isClosed() && terminalConnected) {
if (terminalHistoryOffset > 0) {
socket.scroll(terminalHistoryOffset);
terminalHistoryOffset = 0;
terminalFollowOutput = true;
}
socket.sendInput(data); socket.sendInput(data);
setStatus("Sent input"); setStatus("Sent input");
return; return;
@@ -3776,6 +3752,9 @@ public final class MainActivity extends Activity {
} }
private void scrollTerminalBottom() { private void scrollTerminalBottom() {
if (terminalHistoryOffset > 0) {
scrollTerminalHistory(terminalHistoryOffset);
}
terminalFollowOutput = true; terminalFollowOutput = true;
if (terminalScroll != null) { if (terminalScroll != null) {
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
@@ -3783,6 +3762,31 @@ public final class MainActivity extends Activity {
setStatus("Following terminal output"); setStatus("Following terminal output");
} }
private void scrollTerminalHistory(int lines) {
TerminalSocketClient socket = terminalSocket;
if (socket == null || socket.isClosed() || !terminalConnected || lines == 0) {
setStatus("Terminal history unavailable while disconnected");
return;
}
if (lines < 0) {
terminalHistoryOffset += Math.abs(lines);
terminalFollowOutput = false;
} else {
if (terminalHistoryOffset == 0) {
setStatus("Live terminal output");
return;
}
int amount = Math.min(lines, terminalHistoryOffset);
terminalHistoryOffset = Math.max(0, terminalHistoryOffset - amount);
terminalFollowOutput = terminalHistoryOffset == 0;
lines = amount;
}
socket.scroll(lines);
setStatus(terminalHistoryOffset == 0
? "Live terminal output"
: "Terminal history · " + terminalHistoryOffset + " lines back");
}
private void setTerminalKeyPage(int page) { private void setTerminalKeyPage(int page) {
terminalKeyPage = (page + 4) % 4; terminalKeyPage = (page + 4) % 4;
updateAccessoryTabs(); updateAccessoryTabs();
@@ -3827,10 +3831,21 @@ public final class MainActivity extends Activity {
return; return;
} }
lastTerminalRenderMs = System.currentTimeMillis(); lastTerminalRenderMs = System.currentTimeMillis();
terminalText.setMovementMethod(null); if (terminalViewMode == TERMINAL_VIEW_CHAT) {
terminalText.setText(terminalScreen.render()); terminalText.setMovementMethod(LinkMovementMethod.getInstance());
terminalText.setHighlightColor(Color.TRANSPARENT);
terminalText.setText(terminalScreen.renderFocused(terminalExpandedBlocks, key -> {
if (!terminalExpandedBlocks.add(key)) {
terminalExpandedBlocks.remove(key);
}
renderTerminalNow();
}));
} else {
terminalText.setMovementMethod(null);
terminalText.setText(terminalScreen.render());
}
updateTerminalLivePanel(); updateTerminalLivePanel();
if (terminalViewMode != TERMINAL_VIEW_CHAT && terminalFollowOutput) { if (terminalFollowOutput) {
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
} }
} }
@@ -3986,7 +4001,12 @@ public final class MainActivity extends Activity {
input.setHintTextColor(COLOR_TEXT_DIM); input.setHintTextColor(COLOR_TEXT_DIM);
input.setTextSize(13); input.setTextSize(13);
input.setPadding(dp(8), dp(5), dp(8), dp(5)); input.setPadding(dp(8), dp(5), dp(8), dp(5));
input.setBackground(inputBackground()); input.setBackground(composerInputBackground());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
GradientDrawable cursor = rounded(COLOR_ACCENT, 1, Color.TRANSPARENT, 0);
cursor.setSize(dp(2), dp(20));
input.setTextCursorDrawable(cursor);
}
} }
private Button compactButton(String label, View.OnClickListener listener) { private Button compactButton(String label, View.OnClickListener listener) {
@@ -4019,6 +4039,14 @@ public final class MainActivity extends Activity {
return rounded(COLOR_FIELD, 8, COLOR_BORDER, 1); return rounded(COLOR_FIELD, 8, COLOR_BORDER, 1);
} }
private StateListDrawable composerInputBackground() {
StateListDrawable states = new StateListDrawable();
states.addState(new int[]{android.R.attr.state_focused},
rounded(COLOR_CARD_ALT, 7, COLOR_ACCENT, 1));
states.addState(new int[]{}, rounded(COLOR_CARD, 7, COLOR_BORDER, 1));
return states;
}
private GradientDrawable rounded(int color, int radiusDp, int strokeColor, int strokeDp) { private GradientDrawable rounded(int color, int radiusDp, int strokeColor, int strokeDp) {
GradientDrawable drawable = new GradientDrawable(); GradientDrawable drawable = new GradientDrawable();
drawable.setColor(color); drawable.setColor(color);
@@ -146,33 +146,82 @@ final class TerminalScreenBuffer {
CharSequence renderFocused(Set<String> expandedBlocks, FocusToggle toggle) { CharSequence renderFocused(Set<String> expandedBlocks, FocusToggle toggle) {
SpannableStringBuilder output = new SpannableStringBuilder(); SpannableStringBuilder output = new SpannableStringBuilder();
List<String> hiddenRows = new ArrayList<>(); int row = 0;
int hiddenStart = -1; while (row < rows) {
for (int row = 0; row < rows; row++) {
String text = rowText(row).trim(); String text = rowText(row).trim();
if (containsHan(text)) { if (!isCollapsibleHeader(text)) {
if (!hiddenRows.isEmpty()) { if (text.isEmpty()) {
appendFocusBlock(output, hiddenRows, hiddenStart, row - 1, expandedBlocks, toggle); if (output.length() > 0) {
hiddenRows.clear(); output.append('\n');
hiddenStart = -1; }
} else {
appendStyledRow(output, row);
} }
appendLine(output, text); row++;
} else if (!text.isEmpty()) { continue;
if (hiddenStart < 0) {
hiddenStart = row;
}
hiddenRows.add(text);
} }
}
if (!hiddenRows.isEmpty()) { int startRow = row;
appendFocusBlock(output, hiddenRows, hiddenStart, rows - 1, expandedBlocks, toggle); List<String> hiddenRows = new ArrayList<>();
hiddenRows.add(text);
row++;
while (row < rows) {
String next = rowText(row).trim();
if (next.isEmpty() || isTopLevelLine(next)) {
break;
}
hiddenRows.add(next);
row++;
}
appendFocusBlock(output, hiddenRows, startRow, row - 1, expandedBlocks, toggle);
} }
if (output.length() == 0) { if (output.length() == 0) {
output.append("暂无可读内容"); output.append("Waiting for terminal output");
} }
return output; return output;
} }
private boolean isCollapsibleHeader(String text) {
boolean activity = text.startsWith("") || text.startsWith("");
String header = stripActivityMarker(text);
return activity && (header.startsWith("Ran ")
|| header.equals("Explored")
|| header.startsWith("Explored ")
|| header.startsWith("Searched ")
|| header.startsWith("Read ")
|| header.startsWith("List ")
|| header.startsWith("Viewed ")
|| header.startsWith("Opened ")
|| header.startsWith("Fetched ")
|| header.startsWith("Downloaded ")
|| header.startsWith("Wrote ")
|| header.startsWith("Edited ")
|| header.startsWith("Applied ")
|| header.startsWith("Updated ")
|| header.startsWith("Checked ")
|| header.startsWith("Inspected ")
|| header.startsWith("Waited ")
|| header.startsWith("Working")
|| header.startsWith("Stop hook"))
|| text.startsWith("─ Worked for ");
}
private boolean isTopLevelLine(String text) {
return text.startsWith("")
|| text.startsWith("")
|| text.startsWith(" ")
|| text.startsWith("> ")
|| text.startsWith("")
|| isCollapsibleHeader(text);
}
private String stripActivityMarker(String text) {
if (text.startsWith("") || text.startsWith("")) {
return text.substring(2).trim();
}
return text;
}
private void appendFocusBlock( private void appendFocusBlock(
SpannableStringBuilder output, SpannableStringBuilder output,
List<String> lines, List<String> lines,
@@ -208,25 +257,22 @@ final class TerminalScreenBuffer {
} }
private String focusSummary(List<String> lines) { private String focusSummary(List<String> lines) {
String first = stripActivityMarker(lines.get(0));
String joined = String.join(" ", lines).toLowerCase(Locale.ROOT); String joined = String.join(" ", lines).toLowerCase(Locale.ROOT);
String type; String type;
if (joined.contains("error") || joined.contains("failed") || joined.contains("exception")) { if (first.startsWith("Explored") || first.startsWith("Searched")
type = "错误输出"; || first.startsWith("Read ") || first.startsWith("List ")
} else if (joined.contains("working") || joined.contains("running") || first.startsWith("Viewed ") || first.startsWith("Inspected ")) {
|| joined.contains("waiting") || joined.contains("interrupt")) { type = "Explored";
type = "运行状态"; } else if (first.startsWith("Working") || first.startsWith("Stop hook")
} else if (joined.contains("test") || joined.contains("build") || joined.contains("compile") || first.startsWith("─ Worked for ")) {
|| joined.contains("gradle") || joined.contains("webpack") || joined.contains("npm")) { type = "Status";
type = "构建/测试"; } else if (joined.contains("error") || joined.contains("failed") || joined.contains("exception")) {
} else if (joined.contains("git ") || joined.contains("commit") || joined.contains("push")) { type = "Command failed";
type = "Git 操作";
} else if (lines.get(0).startsWith(">") || lines.get(0).startsWith("$")
|| lines.get(0).startsWith("!")) {
type = "命令与输出";
} else { } else {
type = "终端细节"; type = "Command output";
} }
return type + " · " + lines.size() + " · " + compactSummary(lines.get(0), 42); return type + " · " + lines.size() + " lines · " + compactSummary(first, 48);
} }
private String compactSummary(String text, int maxChars) { private String compactSummary(String text, int maxChars) {
@@ -244,6 +290,18 @@ final class TerminalScreenBuffer {
output.append(text); output.append(text);
} }
private void appendStyledRow(SpannableStringBuilder output, int row) {
if (output.length() > 0) {
output.append('\n');
}
int limit = cols;
while (limit > 0 && cells[row][limit - 1].value == ' '
&& cells[row][limit - 1].bg == DEFAULT_BG) {
limit--;
}
appendRow(output, row, limit);
}
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;
@@ -583,15 +641,19 @@ final class TerminalScreenBuffer {
} }
private void appendRow(SpannableStringBuilder output, int row) { private void appendRow(SpannableStringBuilder output, int row) {
appendRow(output, row, cols);
}
private void appendRow(SpannableStringBuilder output, int row, int limit) {
int col = 0; int col = 0;
while (col < cols) { while (col < limit) {
Cell first = cells[row][col]; Cell first = cells[row][col];
int start = output.length(); int start = output.length();
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; boolean isDim = first.dim;
while (col < cols) { while (col < limit) {
Cell cell = cells[row][col]; Cell cell = cells[row][col];
if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold || cell.dim != isDim) { if (cell.fg != fgColor || cell.bg != bgColor || cell.bold != isBold || cell.dim != isDim) {
break; break;
@@ -626,18 +688,6 @@ final class TerminalScreenBuffer {
return text.toString(); 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;