Compare commits

..

No commits in common. "4990c6d26a87984ffc3bf65106b819829df08a6c" and "f126ba7cceee50f1debbb7430bb9ad823214ba41" have entirely different histories.

11 changed files with 258 additions and 381 deletions

View file

@ -1,30 +1,42 @@
# nanite
`nanite` is a terminal client for the [Nanochat] protocol.
`nanite` is a terminal [Nanochat] client.
Requires Go 1.24.4 or higher. Build using `make build`.
![](./assets/screenshot.png)
> The upstream URL for this repository is <https://git.rhzm.org/lobo/nanite>.
> The repositories hosted on GitHub and Codeberg are mirrors.
## build
# keybindings
```
$ go build .
```
## usage
```
$ ./nanite
usage: ./nanite host [port]
$ ./nanite very.real-server.com
```
keybindings:
- `Ctrl+C`: quit
- `Ctrl+L`: refresh screen
- `Ctrl+P`: poll
- Emacs-like bindings for text editing
# commands
commands:
- `/clear`: clear message log
- `/dial host [port]`: connect to server
- `/hangup`: disconnect from server
- `/help`: see command list
- `/me ...`: send IRC-style `/me` message
- `/nick [nickname]`: change nickname or see current nickname if no arguments given
- `/poll [time]`: set polling rate to time or poll manually if no arguments given
- `/quit`: self-explanatory (aliased to `/q`)
- `/script [name]`: run script or see script list if no arguments given (aliased to `/.`)
- `/send ...`: send raw message
- `/dial hostname`: connect to server
- `/hangup`: disconnect
- `/q`, `/quit`: quit
- `/nick [nickname]`: change nick, if no arguments, show current nick
- `/me`: IRC `/me` alike
- `/poll [seconds]`: change polling interval, if no arguments, poll manually
## won't support (yet)
- sixel (tried, it seems to be complicated to get it to work with Vaxis' pager
widget)
[Nanochat]: https://git.phial.org/d6/nanochat
[Vaxis]: https://git.sr.ht/~rockorager/vaxis

BIN
assets/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

View file

