Compare commits

..
8 Commits
Author SHA1 Message Date
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
2 changed files with 472 additions and 65 deletions
@@ -23,6 +23,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;
@@ -124,11 +125,17 @@ 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 ScrollView terminalScroll; private ScrollView terminalScroll;
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 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 +147,13 @@ 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 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 boolean terminalReadingMode = true;
private int terminalKeyPage; private int terminalKeyPage;
private int terminalReconnectAttempt; private int terminalReconnectAttempt;
private int terminalConnectionGeneration; private int terminalConnectionGeneration;
@@ -1596,6 +1605,11 @@ public final class MainActivity extends Activity {
terminalSelectionEnabled = false; terminalSelectionEnabled = false;
terminalFollowOutput = true; terminalFollowOutput = true;
terminalKeyPage = 0; terminalKeyPage = 0;
terminalExpandedBlocks.clear();
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,11 +1628,12 @@ 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(!terminalReadingMode);
terminalText.setLineSpacing(0, 1.05f); terminalText.setLineSpacing(0, 1.05f);
terminalText.setGravity(Gravity.BOTTOM | Gravity.START); terminalText.setGravity((terminalReadingMode ? Gravity.TOP : 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);
@@ -1667,6 +1682,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 +1694,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 +1720,70 @@ 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 ->
setTerminalReadingMode(!terminalReadingMode));
terminalReadingButton.setContentDescription("Toggle Chinese reading mode");
styleTerminalReadingButton();
bar.addView(terminalReadingButton);
bar.addView(terminalToolButton("", view -> showTerminalActions(sessionName))); bar.addView(terminalToolButton("", view -> showTerminalActions(sessionName)));
return bar; return bar;
} }
private void setTerminalReadingMode(boolean reading) {
terminalReadingMode = reading;
if (terminalText != null) {
terminalText.setHorizontallyScrolling(!reading);
terminalText.setGravity((reading ? Gravity.TOP : Gravity.BOTTOM) | Gravity.START);
terminalText.setMovementMethod(reading ? LinkMovementMethod.getInstance() : null);
}
styleTerminalReadingButton();
renderTerminalNow();
setStatus(reading ? "Chinese reading mode" : "Full terminal mode");
}
private void styleTerminalReadingButton() {
if (terminalReadingButton == null) {
return;
}
terminalReadingButton.setText(terminalReadingMode ? "" : "");
terminalReadingButton.setTextColor(terminalReadingMode ? Color.rgb(14, 38, 24) : COLOR_TEXT_MUTED);
terminalReadingButton.setBackground(terminalReadingMode
? 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 +1823,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)) {
@@ -1768,18 +1849,28 @@ public final class MainActivity extends Activity {
} }
private void updateTerminalMeta() { private void updateTerminalMeta() {
if (terminalMetaText == null) { if (terminalMetaText == null || terminalConnectionText == null) {
return; return;
} }
StringBuilder meta = new StringBuilder(); terminalMetaText.setText(defaultValue(terminalPathStatus, "path loading"));
meta.append(defaultValue(terminalPathStatus, "path loading")); String socket = terminalSocketStatus.toLowerCase(java.util.Locale.ROOT);
if (!terminalSocketStatus.isEmpty()) { if (socket.contains("connected") && !socket.contains("disconnected")) {
meta.append(" · ").append(terminalSocketStatus); 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);
} }
if (!terminalEventStatus.isEmpty()) { }
meta.append(" · ").append(terminalEventStatus);
} private String compactTerminalPath(String path) {
terminalMetaText.setText(meta.toString()); 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 +2083,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 +2115,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,6 +2161,12 @@ 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);
@@ -2161,6 +2262,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 +2771,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 +2796,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 +2921,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 +2972,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 +2997,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 +3105,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);
} }
@@ -3124,7 +3321,19 @@ public final class MainActivity extends Activity {
return; return;
} }
lastTerminalRenderMs = System.currentTimeMillis(); lastTerminalRenderMs = System.currentTimeMillis();
terminalText.setText(terminalScreen.render()); if (terminalReadingMode) {
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());
}
if (terminalFollowOutput) { if (terminalFollowOutput) {
terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN)); terminalScroll.post(() -> terminalScroll.fullScroll(View.FOCUS_DOWN));
} }
@@ -3339,9 +3548,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 +3558,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);
} }
@@ -3698,9 +3906,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 +3936,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,106 @@ final class TerminalScreenBuffer {
return output; return output;
} }
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 +356,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 +405,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 +576,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;
} }
output.append(cell.value); if (!cell.continuation) {
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 +601,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 +765,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 +783,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 +818,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;
} }
} }
} }