Skip to content

fix(ssh): terminal ux - #82

Merged
keyldev merged 3 commits into
mainfrom
fix/ssh-terminal-ux
Oct 3, 2026
Merged

keyldev merged 3 commits into
mainfrom
fix/ssh-terminal-ux

Conversation

@keyldev

@keyldev keyldev commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Related

Type

  • Bug fix
  • Feature
  • Refactor / cleanup
  • Docs / CI
  • Other:

Checklist

  • Builds with 0 warnings (TreatWarningsAsErrors=true).
  • Tests pass (dotnet test); new Core logic has unit tests.
  • No layering violation — App references Core only (Infrastructure / Video / Devices wired via DI in a head).
  • Scope stays within one phase (didn't pull work from a later phase's "Не входит").
  • README / docs updated if public commands, options, or setup changed.

Platforms tested

  • Windows
  • Linux
  • macOS
  • Android
  • iOS
  • CI build only

Screenshots / notes

- One character per cell, so the grid stays monospaced where no mono font exists (Android)
- Scrollback with touch drag; output no longer lost when the keyboard resizes the view
- Long-press selection with handles, copy/copy screen/paste menu, Ctrl+V / Ctrl+Shift+C
- "Keyboard on tap" toggle; shell opens at the settled width; SSH.NET shell read fixes
…the screen edge

- Paste drops the trailing line break; a multi-line paste asks: one line / run all / cancel
- Bracketed paste (CSI ?2004h) is honoured when the shell asks for it
- Copy joins rows the terminal soft-wrapped, so a long command copies as one line
- Edge auto-scroll ran the wrong way and per pointer event; bottom-row handle was ungrabbable
- targetSdk 36 is edge-to-edge, so AdjustResize is ignored and the IME overlays the window
- SoftKeyboardInset pads MainView and overlay dialogs by max(safe area, keyboard)
- Focused field is scrolled into view; rotation re-applies the inset
- In the SSH terminal the prompt stays above the keyboard
@keyldev
keyldev merged commit 6de1fc3 into main Oct 3, 2026
2 of 5 checks passed
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Fix SSH terminal selection, paste, scrollback, and mobile keyboard layout

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Keep terminal text and the prompt visible through scrolling, resizing, and soft-keyboard changes.
• Add touch selection, copy controls, and safer paste choices without accidental command execution.
• Improve SSH stream handling and test terminal escape, resize, wrap, and paste behavior.
Diagram

graph TD
  A["Mobile input pane"] --> B["Keyboard inset"] --> C["App layouts"] --> D["Terminal content"] --> E["Terminal view"] --> F["Terminal emulator"]
  D --> G["Paste shaping"] --> H["SSH shell"]
  F --> D
  H --> F
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use native Android window-inset handling
  • ➕ Could measure Android IME overlap directly, without inferring it from client-size changes.
  • ➖ Adds platform-specific plumbing and does not by itself address the shared Avalonia layouts or iOS.
2. Adopt a full terminal-control library
  • ➕ Could provide mature selection, reflow, and escape-sequence handling.
  • ➖ Requires a substantially larger migration of the existing renderer and emulator for this UX fix.

Recommendation: Keep the incremental approach: it preserves the existing App/Core/Infrastructure boundaries and shares keyboard compensation across layouts. Validate inset behavior on devices that both resize and overlay, and pay particular attention to fixed-PTY-width behavior and the new SSH read/write pumps during review.

Files changed (16) +1775 / -113

Enhancement (6) +836 / -52
TerminalView.csAdd readable grid rendering, scrollback, and selection +646/-34

Add readable grid rendering, scrollback, and selection

• Pins cached glyphs to individual cells, renders scrollback, and adds mouse and touch selection with handles and edge scrolling. Exposes selected and visible text plus copy, paste, and menu gestures and shortcuts.

src/OpenIPC.Viewer.App/Controls/TerminalView.cs

Localizer.csTranslate terminal clipboard and keyboard actions +16/-0

Translate terminal clipboard and keyboard actions

• Adds English and Russian strings for the terminal menu, keyboard toggle, and multiline-paste choices.

