Here's a gopher client I had gemini write and claude fix. I take no credit for any of the code, it compiles and works pretty good for something a computer generated in about 30 mins.
----- gophernav.pas begins -----
program tiegopher;
(* TieGopher July 10 2026
Freeware do what you want license.
Written by Gemini fixed by Claude
*)
{$mode objfpc}{$H+}
uses
Crt, Classes, SysUtils, ssockets, Unix;
type
TMenuItem = record
ItemType: Char;
Desc: String;
Selector: String;
Host: String;
Port: Word;
IsNavigable: Boolean;
end;
THistoryItem = record
Host: String;
Port: Word;
Selector: String;
end;
var
CurrentHost: String = 'gopher.floodgap.com';
CurrentPort: Word = 70;
CurrentSelector: String = '';
MenuItems: array of TMenuItem;
NavMap: array of Integer;
History: array of THistoryItem;
// TUI State variables
ActiveNavIndex: Integer;
ScrollOffset: Integer;
TargetMenu: TMenuItem;
SearchQuery: String;
// ---------------------------------------------------------
// Fetches data from a Gopher server and returns a StringList
// ---------------------------------------------------------
function FetchData(Host: String; Port: Word; Selector: String): TStringList; var
Stream: TInetSocket;
MemStream: TMemoryStream;
Buffer: array[0..4095] of Byte;
BytesRead: Integer;
Request: String;
begin
Result := TStringList.Create;
MemStream := TMemoryStream.Create;
try
try
Stream := TInetSocket.Create(Host, Port);
try
Request := Selector + #13#10;
Stream.WriteBuffer(Request[1], Length(Request));
repeat
BytesRead := Stream.Read(Buffer[0], SizeOf(Buffer));
if BytesRead > 0 then
MemStream.Write(Buffer[0], BytesRead);
until BytesRead <= 0;
finally
Stream.Free;
end;
MemStream.Position := 0;
Result.LoadFromStream(MemStream);
except
on E: Exception do
Result.Add('3Connection Error: ' + E.Message + #9 + 'fake' + #9 + 'error.host' + #9 + '1');
end;
finally
MemStream.Free;
end;
end;
// ---------------------------------------------------------
// Fetches raw bytes (for binary files) into a TMemoryStream
// ---------------------------------------------------------
function FetchRaw(Host: String; Port: Word; Selector: String): TMemoryStream; var
Stream: TInetSocket;
Buffer: array[0..4095] of Byte;
BytesRead: Integer;
Request: String;
begin
Result := TMemoryStream.Create;
try
Stream := TInetSocket.Create(Host, Port);
try
Request := Selector + #13#10;
Stream.WriteBuffer(Request[1], Length(Request));
repeat
BytesRead := Stream.Read(Buffer[0], SizeOf(Buffer));
if BytesRead > 0 then
Result.Write(Buffer[0], BytesRead);
until BytesRead <= 0;
finally
Stream.Free;
end;
Result.Position := 0;
except
on Exception do
Result.Position := 0; { return whatever we have; caller checks Size }
end;
end;
// ---------------------------------------------------------
// Parses a raw tab-delimited Gopher line into a TMenuItem
// ---------------------------------------------------------
function ParseMenuLine(const Line: String): TMenuItem;
var
Parts: array of String;
i: Integer;
CurStr: String;
begin
Result.ItemType := 'i';
if Length(Line) = 0 then Exit;
Result.ItemType := Line[1];
SetLength(Parts, 0);
CurStr := '';
for i := 2 to Length(Line) do
begin
if Line[i] = #9 then
begin
SetLength(Parts, Length(Parts) + 1);
Parts[High(Parts)] := CurStr;
CurStr := '';
end
else
CurStr := CurStr + Line[i];
end;
SetLength(Parts, Length(Parts) + 1);
Parts[High(Parts)] := CurStr;
Result.Desc := ''; Result.Selector := ''; Result.Host := ''; Result.Port := 70;
if Length(Parts) > 0 then Result.Desc := Parts[0];
if Length(Parts) > 1 then Result.Selector := Parts[1];
if Length(Parts) > 2 then Result.Host := Parts[2];
if Length(Parts) > 3 then Result.Port := StrToIntDef(Trim(Parts[3]), 70);
Result.IsNavigable := Result.ItemType in
['0', '1', '7', 'h', { text, menu, search, html-link }
'4', '5', '6', '9', { encoded / binary files }
'g', 'I', 'p', { images }
's', 'd', ';']; { sound, document, video }
end;
// ---------------------------------------------------------
// Downloads and displays a Text file (Type 0)
// ---------------------------------------------------------
procedure ShowText(Host: String; Port: Word; Selector: String);
var
Data: TStringList;
i, LineCount: Integer;
Cmd: String;
begin
ClrScr;
WriteLn('Fetching text from ', Host, '...');
Data := FetchData(Host, Port, Selector);
ClrScr;
LineCount := 0;
for i := 0 to Data.Count - 1 do
begin
if (Data[i] = '.') and (i = Data.Count - 1) then Break;
WriteLn(Data[i]);
Inc(LineCount);
if LineCount mod (WindMaxY - 2) = 0 then
begin
TextColor(LightCyan);
Write('-- Press Enter for more, or "q" to stop --');
NormVideo;
ReadLn(Cmd);
if LowerCase(Trim(Cmd)) = 'q' then Break;
GotoXY(1, WhereY - 1); ClrEol;
end;
end;
Data.Free;
TextColor(LightCyan);
WriteLn;
WriteLn('-- End of file. Press Enter to return to menu --');
NormVideo;
ReadLn;
end;
// ---------------------------------------------------------
// Screen-width helper (columns in the current text window)
// ---------------------------------------------------------
function ScreenCols: Integer;
begin
ScreenCols := Lo(WindMax) + 1;
end;
// ---------------------------------------------------------
// Picks a 7-char type tag so descriptions line up
// ---------------------------------------------------------
function RowTag(const Item: TMenuItem): String;
begin
case Item.ItemType of
'i': RowTag := ' ';
'3': RowTag := ' [ERR] ';
'0': RowTag := ' [TXT] ';
'1': RowTag := ' [DIR] ';
'7': RowTag := ' [FND] ';
'h': RowTag := ' [WWW] ';
else
if Item.IsNavigable then RowTag := ' [BIN] '
else RowTag := ' [?] ';
end;
end;
// ---------------------------------------------------------
// Clamps/pads a row to the screen width: stops long lines
// from wrapping and lets the highlight span the whole row
// ---------------------------------------------------------
function RowText(const Prefix, Desc: String): String;
var
W: Integer;
begin
W := ScreenCols - 1; { leave the last column free to avoid auto-wrap }
RowText := Prefix + Desc;
if Length(RowText) > W then
RowText := Copy(RowText, 1, W)
else
RowText := RowText + StringOfChar(' ', W - Length(RowText));
end;
// ---------------------------------------------------------
// Downloads a binary item to disk (images, sounds, etc.)
// ---------------------------------------------------------
procedure SaveFile(const Item: TMenuItem);
var
Data: TMemoryStream;
HomeDir, FileName, BaseName, Answer: String;
begin
BaseName := ExtractFileName(Item.Selector);
if Trim(BaseName) = '' then BaseName := 'gopherfile';
GotoXY(1, WindMaxY);
ClrEol;
TextColor(LightGreen);
Write('Save as [', BaseName, ']: ');
NormVideo;
ReadLn(Answer);
if Trim(Answer) <> '' then FileName := Trim(Answer) else FileName := BaseName;
HomeDir := GetEnvironmentVariable('HOME');
if HomeDir <> '' then FileName := HomeDir + PathDelim + FileName;
ClrScr;
WriteLn('Downloading ', Item.Selector, ' from ', Item.Host, ' ...');
Data := FetchRaw(Item.Host, Item.Port, Item.Selector);
try
if Data.Size > 0 then
begin
try
Data.SaveToFile(FileName);
TextColor(LightGreen);
WriteLn('Saved ', Data.Size, ' bytes to ', FileName);
except
on E: Exception do
begin
TextColor(LightRed);
WriteLn('Error saving file: ', E.Message);
end;
end;
end
else
begin
TextColor(LightRed);
WriteLn('No data received (empty file or connection error).');
end;
NormVideo;
finally
Data.Free;
end;
TextColor(LightCyan);
WriteLn('-- Press Enter to return to menu --');
NormVideo;
ReadLn;
end;
// ---------------------------------------------------------
// Shell-escapes a string for safe use inside single quotes
// ---------------------------------------------------------
function ShellSingleQuote(const S: String): String;
const
SQ = ''''; { one single-quote character }
begin
ShellSingleQuote := SQ + StringReplace(S, SQ, SQ + '\' + SQ + SQ, [rfReplaceAll]) + SQ;
end;
// ---------------------------------------------------------
// Opens a Gopher 'h' (HTML) link in the system browser
// ---------------------------------------------------------
procedure OpenURL(const RawSelector: String);
var
URL: String;
begin
URL := RawSelector;
{ 'h' items almost always carry "URL:<real url>" in the selector. }
if (Length(URL) >= 4) and (LowerCase(Copy(URL, 1, 4)) = 'url:') then
URL := Copy(URL, 5, Length(URL) - 4);
URL := Trim(URL);
GotoXY(1, WindMaxY);
ClrEol;
if URL = '' then
begin
TextColor(LightRed);
Write('This link has no URL to open.');
NormVideo;
Delay(1200);
Exit;
end;
{ Launch detached and injection-safe: the URL is single-quoted with any
embedded quotes escaped, output is discarded, and '&' backgrounds it so
the browser opens without freezing the TUI or leaving a zombie. }
FpSystem('xdg-open ' + ShellSingleQuote(URL) + ' >/dev/null 2>&1 &');
TextColor(LightGreen);
Write(Copy('Opening in browser: ' + URL, 1, ScreenCols - 1));
NormVideo;
Delay(1000);
end;
// ---------------------------------------------------------
// Renders the viewport based on current scroll position
// ---------------------------------------------------------
procedure DrawMenu(ViewCapacity: Integer);
var
i, TargetMenuIdx: Integer;
begin
if Length(NavMap) > 0 then
TargetMenuIdx := NavMap[ActiveNavIndex]
else
TargetMenuIdx := -1;
// Auto-scroll the camera if the selection goes out of bounds
if TargetMenuIdx <> -1 then
begin
if TargetMenuIdx < ScrollOffset then
ScrollOffset := TargetMenuIdx;
if TargetMenuIdx >= ScrollOffset + ViewCapacity then
ScrollOffset := TargetMenuIdx - ViewCapacity + 1;
end;
ClrScr;
TextColor(LightGreen);
WriteLn('=======================================================');
WriteLn(' Gopherspace: ', CurrentHost, ':', CurrentPort);
WriteLn('=======================================================');
NormVideo;
// Render only the items that fit on screen
for i := ScrollOffset to ScrollOffset + ViewCapacity - 1 do
begin
if i > High(MenuItems) then Break;
// Apply Highlight
if i = TargetMenuIdx then
begin
TextBackground(LightGray);
TextColor(Black);
end
else
begin
TextBackground(Black);
TextColor(LightGray);
end;
WriteLn(RowText(RowTag(MenuItems[i]), MenuItems[i].Desc));
NormVideo;
end;
// Footer
GotoXY(1, WindMaxY);
TextColor(LightGreen);
Write(Copy('Arrows/PgUp/PgDn: Move | Enter: Select | G: Go | B: Back | Q: Quit', 1, ScreenCols - 1));
NormVideo;
end;
// ---------------------------------------------------------
// Parses a gopher URL or bare host into a TMenuItem
//
gopher://host:port/Tselector (T = one item-type char)
// ---------------------------------------------------------
function ParseGopherURL(Raw: String): TMenuItem;
var
P: Integer;
HostPort, PathPart: String;
begin
Result.ItemType := '1';
Result.Desc := '';
Result.Selector := '';
Result.Host := '';
Result.Port := 70;
Result.IsNavigable := True;
Raw := Trim(Raw);
if Raw = '' then Exit;
{ Strip a leading
gopher:// scheme if present. }
if LowerCase(Copy(Raw, 1, 9)) = '
gopher://' then
Delete(Raw, 1, 9);
{ Split host[:port] from the path at the first '/'. }
P := Pos('/', Raw);
if P > 0 then
begin
HostPort := Copy(Raw, 1, P - 1);
PathPart := Copy(Raw, P + 1, Length(Raw));
end
else
begin
HostPort := Raw;
PathPart := '';
end;
{ Optional :port on the host. }
P := Pos(':', HostPort);
if P > 0 then
begin
Result.Host := Copy(HostPort, 1, P - 1);
Result.Port := StrToIntDef(Copy(HostPort, P + 1, Length(HostPort)), 70);
end
else
Result.Host := HostPort;
{ First path char is the gopher item type; the rest is the selector. }
if PathPart <> '' then
begin
Result.ItemType := PathPart[1];
Result.Selector := Copy(PathPart, 2, Length(PathPart));
end;
end;
// ---------------------------------------------------------
// Prompts for a URL/host, parses it, and acts on it the
// same way Enter does. Returns True if a new menu should
// be fetched by the main loop.
// ---------------------------------------------------------
function GoToAddress: Boolean;
var
Input, Query: String;
Item: TMenuItem;
procedure PushHistory;
begin
SetLength(History, Length(History) + 1);
History[High(History)].Host := CurrentHost;
History[High(History)].Port := CurrentPort;
History[High(History)].Selector := CurrentSelector;
end;
begin
Result := False;
GotoXY(1, WindMaxY);
ClrEol;
TextColor(LightCyan);
Write('Go to (host or gopher URL): ');
NormVideo;
ReadLn(Input);
Input := Trim(Input);
if Input = '' then Exit;
Item := ParseGopherURL(Input);
if Item.Host = '' then Exit;
case Item.ItemType of
'0':
ShowText(Item.Host, Item.Port, Item.Selector);
'h':
OpenURL(Item.Selector);
'7':
begin
GotoXY(1, WindMaxY);
ClrEol;
TextColor(LightGreen);
Write('Enter search query: ');
NormVideo;
ReadLn(Query);
if Trim(Query) <> '' then
begin
PushHistory;
CurrentHost := Item.Host;
CurrentPort := Item.Port;
CurrentSelector := Item.Selector + #9 + Trim(Query);
Result := True;
end;
end;
'4', '5', '6', '9', 'g', 'I', 'p', 's', 'd', ';':
SaveFile(Item);
else
begin
PushHistory;
CurrentHost := Item.Host;
CurrentPort := Item.Port;
CurrentSelector := Item.Selector;
Result := True;
end;
end;
end;
// ---------------------------------------------------------
// Main execution loop
// ---------------------------------------------------------
var
MenuData: TStringList;
i, ViewCapacity: Integer;
NeedFetch: Boolean;
Key: Char;
begin
NeedFetch := True;
History := nil;
while True do
begin
// Fetch data only when we've navigated to a new menu
if NeedFetch then
begin
ClrScr;
WriteLn('Fetching menu from ', CurrentHost, '...');
MenuData := FetchData(CurrentHost, CurrentPort, CurrentSelector);
SetLength(MenuItems, 0);
SetLength(NavMap, 0);
for i := 0 to MenuData.Count - 1 do
begin
if (MenuData[i] = '.') or (MenuData[i] = '') then Continue;
SetLength(MenuItems, Length(MenuItems) + 1);
MenuItems[High(MenuItems)] := ParseMenuLine(MenuData[i]);
if MenuItems[High(MenuItems)].IsNavigable then
begin
SetLength(NavMap, Length(NavMap) + 1);
NavMap[High(NavMap)] := High(MenuItems);
end;
end;
MenuData.Free;
ActiveNavIndex := 0;
ScrollOffset := 0;
NeedFetch := False;
end;
// ViewCapacity is screen height minus header(3 lines) and footer(1 line)
ViewCapacity := WindMaxY - 4;
DrawMenu(ViewCapacity);
// Input Handling
Key := ReadKey;
if Key = #0 then // Extended keys (Arrows)
begin
Key := ReadKey;
if Length(NavMap) = 0 then Continue; // Ignore arrows if nothing is selectable
case Key of
#72: if ActiveNavIndex > 0 then Dec(ActiveNavIndex); // Up Arrow
#80: if ActiveNavIndex < High(NavMap) then Inc(ActiveNavIndex); // Down Arrow
#73: begin // Page Up
ActiveNavIndex := ActiveNavIndex - ViewCapacity;
if ActiveNavIndex < 0 then ActiveNavIndex := 0;
end;
#81: begin // Page Down
ActiveNavIndex := ActiveNavIndex + ViewCapacity;
if ActiveNavIndex > High(NavMap) then ActiveNavIndex := High(NavMap);
end;
end;
end
else
begin
case LowerCase(Key) of
'q': Break;
'b': begin
if Length(History) > 0 then
begin
CurrentHost := History[High(History)].Host;
CurrentPort := History[High(History)].Port;
CurrentSelector := History[High(History)].Selector;
SetLength(History, Length(History) - 1);
NeedFetch := True;
end;
end;
'g': if GoToAddress then NeedFetch := True;
#13: begin // Enter Key
if Length(NavMap) > 0 then
begin
TargetMenu := MenuItems[NavMap[ActiveNavIndex]];
if TargetMenu.ItemType = '1' then
begin
SetLength(History, Length(History) + 1);
History[High(History)].Host := CurrentHost;
History[High(History)].Port := CurrentPort;
History[High(History)].Selector := CurrentSelector;
CurrentHost := TargetMenu.Host;
CurrentPort := TargetMenu.Port;
CurrentSelector := TargetMenu.Selector;
NeedFetch := True;
end
else if TargetMenu.ItemType = '0' then
begin
ShowText(TargetMenu.Host, TargetMenu.Port, TargetMenu.Selector);
end
else if TargetMenu.ItemType = '7' then
begin
// Briefly take over the bottom row for standard text input
GotoXY(1, WindMaxY);
ClrEol;
TextColor(LightGreen);
Write('Enter search query: ');
NormVideo;
ReadLn(SearchQuery);
if Trim(SearchQuery) <> '' then
begin
SetLength(History, Length(History) + 1);
History[High(History)].Host := CurrentHost;
History[High(History)].Port := CurrentPort;
History[High(History)].Selector := CurrentSelector;
CurrentHost := TargetMenu.Host;
CurrentPort := TargetMenu.Port;
CurrentSelector := TargetMenu.Selector + #9 + SearchQuery;
NeedFetch := True;
end;
end
else if TargetMenu.ItemType = 'h' then
OpenURL(TargetMenu.Selector)
else
SaveFile(TargetMenu);
end;
end;
end;
end;
end;
end.
----- gophernav.pas ends -----
--- msged/lnx 6.3 archlinux-git
* Origin: 1:229/452 (1:229/452@fidonet)