@ -2,116 +2,129 @@ package main
import (
"fmt"
"maps"
"os"
"path"
"slices"
"strconv"
"strings"
"time"
)
type Command func(*App, string)
func (app *App) Stat() (res string, err error) {
if _, err := app.conn.Write([]byte("STAT\n")); err != nil {
return "", err
}
var CommandMap map[string]Command
var AliasMap map[string]string
var str strings.Builder
for range 3 {
if !app.conn.Scanner.Scan() {
return "", app.conn.Scanner.Err()
}
str.Write(app.conn.Scanner.Bytes())
str.WriteRune(' ')
}
func initCommandMap() {
CommandMap = map[string]Command{
"help": func(app *App, rest string) {
var s strings.Builder
for _, name := range slices.Sorted(maps.Keys(CommandMap)) {
s.WriteString(name)
s.WriteRune(' ')
return str.String(), nil
}
app.AppendSystemMessage("commands: %s", s.String())
},
"script": func(app *App, rest string) {
if rest == "" {
if err := app.RefreshScripts(); err != nil {
app.AppendSystemMessage("failed to refresh scripts")
func (app *App) Send(data string) (num int, err error) {
if _, err := fmt.Fprintf(app.conn, "SEND %s\n", data); err != nil {
return 0, err
}
var s strings.Builder
for _, script := range slices.Sorted(maps.Keys(app.scripts)) {
s.WriteString(script)
s.WriteRune(' ')
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
app.AppendSystemMessage("scripts: %s", s.String())
} else {
if err := app.LoadScript(rest); err != nil {
app.AppendSystemMessage("error loading script \"%s\": %s", rest, err)
}
}
},
"send": func(app *App, rest string) {
app.outgoing <- MessageEvent(rest)
},
"dial": func(app *App, rest string) {
args := strings.Fields(rest)
if len(args) < 1 || len(args) > 2 {
app.AppendSystemMessage("usage: /dial host [port]")
}
host := args[0]
port := "44322"
if len(args) == 2 {
port = args[1]
}
app.outgoing <- DialEvent{host, port}
},
"hangup": func(app *App, rest string) {
app.outgoing <- HangupEvent{}
},
"nick": func(app *App, rest string) {
if rest == "" {
app.AppendSystemMessage("your nickname is %s", app.nick)
} else {
app.SetNick(rest)
app.AppendSystemMessage("your nickname is now %s", app.nick)
if err := os.WriteFile(path.Join(app.cfgHome, "nick"), []byte(rest), 0o600); err != nil {
app.AppendSystemMessage("failed to persist nickname: %s", err)
}
}
},
"poll": func(app *App, rest string) {
if rest == "" {
app.outgoing <- ManualPollEvent(app.last)
} else {
if app.conn == nil {
app.AppendSystemMessage("not connected to any server")
return
}
num, err := strconv.Atoi(rest)
numRaw := app.conn.Scanner.Text()
num, err = strconv.Atoi(numRaw)
if err != nil {
app.AppendSystemMessage("invalid number \"%s\"", rest)
} else {
if num == 0 {
app.conn.ticker.Stop()
app.conn.rate = 0
app.AppendSystemMessage("disabled automatic polling")
} else {
app.conn.rate = time.Second * time.Duration(num)
app.conn.ticker.Stop()
app.conn.ticker = time.NewTicker(app.conn.rate)
app.AppendSystemMessage("polling rate set to %s", app.conn.rate.String())
return 0, err
}
}
}
},
"me": func(app *App, rest string) {
app.outgoing <- MessageEvent(fmt.Sprintf("%s %s", app.nick, rest))
},
"clear": func(app *App, rest string) {
clear(app.pager.Segments)
app.pager.Layout()
app.AppendSystemMessage("cleared message history")
},
"quit": func(app *App, rest string) {
app.stop()
},
return num, nil
}
AliasMap = map[string]string {
"q": "quit",
".": "script",
func (app *App) Poll(since int) (num int, err error) {
if _, err := fmt.Fprintf(app.conn, "POLL %d\n", since); err != nil {
return 0, err
}
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
numRaw := app.conn.Scanner.Text()
num, err = strconv.Atoi(numRaw)
if err != nil {
return 0, err
}
return num, nil
}
func (app *App) Skip(since int) (num int, err error) {
if _, err := fmt.Fprintf(app.conn, "SKIP %d\n", since); err != nil {
return 0, err
}
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
num, err = strconv.Atoi(app.conn.Scanner.Text())
if err != nil {
return 0, err
}
for range num {
if !app.conn.Scanner.Scan() {
return 0, err
}
app.incoming <- MessageEvent(app.conn.Scanner.Text())
}
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
last, err := strconv.Atoi(app.conn.Scanner.Text())
if err != nil {
return 0, err
}
app.incoming <- SetLastEvent(last)
return num, nil
}
func (app *App) Last(n int) (num int, err error) {
if n == 0 {
return 0, nil
}
if _, err := fmt.Fprintf(app.conn, "LAST %d\n", n); err != nil {
return 0, err
}
var nsrv int
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
nsrvRaw := app.conn.Scanner.Text()
nsrv, err = strconv.Atoi(nsrvRaw)
if err != nil {
return 0, err
}
if nsrv != 0 {
for range nsrv {
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
app.incoming <- MessageEvent(app.conn.Scanner.Text())
}
}
var last int
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
lastRaw := app.conn.Scanner.Text()
last, err = strconv.Atoi(lastRaw)
if err != nil {
return 0, err
}
app.incoming <- SetLastEvent(last)
return nsrv, nil
}

View file

@ -1,7 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.buildGoModule {
pname = "nanite";
version = "0.1.0+5701062c7f";
src = ./.;
vendorHash = "sha256-10zAs/SEJp3ImoaJ3/uJ23jjsnzOceLvizrvrWYI97k=";
}

View file

@ -5,7 +5,7 @@ import (
"math"
"strings"
"git.rhzm.org/lobo/nanite/widgets/pager"
"codeberg.org/lobo/nanite/widgets/pager"
"git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rockorager/vaxis/widgets/textinput"
)
@ -87,14 +87,14 @@ func (app *App) submitTextInput() {
name, rest, _ := strings.Cut(app.input.String()[1:], " ")
if cmd, ok := CommandMap[name]; ok {
cmd(app, rest)
} else if alias, ok := AliasMap[name]; ok {
cmd := CommandMap[alias]
cmd(app, rest)
} else {
app.AppendSystemMessage("unknown command \"%s\"", name)
}
} else {
app.outgoing <- MessageEvent(fmt.Sprintf("%s: %s", app.nick, app.input.String()))
message := fmt.Sprintf("%s: %s", app.nick, app.input.String())
app.AppendMessage(message)
app.outgoing <- MessageEvent(message)
app.outgoing <- StatEvent("")
}
app.input.SetContent("")
@ -108,10 +108,6 @@ func (app *App) HandleTerminalEvent(ev vaxis.Event) {
app.pager.ScrollUp()
case "Down":
app.pager.ScrollDown()
case "Page_Up":
app.pager.ScrollUpN(app.w.log.Height * 2 / 3)
case "Page_Down":
app.pager.ScrollDownN(app.w.log.Height * 2 / 3)
case "Enter":
app.submitTextInput()
case "Ctrl+p":

View file

@ -11,7 +11,6 @@ import (
type IncomingEvent interface {
HandleIncoming(*App)
}
type OutgoingEvent interface {
HandleOutgoing(*App) error
}
@ -28,9 +27,7 @@ func (ev DialEvent) HandleOutgoing(app *App) error {
var err error
if app.conn != nil {
app.incoming <- SystemMessageEvent(
fmt.Sprintf("already connected to %s:%s", app.conn.host, app.conn.port),
)
app.incoming <- SystemMessageEvent(fmt.Sprintf("already connected to %s:%s", app.conn.host, app.conn.port))
return nil
}
conn, err := net.Dial("tcp", net.JoinHostPort(ev.Host, ev.Port))
@ -54,8 +51,8 @@ func (ev DialEvent) HandleOutgoing(app *App) error {
if err != nil {
return err
}
delta := time.Since(latStart)
delta = min(max(time.Second, delta*3/2), 5*time.Second).Round(time.Second)
delta := time.Since(latStart).Round(time.Second)
delta = min(max(time.Second, delta*3/2), 5*time.Second)
app.conn.rate = delta
app.conn.ticker = time.NewTicker(delta)
@ -78,13 +75,9 @@ func (ev DialEvent) HandleOutgoing(app *App) error {
return nil
}
type HangupEvent struct{ host, port string }
type HangupEvent struct{}
func (ev HangupEvent) HandleIncoming(app *App) {
app.AppendSystemMessage("disconnected from %s:%s", ev.host, ev.port)
}
func (_ HangupEvent) HandleOutgoing(app *App) error {
func (ev HangupEvent) HandleOutgoing(app *App) error {
if app.conn == nil {
app.incoming <- SystemMessageEvent("not connected to any server")
return nil
@ -99,7 +92,7 @@ func (_ HangupEvent) HandleOutgoing(app *App) error {
app.conn.ticker.Stop()
app.conn.ticker = nil
app.conn = nil
app.incoming <- HangupEvent{host, port}
app.incoming <- SystemMessageEvent(fmt.Sprintf("disconnected from %s:%s", host, port))
return nil
}
@ -118,13 +111,11 @@ func (ev MessageEvent) HandleIncoming(app *App) {
}
func (ev MessageEvent) HandleOutgoing(app *App) error {
app.incoming <- ev
num, err := app.Send(string(ev))
if err != nil {
return err
}
app.incoming <- SetLastEvent(num)
app.outgoing <- StatEvent("")
return nil
}
@ -180,9 +171,9 @@ func (ev ManualPollEvent) HandleOutgoing(app *App) error {
func (ev ManualPollEvent) HandleIncoming(app *App) {
if int(ev) == 0 {
app.AppendSystemMessage("no new messages")
app.AppendSystemMessage("poll: no new messages")
} else {
app.AppendSystemMessage("retrieving %d messages", ev)
app.AppendSystemMessage("poll: retrieving %d messages", ev)
}
}

2
go.mod
View file

@ -1,4 +1,4 @@
module git.rhzm.org/lobo/nanite
module codeberg.org/lobo/nanite
go 1.24.4

122
main.go
View file

@ -8,15 +8,97 @@ import (
"net"
"os"
"path"
"strconv"
"strings"
"time"
"git.rhzm.org/lobo/nanite/widgets/pager"
"codeberg.org/lobo/nanite/widgets/pager"
"git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rockorager/vaxis/widgets/textinput"
)
var CommandMap map[string]func(*App, string)
func init() {
CommandMap = map[string]func(*App, string){
"help": func(app *App, rest string) {
var s strings.Builder
for name, _ := range CommandMap {
if name == "q" {
continue
}
s.WriteString(name)
s.WriteRune(' ')
}
app.AppendSystemMessage("commands: %s", s.String())
},
"dial": func(app *App, rest string) {
args := strings.Fields(rest)
if len(args) < 1 || len(args) > 2 {
app.AppendSystemMessage("usage: /connect host [port]")
}
host := args[0]
port := "44322"
if len(args) == 2 {
port = args[1]
}
app.outgoing <- DialEvent{host, port}
},
"hangup": func(app *App, rest string) {
app.outgoing <- HangupEvent{}
},
"nick": func(app *App, rest string) {
if rest == "" {
app.AppendSystemMessage("nick: your nickname is %s", app.nick)
} else {
app.SetNick(rest)
app.AppendSystemMessage("nick: your nickname is now %s", app.nick)
if err := os.WriteFile(path.Join(app.cfgHome, "nick"), []byte(rest), 0o700); err != nil {
app.AppendSystemMessage("nick: failed to persist nickname: %s", err)
}
}
},
"poll": func(app *App, rest string) {
if rest == "" {
app.outgoing <- ManualPollEvent(app.last)
} else {
num, err := strconv.Atoi(rest)
if err != nil {
app.AppendSystemMessage("poll: invalid number %s", rest)
} else {
if num == 0 {
app.conn.ticker.Stop()
app.conn.rate = 0
app.AppendSystemMessage("poll: disabled automatic polling")
} else {
app.conn.rate = time.Second * time.Duration(num)
app.conn.ticker.Stop()
app.conn.ticker = time.NewTicker(app.conn.rate)
app.AppendSystemMessage("poll: polling every %s", app.conn.rate.String())
}
}
}
},
"me": func(app *App, rest string) {
msg := fmt.Sprintf("%s %s", app.nick, rest)
app.AppendMessage(msg)
app.outgoing <- MessageEvent(msg)
},
"clear": func(app *App, rest string) {
app.pager.Segments = []vaxis.Segment{}
app.pager.Layout()
app.AppendSystemMessage("cleared message history")
},
"quit": func(app *App, rest string) {
app.stop()
},
"q": func(app *App, rest string) {
app.stop()
},
}
}
type App struct {
ctx context.Context
stop context.CancelFunc
@ -31,15 +113,12 @@ type App struct {
outgoing chan OutgoingEvent
error chan error
scripts map[string]string
vx *vaxis.Vaxis
pager *pager.Model
input *textinput.Model
w struct {
log, title, input vaxis.Window
}
pager *pager.Model
input *textinput.Model
cfgHome string
}
@ -54,15 +133,12 @@ type Conn struct {
}
func (app *App) AppendMessage(data string) {
nick, _, found := strings.Cut(data, ": ")
style := vaxis.Style{}
if !found || strings.TrimSpace(nick) != nick {
style.Attribute = vaxis.AttrItalic
}
// TODO: make messages without a nick italic
app.pager.Segments = append(app.pager.Segments,
vaxis.Segment{Text: data, Style: style},
vaxis.Segment{Text: data},
vaxis.Segment{Text: "\n"},
)
app.last += 1
app.pager.Offset = math.MaxInt
}
@ -78,7 +154,7 @@ func (app *App) AppendSystemMessage(format string, args ...any) {
func (app *App) SetNick(nick string) {
nick = strings.TrimSpace(nick)
app.input.SetPrompt(fmt.Sprintf("[%s] ", nick))
app.input.SetPrompt(fmt.Sprintf("%s: ", nick))
app.input.Prompt.Attribute = vaxis.AttrBold
app.nick = nick
}
@ -91,7 +167,7 @@ func (app *App) EnsureConfigDir() error {
app.cfgHome = path.Join(userCfg, "nanite")
stat, err := os.Stat(app.cfgHome)
if err != nil {
if err := os.MkdirAll(app.cfgHome, 0o700); err != nil {
if err := os.Mkdir(app.cfgHome, 0o700); err != nil {
return err
}
} else {
@ -110,26 +186,17 @@ func NewApp() *App {
app.error = make(chan error)
if err := app.EnsureConfigDir(); err != nil {
panic(err)
}
if err := app.RefreshScripts(); err != nil {
panic(err)
app.error <- err
}
go func() {
for {
select {
case ev := <-app.outgoing:
_, dial := ev.(DialEvent)
if dial || app.conn != nil {
if err := ev.HandleOutgoing(app); err != nil {
app.error <- err
return
}
} else {
app.incoming <- SystemMessageEvent("not connected to any server")
}
case <-app.ctx.Done():
return
}
@ -160,14 +227,8 @@ func (app *App) Loop() error {
func (app *App) Finish() {
app.stop()
app.FinishUI()
if app.conn != nil {
HangupEvent{}.HandleOutgoing(app)
}
}
func init() {
initCommandMap()
}
func main() {
app := NewApp()
@ -192,7 +253,6 @@ func main() {
app.AppendSystemMessage("welcome to nanite! :3")
if err := app.Loop(); err != nil {
app.FinishUI()
panic(err)
}
}

View file

@ -1,9 +0,0 @@
.PHONY: build install
PREFIX=$(HOME)/.local
build:
go build .
install: build
install -Dd $(PREFIX)/bin
install nanite $(PREFIX)/bin/nanite

View file

@ -1,127 +0,0 @@
package main
import (
"fmt"
"strconv"
"strings"
)
func (app *App) Stat() (res string, err error) {
if _, err := app.conn.Write([]byte("STAT\n")); err != nil {
return "", err
}
var str strings.Builder
for range 3 {
if !app.conn.Scanner.Scan() {
return "", app.conn.Scanner.Err()
}
str.Write(app.conn.Scanner.Bytes())
str.WriteRune(' ')
}
return str.String(), nil
}
func (app *App) Send(data string) (num int, err error) {
if _, err := fmt.Fprintf(app.conn, "SEND %s\n", data); err != nil {
return 0, err
}
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
numRaw := app.conn.Scanner.Text()
num, err = strconv.Atoi(numRaw)
if err != nil {
return 0, err
}
return num, nil
}
func (app *App) Poll(since int) (num int, err error) {
if _, err := fmt.Fprintf(app.conn, "POLL %d\n", since); err != nil {
return 0, err
}
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
numRaw := app.conn.Scanner.Text()
num, err = strconv.Atoi(numRaw)
if err != nil {
return 0, err
}
return num, nil
}
func (app *App) Skip(since int) (num int, err error) {
if _, err := fmt.Fprintf(app.conn, "SKIP %d\n", since); err != nil {
return 0, err
}
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
num, err = strconv.Atoi(app.conn.Scanner.Text())
if err != nil {
return 0, err
}
for range num {
if !app.conn.Scanner.Scan() {
return 0, err
}
app.incoming <- MessageEvent(app.conn.Scanner.Text())
}
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
last, err := strconv.Atoi(app.conn.Scanner.Text())
if err != nil {
return 0, err
}
app.incoming <- SetLastEvent(last)
return num, nil
}
func (app *App) Last(n int) (num int, err error) {
if n == 0 {
return 0, nil
}
if _, err := fmt.Fprintf(app.conn, "LAST %d\n", n); err != nil {
return 0, err
}
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
numRaw := app.conn.Scanner.Text()
num, err = strconv.Atoi(numRaw)
if err != nil {
return 0, err
}
for range num {
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
app.incoming <- MessageEvent(app.conn.Scanner.Text())
}
var last int
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
}
lastRaw := app.conn.Scanner.Text()
last, err = strconv.Atoi(lastRaw)
if err != nil {
return 0, err
}
app.incoming <- SetLastEvent(last)
return num, nil
}

View file

@ -1,52 +0,0 @@
package main
import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
func (app *App) RefreshScripts() error {
app.scripts = make(map[string]string)
scriptDir := path.Join(app.cfgHome, "scripts")
if _, err := os.Stat(scriptDir); errors.Is(err, fs.ErrNotExist) {
return nil
}
return filepath.Walk(scriptDir, func(p string, i os.FileInfo, err error) error {
switch {
case err != nil:
return err
case i.IsDir():
return nil
default:
if data, err := os.ReadFile(p); err != nil {
return err
} else {
app.scripts[path.Base(p)] = string(data)
return nil
}
}
})
}
func (app *App) LoadScript(name string) error {
if script, ok := app.scripts[name]; ok {
for line := range strings.Lines(script) {
cmdName, rest, _ := strings.Cut(strings.TrimSpace(line), " ")
if cmd, ok := CommandMap[cmdName]; ok {
cmd(app, rest)
} else {
return fmt.Errorf("unknown command \"%s\"", cmdName)
}
}
} else {
return fmt.Errorf("not found")
}
return nil
}