src/OpenIPC.Viewer.App/Services/Localizer.cs

SshTerminalFactory.csPass settings into terminal view models +2/-1

Pass settings into terminal view models

• Supplies UserSettingsService when constructing an SSH terminal view model so it can read and persist the keyboard-on-tap preference.

src/OpenIPC.Viewer.App/Services/SshTerminalFactory.cs

SshTerminalViewModel.csExpose and save the keyboard-on-tap setting +10/-0

Expose and save the keyboard-on-tap setting

• Reads the current terminal keyboard preference and adds an asynchronous method to persist changes for later sessions.

src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs

SshTerminalContent.axamlAdd terminal menus and multiline-paste choices +62/-17

Add terminal menus and multiline-paste choices

• Adds copy, copy-screen, paste, and keyboard-toggle menu items plus a three-choice multiline-paste bar. Reworks the mobile special-key bar to keep its keys and keyboard button visible at narrow widths.

src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml

TerminalPaste.csShape clipboard text for safe shell pasting +100/-0

Shape clipboard text for safe shell pasting

• Adds reusable normalization, line counting, one-line joining, explicit command execution, and bracketed-paste formatting. Removes trailing line breaks and control characters before sending clipboard text.

src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalPaste.cs

Bug fix (6) +761 / -55
OverlayDialogPresenter.csKeep overlay dialogs clear of the soft keyboard +62/-10

Keep overlay dialogs clear of the soft keyboard

• Applies safe-area and keyboard padding to sheets and full-screen dialogs, updates it when insets or size change, and brings focused controls into view. Removes the new subscriptions when a dialog closes.

src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs

SoftKeyboardInset.csMeasure keyboard overlap not handled by window resizing +98/-0

Measure keyboard overlap not handled by window resizing

• Adds a per-window inset calculation using InputPane occlusion and a keyboard-down client-size baseline. Accounts for window resizing and rotation, and provides keyboard-state subscriptions.

src/OpenIPC.Viewer.App/Services/SoftKeyboardInset.cs

SshTerminalContent.axaml.csCoordinate terminal clipboard, focus, and shell startup +289/-12

Coordinate terminal clipboard, focus, and shell startup

• Wires selection and clipboard actions, routes multiline paste through a confirmation choice, and manages optional mobile keyboard focus. Delays shell connection until the measured grid settles and returns to live output when sending input.

src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs

MainView.axaml.csPad the main page above an overlaid keyboard +17/-5

Pad the main page above an overlaid keyboard

• Subscribes to keyboard changes and applies the larger of the safe-area bottom inset and remaining keyboard overlap, without stacking both paddings.

src/OpenIPC.Viewer.App/Views/MainView.axaml.cs

TerminalEmulator.csPreserve terminal history and recognize paste mode +159/-18

Preserve terminal history and recognize paste mode

• Keeps bottom rows and scrollback through grid resizing, tracks soft-wrapped rows for copying, and recognizes bracketed-paste mode. Also handles more string-escape terminators and bounds malformed-sequence parsing.

src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs

SshNetShell.csReplace SSH event reads and blocking writes with pumps +136/-10

Replace SSH event reads and blocking writes with pumps

• Drains ShellStream with a read loop instead of relying on DataReceived, retaining early output until a subscriber attaches. Queues writes in order off the caller thread and bounds disposal waits for both pumps.

src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs

Tests (2) +168 / -0
TerminalEmulatorTests.csTest history, wrapping, escapes, and paste mode +126/-0

Test history, wrapping, escapes, and paste mode

• Adds regression tests for keyboard-like resizing, scrollback, wrap marks, OSC and CSI parsing, and bracketed-paste mode transitions.

tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalEmulatorTests.cs

TerminalPasteTests.csTest shell paste normalization and formatting +42/-0

Test shell paste normalization and formatting

• Covers line-ending normalization, trailing-break removal, control-code filtering, multiline detection, joining, command execution, and bracketed-paste output.

tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalPasteTests.cs

Documentation (1) +6 / -6
MainActivity.csClarify Android keyboard-resize fallback +6/-6

Clarify Android keyboard-resize fallback

