Compare commits

...

10 commits

Author SHA1 Message Date
4990c6d26a add default.nix 2025-12-24 16:25:18 -03:00
5701062c7f send StatEvent on MessageEvent outgoing handler
avoids duplicate "not connected to any server" messages when attempting
to send messages while disconnected (because of the outgoing event loop
handling that message)
2025-10-29 22:21:02 -03:00
b091174e5a delete screenshot.png 2025-10-29 14:13:55 -03:00
f7e904c6a4 change module name to new upstream 2025-10-29 13:38:45 -03:00
6c66b32396 handle offline status correctly and other changes 2025-10-29 12:50:59 -03:00
dcc018efec upstream notice 2025-10-24 12:06:08 -03:00
70d95cdcce add scripts 2025-10-24 11:59:29 -03:00
3412dd7900 fix freeze when config dir can't be created 2025-10-16 12:39:10 -03:00
d3b3a62f28 small changes 2025-10-16 12:29:45 -03:00
77b1a93b30 reorder some files 2025-10-14 17:19:31 -03:00
11 changed files with 378 additions and 255 deletions

View file

@ -1,42 +1,30 @@
# nanite
`nanite` is a terminal [Nanochat] client.
`nanite` is a terminal client for the [Nanochat] protocol.
![](./assets/screenshot.png)
Requires Go 1.24.4 or higher. Build using `make build`.
## build
> The upstream URL for this repository is <https://git.rhzm.org/lobo/nanite>.
> The repositories hosted on GitHub and Codeberg are mirrors.
```
$ go build .
```
## usage
```
$ ./nanite
usage: ./nanite host [port]
$ ./nanite very.real-server.com
```
keybindings:
# keybindings
- `Ctrl+C`: quit
- `Ctrl+L`: refresh screen
- `Ctrl+P`: poll
- Emacs-like bindings for text editing
commands:
# commands
- `/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)
- `/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
[Nanochat]: https://git.phial.org/d6/nanochat
[Vaxis]: https://git.sr.ht/~rockorager/vaxis

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 KiB

View file

@ -2,129 +2,116 @@ package main
import (
"fmt"
"maps"
"os"
"path"
"slices"
"strconv"
"strings"
"time"
)
func (app *App) Stat() (res string, err error) {
if _, err := app.conn.Write([]byte("STAT\n")); err != nil {
return "", err
}
type Command func(*App, 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(' ')
}
var CommandMap map[string]Command
var AliasMap map[string]string
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
}
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()
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(' ')
}
app.incoming <- MessageEvent(app.conn.Scanner.Text())
}
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")
}
var s strings.Builder
for _, script := range slices.Sorted(maps.Keys(app.scripts)) {
s.WriteString(script)
s.WriteRune(' ')
}
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)
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())
}
}
}
},
"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()
},
}
var last int
if !app.conn.Scanner.Scan() {
return 0, app.conn.Scanner.Err()
AliasMap = map[string]string {
"q": "quit",
".": "script",
}
lastRaw := app.conn.Scanner.Text()
last, err = strconv.Atoi(lastRaw)
if err != nil {
return 0, err
}
app.incoming <- SetLastEvent(last)
return nsrv, nil
}

7
default.nix Normal file
View file

