From 32de2a6957e3570894c07e4ed7bcd57992bde469 Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Sun, 8 Mar 2026 03:04:16 +0000 Subject: [PATCH 1/5] feat: working on handling stdin ansi sexies --- internal/fullterm/app.go | 2 ++ internal/fullterm/stdin_ansi.go | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 internal/fullterm/stdin_ansi.go diff --git a/internal/fullterm/app.go b/internal/fullterm/app.go index 97b7424..3035098 100644 --- a/internal/fullterm/app.go +++ b/internal/fullterm/app.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/signal" + "strconv" "sync" "syscall" @@ -43,6 +44,7 @@ func (src *app) ListenStdin(context context.Context) { if err != nil { return } + src.content = append(src.content, strconv.Itoa(int(b[0]))+"\n") src.stdinChannel <- b[0] } } diff --git a/internal/fullterm/stdin_ansi.go b/internal/fullterm/stdin_ansi.go new file mode 100644 index 0000000..9500259 --- /dev/null +++ b/internal/fullterm/stdin_ansi.go @@ -0,0 +1,42 @@ +package fullterm + +type ansiState int + +const ( + ansiStateIdle ansiState = iota + ansiStateEscape + ansiStateCSI + ansiStateCSITerm +) + +type stdinAnsi struct { + buf []byte + state ansiState +} + +func newStdinAnsi() stdinAnsi { + return stdinAnsi{ + buf: make([]byte, 0), + state: ansiStateIdle, + } +} + +func (src *stdinAnsi) handle(b byte) ansiState { + switch { + case b == 27: + src.buf = []byte{b} + src.state = ansiStateEscape + case b == 91 && src.state == ansiStateEscape: + src.buf = append(src.buf, b) + src.state = ansiStateCSI + case src.state == ansiStateCSI: + src.buf = append(src.buf, b) + if b > 63 && b < 127 { + defer func() { + src.state = ansiStateIdle + }() + src.state = ansiStateCSITerm + } + } + return src.state +} From fca73e3e7dd0d6b8b0a15ecbfcc4ca215c2f73f4 Mon Sep 17 00:00:00 2001 From: helio Date: Sun, 8 Mar 2026 20:30:15 +0000 Subject: [PATCH 2/5] feat: add ANSI escape code handling and history --- internal/ansi/constants.go | 4 ++ internal/fullterm/app.go | 68 +++++++++++++++++++++++++++------ internal/fullterm/stdin_ansi.go | 16 ++++++-- internal/fullterm/util.go | 4 ++ internal/fullterm/util_test.go | 30 +++++++++++++++ 5 files changed, 107 insertions(+), 15 deletions(-) diff --git a/internal/ansi/constants.go b/internal/ansi/constants.go index 3cc812a..05f5528 100644 --- a/internal/ansi/constants.go +++ b/internal/ansi/constants.go @@ -1,6 +1,10 @@ package ansi const ( + ArrowKeyUp = "\033[A" + ArrowKeyDown = "\033[B" + PageUpKey = "\033[5~" + PageDownKey = "\033[6~" ClearScreen = "\033[2J" CursorHome = "\033[H" CursorToPos = "\033[%d;%dH" // use with fmt.Sprintf, the two ds are for the row and column coordinates diff --git a/internal/fullterm/app.go b/internal/fullterm/app.go index 3035098..148604c 100644 --- a/internal/fullterm/app.go +++ b/internal/fullterm/app.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "os/signal" - "strconv" "sync" "syscall" @@ -21,10 +20,12 @@ type app struct { stdinChannel chan byte fd int prevState *term.State - cmdLine []byte content []string commandSignature string once sync.Once + ansiMachine stdinAnsi + history [][]byte + historyCursor int } func (src *app) Write(bytes []byte) (int, error) { @@ -44,11 +45,23 @@ func (src *app) ListenStdin(context context.Context) { if err != nil { return } - src.content = append(src.content, strconv.Itoa(int(b[0]))+"\n") src.stdinChannel <- b[0] } } } + +func (src *app) setHistoryTail(b []byte) { + src.history[len(src.history)-1] = b +} + +func (src *app) historyTail() []byte { + return src.history[len(src.history)-1] +} + +func (src *app) currentCmd() []byte { + return src.history[src.historyCursor] +} + func (src *app) Submissions() <-chan string { return src.submissionChan } @@ -82,10 +95,14 @@ func (src *app) DrawContent(finalDraw bool) error { } ansi.MoveCursorTo(height, 0) fmt.Printf(ansi.Format("%v> ", ansi.Blue), src.commandSignature) - fmt.Print(string(src.cmdLine)) + fmt.Print(string(src.currentCmd())) return nil } +func (src *app) traverseHistory(delta int) { + src.historyCursor = clamp(0, src.historyCursor+delta, len(src.history)-1) +} + func (src *app) Run(context context.Context) error { // this could be an argument but i aint feeling yet @@ -124,14 +141,40 @@ func (src *app) Run(context context.Context) error { case <-context.Done(): return nil case newStdinInput := <-src.stdinChannel: - newCmd, isSubmission := constructCmdLine(newStdinInput, src.cmdLine) - if isSubmission { - src.content = append(src.content, formatCommandEcho(string(newCmd))) - src.cmdLine = []byte{} - src.submissionChan <- string(newCmd) - } else { - src.cmdLine = newCmd + ansiSeq, ansiState := src.ansiMachine.handle(newStdinInput) + // src.content = append(src.content, fmt.Sprintf("ansi machine handling %v, at state %v\n", ansiSeq, ansiState)) + switch ansiState { + case ansiStateIdle: + // no ansi sequence ongoing so its just presentation bytes + newCmd, isSubmission := constructCmdLine(newStdinInput, src.currentCmd()) + if isSubmission { + src.content = append(src.content, formatCommandEcho(string(newCmd))) + if len(src.historyTail()) > 0 { + src.history = append(src.history, []byte{}) + } + src.submissionChan <- string(newCmd) + } else { + src.setHistoryTail(newCmd) + } + // feel a bit awkward doing this every input stroke, we can get back to it later + src.historyCursor = len(src.history) - 1 + case ansiStateCSITerm: + switch string(ansiSeq) { + case ansi.ArrowKeyUp: + src.traverseHistory(-1) + case ansi.ArrowKeyDown: + src.traverseHistory(1) + case ansi.PageUpKey: + //tbd + case ansi.PageDownKey: + //tbd + default: + // src.content = append(src.content, fmt.Sprintf("unhandled csi %v, %v\n", strconv.Itoa(int(ansiState)), ansiSeq)) + } + default: + // src.content = append(src.content, fmt.Sprintf("unhandled state %v, %v\n", strconv.Itoa(int(ansiState)), ansiSeq)) } + if err := src.DrawContent(false); err != nil { return err } @@ -166,5 +209,8 @@ func CreateApp(commandSignature string) *app { submissionChan: submissionChan, content: make([]string, 0), commandSignature: commandSignature, + ansiMachine: newStdinAnsi(), + history: [][]byte{[]byte{}}, + historyCursor: 0, } } diff --git a/internal/fullterm/stdin_ansi.go b/internal/fullterm/stdin_ansi.go index 9500259..b2ffee1 100644 --- a/internal/fullterm/stdin_ansi.go +++ b/internal/fullterm/stdin_ansi.go @@ -16,15 +16,21 @@ type stdinAnsi struct { func newStdinAnsi() stdinAnsi { return stdinAnsi{ - buf: make([]byte, 0), + buf: make([]byte, 0, 8), state: ansiStateIdle, } } -func (src *stdinAnsi) handle(b byte) ansiState { +func (src *stdinAnsi) reset() { + src.buf = make([]byte, 0, 8) + src.state = ansiStateIdle +} + +func (src *stdinAnsi) handle(b byte) ([]byte, ansiState) { switch { case b == 27: - src.buf = []byte{b} + src.reset() + src.buf = append(src.buf, b) src.state = ansiStateEscape case b == 91 && src.state == ansiStateEscape: src.buf = append(src.buf, b) @@ -37,6 +43,8 @@ func (src *stdinAnsi) handle(b byte) ansiState { }() src.state = ansiStateCSITerm } + default: + src.reset() } - return src.state + return src.buf, src.state } diff --git a/internal/fullterm/util.go b/internal/fullterm/util.go index c00e432..3589619 100644 --- a/internal/fullterm/util.go +++ b/internal/fullterm/util.go @@ -15,3 +15,7 @@ func constructCmdLine(newByte byte, cmdLine []byte) ([]byte, bool) { } return cmdLine, isSubmission } + +func clamp(minBound int, n int, maxBound int) int { + return max(minBound, min(maxBound, n)) +} diff --git a/internal/fullterm/util_test.go b/internal/fullterm/util_test.go index 57fdb89..394ea62 100644 --- a/internal/fullterm/util_test.go +++ b/internal/fullterm/util_test.go @@ -73,3 +73,33 @@ func TestConstructCmdLineSubmissionLF(t *testing.T) { t.Errorf("expected 'test', got '%s'", newLine) } } + +func TestClamp(t *testing.T) { + minBound := 4 + maxBound := 23 + n := 11 + r := clamp(minBound, n, maxBound) + if r != n { + t.Fatalf("expect r to equal %v but instead it is %v", n, r) + } +} + +func TestClampGreaterThanMax(t *testing.T) { + minBound := 2 + maxBound := 5 + n := 12 + r := clamp(minBound, n, maxBound) + if r != maxBound { + t.Fatalf("expect r to equal %v but instead it is %v", maxBound, r) + } +} + +func TestClampLesserThanMin(t *testing.T) { + minBound := 202 + maxBound := 582 + n := 105 + r := clamp(minBound, n, maxBound) + if r != minBound { + t.Fatalf("expect r to equal %v but instead it is %v", minBound, r) + } +} From f5f11b2d633a0b2e774451ee5d2d3615448860eb Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Sun, 22 Mar 2026 15:12:17 +0000 Subject: [PATCH 3/5] feat: handle scroll --- internal/fullterm/app.go | 34 ++++++++++++++++++------ internal/fullterm/app_test.go | 49 +++++++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/internal/fullterm/app.go b/internal/fullterm/app.go index 148604c..d58a49e 100644 --- a/internal/fullterm/app.go +++ b/internal/fullterm/app.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/signal" + "strings" "sync" "syscall" @@ -26,6 +27,7 @@ type app struct { ansiMachine stdinAnsi history [][]byte historyCursor int + scrollOffset int } func (src *app) Write(bytes []byte) (int, error) { @@ -66,11 +68,12 @@ func (src *app) Submissions() <-chan string { return src.submissionChan } -func visibleContent(content []string, height int) []string { +func visibleContent(content []string, height int, scrollOffset int) []string { currentRows := len(content) + endRow := max(currentRows-scrollOffset, 0) // ngl i forgot why we adding plus 1.. oh well - startRow := max(currentRows-(height+1), 0) - return content[startRow:] + startRow := max(endRow-(height+1), 0) + return content[startRow:endRow] } func formatCommandEcho(cmd string) string { @@ -85,7 +88,7 @@ func (src *app) DrawContent(finalDraw bool) error { if !finalDraw { fmt.Print(ansi.ClearScreen + ansi.CursorHome) } - drawableRows := visibleContent(src.content, height) + drawableRows := visibleContent(src.content, height, src.scrollOffset) for i := range drawableRows { fmt.Print(drawableRows[i]) } @@ -94,7 +97,10 @@ func (src *app) DrawContent(finalDraw bool) error { return nil } ansi.MoveCursorTo(height, 0) - fmt.Printf(ansi.Format("%v> ", ansi.Blue), src.commandSignature) + if src.scrollOffset > 0 { + fmt.Print(ansi.Format(fmt.Sprintf("[↑ %d] ", src.scrollOffset), ansi.Yellow, ansi.Bold)) + } + fmt.Printf(ansi.Format("%v> (%v) (%v)", ansi.Blue), src.commandSignature, height, len(src.content)) fmt.Print(string(src.currentCmd())) return nil } @@ -152,6 +158,7 @@ func (src *app) Run(context context.Context) error { if len(src.historyTail()) > 0 { src.history = append(src.history, []byte{}) } + src.scrollOffset = 0 src.submissionChan <- string(newCmd) } else { src.setHistoryTail(newCmd) @@ -165,9 +172,18 @@ func (src *app) Run(context context.Context) error { case ansi.ArrowKeyDown: src.traverseHistory(1) case ansi.PageUpKey: - //tbd + if _, h, err := term.GetSize(src.fd); err == nil { + maxOffset := max(len(src.content)-(h+1), 0) + // substract one cuz we need to account for persistent command line + src.scrollOffset = min(src.scrollOffset+h-1, maxOffset) + } else { + // TODO: do something here idk + } case ansi.PageDownKey: - //tbd + if _, h, err := term.GetSize(src.fd); err == nil { + // ditto subtraction + src.scrollOffset = max(src.scrollOffset-(h-1), 0) + } default: // src.content = append(src.content, fmt.Sprintf("unhandled csi %v, %v\n", strconv.Itoa(int(ansiState)), ansiSeq)) } @@ -179,7 +195,9 @@ func (src *app) Run(context context.Context) error { return err } case newDisplayInput := <-src.DisplayChannel: - src.content = append(src.content, newDisplayInput) + for line := range strings.Lines(newDisplayInput) { + src.content = append(src.content, line) + } if err := src.DrawContent(false); err != nil { return err } diff --git a/internal/fullterm/app_test.go b/internal/fullterm/app_test.go index cbbc101..114a2e0 100644 --- a/internal/fullterm/app_test.go +++ b/internal/fullterm/app_test.go @@ -6,7 +6,7 @@ import ( func TestVisibleContentShorterThanWindow(t *testing.T) { content := []string{"line1\n", "line2\n", "line3\n"} - result := visibleContent(content, 10) + result := visibleContent(content, 10, 0) if len(result) != len(content) { t.Fatalf("expected %d rows, got %d", len(content), len(result)) } @@ -20,7 +20,7 @@ func TestVisibleContentShorterThanWindow(t *testing.T) { func TestVisibleContentExactlyFitsWindow(t *testing.T) { content := []string{"line1\n", "line2\n", "line3\n"} // height+1 == len(content), so startRow == 0 - result := visibleContent(content, len(content)-1) + result := visibleContent(content, len(content)-1, 0) if len(result) != len(content) { t.Fatalf("expected %d rows, got %d", len(content), len(result)) } @@ -29,7 +29,7 @@ func TestVisibleContentExactlyFitsWindow(t *testing.T) { func TestVisibleContentOverflowsWindow(t *testing.T) { content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"} height := 2 - result := visibleContent(content, height) + result := visibleContent(content, height, 0) // startRow = max(5 - 3, 0) = 2, so rows 2,3,4 expectedLen := height + 1 if len(result) != expectedLen { @@ -44,7 +44,7 @@ func TestVisibleContentOverflowsWindow(t *testing.T) { } func TestVisibleContentEmpty(t *testing.T) { - result := visibleContent([]string{}, 10) + result := visibleContent([]string{}, 10, 0) if len(result) != 0 { t.Fatalf("expected empty result, got %d rows", len(result)) } @@ -52,7 +52,7 @@ func TestVisibleContentEmpty(t *testing.T) { func TestVisibleContentZeroHeight(t *testing.T) { content := []string{"line1\n", "line2\n", "line3\n"} - result := visibleContent(content, 0) + result := visibleContent(content, 0, 0) // startRow = max(3 - 1, 0) = 2, so only last row if len(result) != 1 { t.Fatalf("expected 1 row, got %d", len(result)) @@ -61,3 +61,42 @@ func TestVisibleContentZeroHeight(t *testing.T) { t.Errorf("expected 'line3\\n', got %q", result[0]) } } + +func TestVisibleContentScrolled(t *testing.T) { + content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"} + height := 2 + // scrollOffset=2: endRow = 5-2 = 3, startRow = max(3-3, 0) = 0, so rows 0,1,2 + result := visibleContent(content, height, 2) + expectedLen := height + 1 + if len(result) != expectedLen { + t.Fatalf("expected %d rows, got %d", expectedLen, len(result)) + } + if result[0] != "line1\n" { + t.Errorf("expected first visible row to be 'line1\\n', got %q", result[0]) + } + if result[len(result)-1] != "line3\n" { + t.Errorf("expected last visible row to be 'line3\\n', got %q", result[len(result)-1]) + } +} + +func TestVisibleContentScrollOffsetPastTop(t *testing.T) { + content := []string{"line1\n", "line2\n", "line3\n"} + // scrollOffset larger than content — should return empty, not panic + result := visibleContent(content, 2, 100) + if len(result) != 0 { + t.Fatalf("expected empty result when scrolled past top, got %d rows", len(result)) + } +} + +func TestVisibleContentScrolledPartial(t *testing.T) { + content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"} + height := 10 + // scrollOffset=1: endRow = 4, startRow = max(4-11, 0) = 0, all 4 rows visible + result := visibleContent(content, height, 1) + if len(result) != 4 { + t.Fatalf("expected 4 rows, got %d", len(result)) + } + if result[len(result)-1] != "line4\n" { + t.Errorf("expected last row to be 'line4\\n', got %q", result[len(result)-1]) + } +} From d631488ed8456b5b7d600401b1d66d0011330469 Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Sun, 22 Mar 2026 15:18:42 +0000 Subject: [PATCH 4/5] fix: small cleanup --- README.md | 2 +- internal/fullterm/app.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 66158cb..5b368bf 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ tcprcon-cli --profile="my_server" --port=27015 ## Protocol Compliance -While `tcprcon-cli` follows the standard Source RCON Protocol, some game servers (like Rust) have non-standard implementations that might cause unexpected behavior, such as duplicated responses or incorrect packet IDs, the cli should still work, you might just have to deal with an overly chatty server. +While `tcprcon-cli` follows the standard Source RCON Protocol, some game servers (like Rust) have non-standard implementations that might introduce unexpected behaviors, such as duplicated responses or incorrect packet IDs, the cli should still work, you might just have to deal with an overly chatty server. For a detailed breakdown of known server quirks and how they are handled, see the [Caveats section in the core library documentation](https://github.com/UltimateForm/tcprcon#caveats). diff --git a/internal/fullterm/app.go b/internal/fullterm/app.go index d58a49e..dea5c6c 100644 --- a/internal/fullterm/app.go +++ b/internal/fullterm/app.go @@ -100,7 +100,7 @@ func (src *app) DrawContent(finalDraw bool) error { if src.scrollOffset > 0 { fmt.Print(ansi.Format(fmt.Sprintf("[↑ %d] ", src.scrollOffset), ansi.Yellow, ansi.Bold)) } - fmt.Printf(ansi.Format("%v> (%v) (%v)", ansi.Blue), src.commandSignature, height, len(src.content)) + fmt.Printf(ansi.Format("%v> ", ansi.Blue), src.commandSignature) fmt.Print(string(src.currentCmd())) return nil } From 0081114edd1e90363af99bf45ce9cf353e8eafcb Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Sun, 22 Mar 2026 15:48:58 +0000 Subject: [PATCH 5/5] fix: missing readme changes --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 5b368bf..94d0d7d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - [Using Environment Variable for Password](#using-environment-variable-for-password) - [Configuration Profiles](#configuration-profiles) - [CLI Flags](#cli-flags) + - [Interactive UX](#interactive-ux) - [Protocol Compliance](#protocol-compliance) - [Using as a Library](#using-as-a-library) - [Streaming Responses](#streaming-responses) @@ -159,6 +160,20 @@ tcprcon-cli --profile="my_server" --port=27015 saves current connection parameters as a profile. Value is the profile name. ``` +## Interactive UX + +The interactive terminal UI supports the following keyboard controls: + +| Key | Action | +|-----|--------| +| `Enter` | Submit command | +| `Backspace` | Delete last character | +| `↑` / `↓` | Navigate command history | +| `Page Up` | Scroll output up one page | +| `Page Down` | Scroll output down one page | + +When scrolled up, a `[↑ N]` indicator is shown in the prompt line, where `N` is the number of lines scrolled above the bottom. Submitting a command snaps the view back to the bottom. + ## Protocol Compliance While `tcprcon-cli` follows the standard Source RCON Protocol, some game servers (like Rust) have non-standard implementations that might introduce unexpected behaviors, such as duplicated responses or incorrect packet IDs, the cli should still work, you might just have to deal with an overly chatty server.