• Documents why AdjustResize remains enabled even though edge-to-edge Android may overlay the window with the keyboard, requiring app-side inset handling.

src/OpenIPC.Viewer.Android/MainActivity.cs

Other (1) +4 / -0
UserSettings.csPersist the terminal keyboard-on-tap preference +4/-0

Persist the terminal keyboard-on-tap preference

• Adds a default-enabled setting controlling whether tapping the mobile terminal automatically raises the soft keyboard.

src/OpenIPC.Viewer.App/Services/UserSettings.cs

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (9) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Failed shell writes keep piling up 🐞 Bug ☼ Reliability
Description
WriteLoopAsync catches a write exception and exits without completing or faulting _outgoing,
while SendAsync continues to accept strings into its unbounded channel. After the connection fails
during a send, subsequent terminal input has no consumer, receives no failure from SendAsync, and
accumulates in memory.
Code

src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[R137-140]

+        catch (Exception ex)
+        {
+            _logger.LogDebug(ex, "SSH shell write loop ended");
+        }
Evidence
The channel is unbounded, the catch ends the sole writer without closing it, and SendAsync ignores
queue-write outcomes and always completes successfully.

src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[34-38]
src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[120-148]
src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs[99-108]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The writer can terminate while the unbounded outgoing channel remains writable, silently retaining subsequent terminal input.
## Fix Focus Areas
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[34-38]
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[120-148]
## Recommended Fix
Complete or fault the outgoing channel when its writer fails, reject later sends, and propagate the write failure to callers rather than reporting queue admission as delivery.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Paste unit test fails and misses its target case 🐞 Bug ≡ Correctness
Description
AsOneLine_JoinsTrimsAndDropsContinuationBackslash uses \\n, producing a literal backslash and
n rather than a backslash followed by a newline. The input therefore has three lines instead of
the asserted four, causing both the line-count and joined-command assertions to fail without
exercising continuation-backslash stripping.
Code

tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalPasteTests.cs[R30-32]

+        var paste = TerminalPaste.Normalize("curl -s \\n  http://cam/x\n\n  | head\n");
+        Assert.Equal(4, TerminalPaste.LineCount(paste));
+        Assert.Equal("curl -s http://cam/x | head", TerminalPaste.AsOneLine(paste));
Evidence
Normalize splits only on actual newline characters, so the test input normalizes to three lines:
one containing the literal \n, one blank, and one containing | head. That contradicts the
asserted line count of four, and the joined text cannot equal the expected `curl -s http://cam/x |
head`.

tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalPasteTests.cs[27-33]
src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalPaste.cs[58-86]
tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalPasteTests.cs[28-32]
src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalPaste.cs[26-51]
src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalPaste.cs[57-66]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The continuation-backslash test uses `\\n`, which C# reads as a literal backslash followed by `n`, rather than a backslash followed by a newline. Its line-count and joined-text assertions fail, and it does not test continuation-backslash stripping.

## Fix Focus Areas
- tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalPasteTests.cs[28-33]

## Recommended Fix
Change the input to `"curl -s \\\n  http://cam/x\n\n  | head\n"` so the continuation backslash is immediately followed by a newline. Then verify that both the asserted line count and joined command match the corrected input.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Opening the keyboard can hide the shell prompt 🐞 Bug ≡ Correctness
Description
Resize always moves Rows - rows lines from the top into scrollback, wherever the cursor is. If
the cursor is above the new bottom edge, as in a fresh session or after clear, the prompt scrolls
out of view and the cursor is clamped to row 0 on a blank line, so typed text echoes away from the
prompt and the line stays split after the keyboard closes.
Code

src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[101]

+        var drop = had ? Math.Max(0, Rows - rows) : 0;
Evidence
drop is Rows - rows whenever a screen already exists, regardless of _cursorRow. The cursor is
then set to _cursorRow - drop + restore and clamped to 0, which leaves it on a row that was
previously below the prompt. SshTerminalViewModel.ResizeAsync calls Emulator.Resize, so every
keyboard-driven change in the grid height goes through this path.