@ -0,0 +1,7 @@
{ 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"
"codeberg.org/lobo/nanite/widgets/pager"
"git.rhzm.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 {
message := fmt.Sprintf("%s: %s", app.nick, app.input.String())
app.AppendMessage(message)
app.outgoing <- MessageEvent(message)
app.outgoing <- StatEvent("")
app.outgoing <- MessageEvent(fmt.Sprintf("%s: %s", app.nick, app.input.String()))
}
app.input.SetContent("")
@ -108,6 +108,10 @@ 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,6 +11,7 @@ import (
type IncomingEvent interface {
HandleIncoming(*App)
}
type OutgoingEvent interface {
HandleOutgoing(*App) error
}
@ -27,7 +28,9 @@ 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))
@ -51,8 +54,8 @@ func (ev DialEvent) HandleOutgoing(app *App) error {
if err != nil {
return err
}
delta := time.Since(latStart).Round(time.Second)
delta = min(max(time.Second, delta*3/2), 5*time.Second)
delta := time.Since(latStart)
delta = min(max(time.Second, delta*3/2), 5*time.Second).Round(time.Second)
app.conn.rate = delta
app.conn.ticker = time.NewTicker(delta)
@ -75,9 +78,13 @@ func (ev DialEvent) HandleOutgoing(app *App) error {
return nil
}
type HangupEvent struct{}
type HangupEvent struct{ host, port string }
func (ev HangupEvent) HandleOutgoing(app *App) error {
func (ev HangupEvent) HandleIncoming(app *App) {
app.AppendSystemMessage("disconnected from %s:%s", ev.host, ev.port)
}
func (_ HangupEvent) HandleOutgoing(app *App) error {
if app.conn == nil {
app.incoming <- SystemMessageEvent("not connected to any server")
return nil
@ -92,7 +99,7 @@ func (ev HangupEvent) HandleOutgoing(app *App) error {
app.conn.ticker.Stop()
app.conn.ticker = nil
app.conn = nil
app.incoming <- SystemMessageEvent(fmt.Sprintf("disconnected from %s:%s", host, port))
app.incoming <- HangupEvent{host, port}
return nil
}
@ -111,11 +118,13 @@ 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
}
@ -171,9 +180,9 @@ func (ev ManualPollEvent) HandleOutgoing(app *App) error {
func (ev ManualPollEvent) HandleIncoming(app *App) {
if int(ev) == 0 {
app.AppendSystemMessage("poll: no new messages")
app.AppendSystemMessage("no new messages")
} else {
app.AppendSystemMessage("poll: retrieving %d messages", ev)
app.AppendSystemMessage("retrieving %d messages", ev)
}
}

2
go.mod
View file

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

134
main.go
View file

@ -8,97 +8,15 @@ import (
"net"
"os"
"path"
"strconv"
"strings"
"time"
"codeberg.org/lobo/nanite/widgets/pager"
"git.rhzm.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
@ -113,12 +31,15 @@ type App struct {
outgoing chan OutgoingEvent
error chan error
vx *vaxis.Vaxis
w struct {
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
}
@ -133,12 +54,15 @@ type Conn struct {
}
func (app *App) AppendMessage(data string) {
// TODO: make messages without a nick italic
nick, _, found := strings.Cut(data, ": ")
style := vaxis.Style{}
if !found || strings.TrimSpace(nick) != nick {
style.Attribute = vaxis.AttrItalic
}
app.pager.Segments = append(app.pager.Segments,
vaxis.Segment{Text: data},
vaxis.Segment{Text: data, Style: style},
vaxis.Segment{Text: "\n"},
)
app.last += 1
app.pager.Offset = math.MaxInt
}
@ -154,7 +78,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
}
@ -167,7 +91,7 @@ func (app *App) EnsureConfigDir() error {
app.cfgHome = path.Join(userCfg, "nanite")
stat, err := os.Stat(app.cfgHome)
if err != nil {
if err := os.Mkdir(app.cfgHome, 0o700); err != nil {
if err := os.MkdirAll(app.cfgHome, 0o700); err != nil {
return err
}
} else {
@ -186,16 +110,25 @@ func NewApp() *App {
app.error = make(chan error)
if err := app.EnsureConfigDir(); err != nil {
app.error <- err
panic(err)
}
if err := app.RefreshScripts(); err != nil {
panic(err)
}
go func() {
for {
select {
case ev := <-app.outgoing:
if err := ev.HandleOutgoing(app); err != nil {
app.error <- err
return
_, 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
@ -227,7 +160,13 @@ func (app *App) Loop() error {
func (app *App) Finish() {
app.stop()
app.FinishUI()
HangupEvent{}.HandleOutgoing(app)
if app.conn != nil {
HangupEvent{}.HandleOutgoing(app)
}
}
func init() {
initCommandMap()
}
func main() {
@ -253,6 +192,7 @@ func main() {
app.AppendSystemMessage("welcome to nanite! :3")
if err := app.Loop(); err != nil {
app.FinishUI()
panic(err)
}
}

9
makefile Normal file
View file

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

127
protocol.go Normal file
View file

@ -0,0 +1,127 @@
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
}

52
script.go Normal file
View file

@ -0,0 +1,52 @@
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
}