src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[96-145]
src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs[115-122]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When the screen gets shorter, `Resize` pushes rows from the top into scrollback even when the cursor sits near the top. The prompt is then pushed off screen and the cursor lands on a blank row.

## Fix Focus Areas
- src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[96-145]

## Recommended Fix
Set `drop = Math.Clamp(_cursorRow - (rows - 1), 0, Rows - rows)` so only enough top rows go to scrollback to keep the cursor on screen. Remove any remaining excess rows from the empty bottom of the screen instead. Add a test where the cursor is near the top when the screen shrinks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Tablet shortcuts send control keys 🐞 Bug ≡ Correctness
Description
TerminalView.OnKeyDown recognizes the new copy and paste shortcuts, but the mobile terminal keeps
keyboard focus on ImeProxy, whose OnProxyKeyDown still routes every Ctrl+letter combination
through SendInput. With an external keyboard on mobile, Ctrl+V sends a control byte instead of
pasting, and Ctrl+Shift+C sends the interrupt byte instead of copying.
Code

src/OpenIPC.Viewer.App/Controls/TerminalView.cs[R802-805]

+        var pasteCombo =
+            (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.V) ||
+            (e.KeyModifiers.HasFlag(KeyModifiers.Shift) && e.Key == Key.Insert);
+        if (pasteCombo)
Evidence
Mobile setup makes the terminal nonfocusable and assigns focus to the proxy; the proxy's first key
branch turns Ctrl+letter keys into control bytes.

src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs[180-199]
src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs[213-241]
src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs[379-389]
src/OpenIPC.Viewer.App/Controls/TerminalView.cs[798-824]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Mobile hardware-keyboard events go to the IME proxy, bypassing the terminal's new copy and paste shortcut handling.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs[379-403]
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[798-824]
## Recommended Fix
Recognize copy and paste combinations in the proxy before its generic Ctrl+letter branch, and route them to the same clipboard actions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. New output moves the scrollback view 🐞 Bug ≡ Correctness
Description
TerminalView.Render calculates the first displayed history row as `ScrollbackRows -
_scrollOffset, while _scrollOffset` stays unchanged as output arrives. When a command continues
printing after the user scrolls back, each appended history row moves the visible window forward
instead of keeping the rows being read in place.
Code

src/OpenIPC.Viewer.App/Controls/TerminalView.cs[R203-204]

+        var top = history - _scrollOffset;
+        var selection = NormalizedSelection();
Evidence
The renderer derives its top row from the current history count, while emulator updates only
invalidate the view and scrolling is the only path that changes the offset.

src/OpenIPC.Viewer.App/Controls/TerminalView.cs[164-165]
src/OpenIPC.Viewer.App/Controls/TerminalView.cs[197-210]
src/OpenIPC.Viewer.App/Controls/TerminalView.cs[297-305]
src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[363-373]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A fixed offset from the newest output makes a scrolled-back viewport advance whenever the shell prints another row.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[197-210]
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[297-305]
- src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[363-373]
## Recommended Fix
Track the displayed history position while scrolled back, adjusting the offset when history grows and accounting for evictions at the history cap.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Widening the terminal adds copied spaces 🐞 Bug ≡ Correctness
Description
TerminalEmulator.Resize preserves a source row's wrap mark after copying it into a wider blank
row, and LineJoiner.Add retains that row's trailing cells when joining wrapped text. If a
four-column abcdef wraps into abcd and ef before the view widens to eight columns, copying the
rows produces abcd    ef rather than abcdef.
Code

src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[R132-134]

+                Array.Copy(source, next[r], Math.Min(columns, source.Length));
+                if (IsWrapped(source))
+                    MarkWrapped(next[r], true);
Evidence
Resize allocates rows at the new width and copies both cells and wrap marks; the copy joiner
deliberately does not trim a marked row.

src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[114-135]
src/OpenIPC.Viewer.App/Controls/TerminalView.cs[423-429]
src/OpenIPC.Viewer.App/Controls/TerminalView.cs[434-445]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Resize transfers wrap metadata to a wider row, causing copy to treat newly padded cells as spaces from the original wrapped line.
## Fix Focus Areas
- src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[118-135]
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[423-429]
## Recommended Fix
Preserve the original wrap boundary or reflow wrapped text on width changes, so joining a copied line does not include padding introduced by resize.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View review recommended (3)
7. Selected history can copy another row 🐞 Bug ≡ Correctness
Description
TerminalView retains a selection as numeric history-row positions without adjusting them when the
emulator evicts its oldest scrollback row. If output reaches the 1000-row history cap while a
selection remains, the highlight and copied text shift to newer rows rather than staying on the
selected content.
Code

src/OpenIPC.Viewer.App/Controls/TerminalView.cs[R96-97]

+    private (int Row, int Col)? _selAnchor;
+    private (int Row, int Col)? _selFocus;
Evidence
The new selection fields store row indices, copying resolves them against the current history list,
and scrollback eviction removes index zero without notifying selection state.

src/OpenIPC.Viewer.App/Controls/TerminalView.cs[91-97]
src/OpenIPC.Viewer.App/Controls/TerminalView.cs[373-388]
src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[363-373]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Evicting the oldest scrollback row shifts every numeric selection coordinate without moving the stored selection endpoints.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[91-97]
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[373-388]
- src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[363-373]
## Recommended Fix
Expose history evictions to the view and adjust or clear affected selection endpoints, or identify selected rows independently of their current list index.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Closing a terminal can discard sent input 🐞 Bug ☼ Reliability
Description
SendAsync now completes when it enqueues text, but DisposeAsync immediately cancels the writer
and disposes its stream without draining queued entries. If the user closes the terminal just after
sending a command or choosing to run a paste, bytes that SendAsync already reported as sent can
remain in the queue and never reach the camera.
Code

src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[R166-169]

+        _outgoing.Writer.TryComplete();
+        _stopping.Cancel();
+        // Disposing the stream is what unblocks the parked read.
        _stream.Dispose();
-        return ValueTask.CompletedTask;
Evidence
Send returns immediately after TryWrite; disposal completes and cancels the queue before awaiting
the writer, then disposes the stream.

src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[120-148]
src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[156-173]
src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs[348-354]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Shell shutdown cancels the writer immediately even though prior sends only guaranteed queue admission, not delivery.
## Fix Focus Areas
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[120-148]
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[156-173]
## Recommended Fix
Give queued writes a bounded opportunity to finish before cancelling and disposing the stream, and make send completion reflect delivery or a reported failure.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Early shell output can disappear 🐞 Bug ☼ Reliability
Description
SshNetShell.Publish stops buffering receive chunks after _pending reaches 64 entries, although
the reader starts before the terminal subscribes. If a shell prints enough output before
SshTerminalViewModel attaches its handler, later chunks in that interval are discarded and cannot
be recovered from the stream.
Code

src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[R78-80]

+            // Bounded: nobody is coming for this if the terminal never attached.
+            if (_pending is { Count: < 64 })
+                _pending.Add(data);
Evidence
The constructor starts the reader, the view model subscribes after opening the shell, and Publish
has no action for receive chunks beyond the 64-entry pending limit.

src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[48-81]
src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[84-107]
src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs[68-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The read loop consumes shell output before a listener attaches, but its pending buffer silently drops chunks after 64 entries.
## Fix Focus Areas
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[41-81]
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[84-107]
- src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs[68-80]
## Recommended Fix
Attach the listener before starting reads or apply backpressure until it attaches; do not consume and discard unread terminal output.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: 🧠 Deep: This is a large, bug-dense SSH terminal UX change spanning emulator parsing, PTY I/O, Android keyboard/insets, selection, clipboard/paste, UI lifecycle, and persistence, creating many independent paths where redundant review could catch subtle defects.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +802 to +805
var pasteCombo =
(e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.V) ||
(e.KeyModifiers.HasFlag(KeyModifiers.Shift) && e.Key == Key.Insert);
if (pasteCombo)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Tablet shortcuts send control keys 🐞 Bug ≡ Correctness

TerminalView.OnKeyDown recognizes the new copy and paste shortcuts, but the mobile terminal keeps
keyboard focus on ImeProxy, whose OnProxyKeyDown still routes every Ctrl+letter combination
through SendInput. With an external keyboard on mobile, Ctrl+V sends a control byte instead of
pasting, and Ctrl+Shift+C sends the interrupt byte instead of copying.
Agent Prompt
## Issue description
Mobile hardware-keyboard events go to the IME proxy, bypassing the terminal's new copy and paste shortcut handling.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs[379-403]
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[798-824]
## Recommended Fix
Recognize copy and paste combinations in the proxy before its generic Ctrl+letter branch, and route them to the same clipboard actions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +203 to +204
var top = history - _scrollOffset;
var selection = NormalizedSelection();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. New output moves the scrollback view 🐞 Bug ≡ Correctness

TerminalView.Render calculates the first displayed history row as `ScrollbackRows -
_scrollOffset, while _scrollOffset` stays unchanged as output arrives. When a command continues
printing after the user scrolls back, each appended history row moves the visible window forward
instead of keeping the rows being read in place.
Agent Prompt
## Issue description
A fixed offset from the newest output makes a scrolled-back viewport advance whenever the shell prints another row.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[197-210]
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[297-305]
- src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[363-373]
## Recommended Fix
Track the displayed history position while scrolled back, adjusting the offset when history grows and accounting for evictions at the history cap.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +132 to +134
Array.Copy(source, next[r], Math.Min(columns, source.Length));
if (IsWrapped(source))
MarkWrapped(next[r], true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

6. Widening the terminal adds copied spaces 🐞 Bug ≡ Correctness

TerminalEmulator.Resize preserves a source row's wrap mark after copying it into a wider blank
row, and LineJoiner.Add retains that row's trailing cells when joining wrapped text. If a
four-column abcdef wraps into abcd and ef before the view widens to eight columns, copying the
rows produces abcd    ef rather than abcdef.
Agent Prompt
## Issue description
Resize transfers wrap metadata to a wider row, causing copy to treat newly padded cells as spaces from the original wrapped line.
## Fix Focus Areas
- src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[118-135]
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[423-429]
## Recommended Fix
Preserve the original wrap boundary or reflow wrapped text on width changes, so joining a copied line does not include padding introduced by resize.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +96 to +97
private (int Row, int Col)? _selAnchor;
private (int Row, int Col)? _selFocus;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

7. Selected history can copy another row 🐞 Bug ≡ Correctness

TerminalView retains a selection as numeric history-row positions without adjusting them when the
emulator evicts its oldest scrollback row. If output reaches the 1000-row history cap while a
selection remains, the highlight and copied text shift to newer rows rather than staying on the
selected content.
Agent Prompt
## Issue description
Evicting the oldest scrollback row shifts every numeric selection coordinate without moving the stored selection endpoints.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[91-97]
- src/OpenIPC.Viewer.App/Controls/TerminalView.cs[373-388]
- src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[363-373]
## Recommended Fix
Expose history evictions to the view and adjust or clear affected selection endpoints, or identify selected rows independently of their current list index.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +137 to +140
catch (Exception ex)
{
_logger.LogDebug(ex, "SSH shell write loop ended");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Failed shell writes keep piling up 🐞 Bug ☼ Reliability

WriteLoopAsync catches a write exception and exits without completing or faulting _outgoing,
while SendAsync continues to accept strings into its unbounded channel. After the connection fails
during a send, subsequent terminal input has no consumer, receives no failure from SendAsync, and
accumulates in memory.
Agent Prompt
## Issue description
The writer can terminate while the unbounded outgoing channel remains writable, silently retaining subsequent terminal input.
## Fix Focus Areas
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[34-38]
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[120-148]
## Recommended Fix
Complete or fault the outgoing channel when its writer fails, reject later sends, and propagate the write failure to callers rather than reporting queue admission as delivery.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +166 to 169
_outgoing.Writer.TryComplete();
_stopping.Cancel();
// Disposing the stream is what unblocks the parked read.
_stream.Dispose();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

8. Closing a terminal can discard sent input 🐞 Bug ☼ Reliability

SendAsync now completes when it enqueues text, but DisposeAsync immediately cancels the writer
and disposes its stream without draining queued entries. If the user closes the terminal just after
sending a command or choosing to run a paste, bytes that SendAsync already reported as sent can
remain in the queue and never reach the camera.
Agent Prompt
## Issue description
Shell shutdown cancels the writer immediately even though prior sends only guaranteed queue admission, not delivery.
## Fix Focus Areas
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[120-148]
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[156-173]
## Recommended Fix
Give queued writes a bounded opportunity to finish before cancelling and disposing the stream, and make send completion reflect delivery or a reported failure.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +78 to +80
// Bounded: nobody is coming for this if the terminal never attached.
if (_pending is { Count: < 64 })
_pending.Add(data);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

9. Early shell output can disappear 🐞 Bug ☼ Reliability

SshNetShell.Publish stops buffering receive chunks after _pending reaches 64 entries, although
the reader starts before the terminal subscribes. If a shell prints enough output before
SshTerminalViewModel attaches its handler, later chunks in that interval are discarded and cannot
be recovered from the stream.
Agent Prompt
## Issue description
The read loop consumes shell output before a listener attaches, but its pending buffer silently drops chunks after 64 entries.
## Fix Focus Areas
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[41-81]
- src/OpenIPC.Viewer.Infrastructure/Ssh/SshNetShell.cs[84-107]
- src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs[68-80]
## Recommended Fix
Attach the listener before starting reads or apply backpressure until it attaches; do not consume and discard unread terminal output.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +30 to +32
var paste = TerminalPaste.Normalize("curl -s \\n http://cam/x\n\n | head\n");
Assert.Equal(4, TerminalPaste.LineCount(paste));
Assert.Equal("curl -s http://cam/x | head", TerminalPaste.AsOneLine(paste));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Paste unit test fails and misses its target case 🐞 Bug ≡ Correctness

AsOneLine_JoinsTrimsAndDropsContinuationBackslash uses \\n, producing a literal backslash and
n rather than a backslash followed by a newline. The input therefore has three lines instead of
the asserted four, causing both the line-count and joined-command assertions to fail without
exercising continuation-backslash stripping.
Agent Prompt
## Issue description
The continuation-backslash test uses `\\n`, which C# reads as a literal backslash followed by `n`, rather than a backslash followed by a newline. Its line-count and joined-text assertions fail, and it does not test continuation-backslash stripping.

## Fix Focus Areas
- tests/OpenIPC.Viewer.Core.Tests/Ssh/TerminalPasteTests.cs[28-33]

## Recommended Fix
Change the input to `"curl -s \\\n  http://cam/x\n\n  | head\n"` so the continuation backslash is immediately followed by a newline. Then verify that both the asserted line count and joined command match the corrected input.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

// Losing rows takes them off the TOP, the way a real terminal does it: the interesting end
// of a shell session is the bottom one. Keeping the top instead would drop the prompt every
// time the view got shorter — which on a phone is every time the soft keyboard opens.
var drop = had ? Math.Max(0, Rows - rows) : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Opening the keyboard can hide the shell prompt 🐞 Bug ≡ Correctness

Resize always moves Rows - rows lines from the top into scrollback, wherever the cursor is. If
the cursor is above the new bottom edge, as in a fresh session or after clear, the prompt scrolls
out of view and the cursor is clamped to row 0 on a blank line, so typed text echoes away from the
prompt and the line stays split after the keyboard closes.
Agent Prompt
## Issue description
When the screen gets shorter, `Resize` pushes rows from the top into scrollback even when the cursor sits near the top. The prompt is then pushed off screen and the cursor lands on a blank row.

## Fix Focus Areas
- src/OpenIPC.Viewer.Core/Ssh/Terminal/TerminalEmulator.cs[96-145]

## Recommended Fix
Set `drop = Math.Clamp(_cursorRow - (rows - 1), 0, Rows - rows)` so only enough top rows go to scrollback to keep the cursor on screen. Remove any remaining excess rows from the empty bottom of the screen instead. Add a test where the cursor is near the top when the screen shrinks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@keyldev
keyldev deleted the fix/ssh-terminal-ux branch October 4, 2026 10:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant