Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ ui:
| `work_args` | | its arguments before the prompt; `{key}` is replaced |
| `work_create` | | the command that makes a missing worktree (`{branch} {base} {key}`); unset: herdr |
| `agent_view` | `fullscreen` | `panel` attaches an agent in the panel. Terminal |
| `multiplexer` | herdr | another multiplexer speaking herdr's socket API and CLI: `command` (its CLI), `socket` (its API socket), `inside_env` (the variable it sets in its panes) |
| `llm` | `claude -p` when on the PATH | the ask command: the question last, the issue on stdin (`llm`, `ollama run llama3`) |
| `actions` | | your own commands on an issue, see [Actions](#actions) |

Expand Down
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ type RulesTest struct {
Status string `yaml:"status"`
}

// MultiplexerConfig points laneway at a terminal multiplexer that speaks
// herdr's socket API and CLI (`<command> agent attach|focus <pane>`).
type MultiplexerConfig struct {
// Command is its CLI ("herdr").
Command string `yaml:"command"`
// Socket is its API socket; empty finds herdr's.
Socket string `yaml:"socket"`
// InsideEnv is the environment variable it sets in its panes, telling
// laneway it runs inside it ("HERDR_ENV").
InsideEnv string `yaml:"inside_env"`
}

// UIConfig tunes the app; every field is optional and "" / 0 keeps the
// default (see ui.optionsFrom).
type UIConfig struct {
Expand Down Expand Up @@ -362,6 +374,9 @@ type UIConfig struct {
// AgentView is where an attached agent shows: fullscreen (laneway steps
// aside) or panel (in the issue panel).
AgentView string `yaml:"agent_view"`
// Multiplexer is the herdr-compatible terminal multiplexer agents run in;
// unset, herdr.
Multiplexer MultiplexerConfig `yaml:"multiplexer"`
// WorkArgs are arguments the agent gets before the start prompt, with
// {key} replaced ([--append-system-prompt, "User is working on {key}"]).
WorkArgs []string `yaml:"work_args"`
Expand Down
8 changes: 5 additions & 3 deletions internal/config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import "github.com/cornedor/laneway/internal/i18n"
// What the settings screens (terminal and web) say about each ui: option.

// SettingsRestart are options read once at startup.
var SettingsRestart = map[string]bool{"images": true, "threaded_replies": true, "image_max_rows": true, "card_limit": true, "default_mode": true, "flag_value": true, "inbox_issues": true, "custom_fields": true, "language": true}
var SettingsRestart = map[string]bool{"images": true, "threaded_replies": true, "image_max_rows": true, "card_limit": true, "default_mode": true, "flag_value": true, "inbox_issues": true, "custom_fields": true, "language": true, "multiplexer": true}

// SettingsTerminal are options only the terminal reads; the browser has
// its own for some (theme, under Appearance).
var SettingsTerminal = map[string]bool{"theme": true, "icons": true, "mouse": true, "double_click": true, "images": true, "image_max_rows": true, "code_theme": true, "full_refresh": true}

// SettingsCommand are options laneway runs as commands (or passes to one).
var SettingsCommand = map[string]bool{"actions": true, "llm": true, "activity": true, "open": true, "clipboard_image": true, "work_agent": true, "work_args": true, "work_create": true}
var SettingsCommand = map[string]bool{"actions": true, "llm": true, "activity": true, "open": true, "clipboard_image": true, "work_agent": true, "work_args": true, "work_create": true, "multiplexer": true}

// SettingsBasic are the options for making laneway your own: looks, layouts,
// views. The rest are specifics and tuning, which the browser's settings
Expand Down Expand Up @@ -95,6 +95,7 @@ var SettingDefaults = map[string]string{
"flag_value": "Impediment",
"work_agent": "claude",
"agent_view": "fullscreen",
"multiplexer": "herdr",
"work_args": "none",
"work_create": "herdr creates the worktree",
"code_theme": "the preset's, else monokai",
Expand All @@ -117,7 +118,7 @@ var SettingGroups = []SettingGroup{
{i18n.N("Panel"), []string{"panel_width", "empty_fields", "date_format", "images", "image_max_rows", "templates", "code_theme", "threaded_replies", "comment_order", "comment_layout"}},
{i18n.N("Refresh"), []string{"auto_refresh", "stale_after", "full_refresh"}},
{i18n.N("Time and worklogs"), []string{"timer_round", "timer_on_start", "workday_start", "workdays", "capacity", "activity", "calendar", "meeting_key"}},
{i18n.N("Start work and agents"), []string{"start_assigns", "start_status", "branch_template", "work_branch_template", "work_agent", "work_args", "work_create", "agent_view", "llm", "actions"}},
{i18n.N("Start work and agents"), []string{"start_assigns", "start_status", "branch_template", "work_branch_template", "work_agent", "work_args", "work_create", "agent_view", "multiplexer", "llm", "actions"}},
{i18n.N("Planning, roadmap and charts"), []string{"velocity_sprints", "report_done", "report_backwards", "roadmap_epic_type", "roadmap_done_days"}},
{i18n.N("Inbox"), []string{"inbox_every", "inbox_lookback", "inbox_issues"}},
{i18n.N("Standup"), []string{"standup_start", "standup_lookback", "standup_length", "standup_timebox", "standup_shuffle", "standup_timer"}},
Expand Down Expand Up @@ -201,6 +202,7 @@ var SettingDocs = map[string]string{
"flag_value": i18n.N("the Flagged field's option flagging sets"),
"work_agent": i18n.N("the herdr agent kind S launches"),
"agent_view": i18n.N("where an attached agent shows: fullscreen or in the panel"),
"multiplexer": i18n.N("the herdr-compatible multiplexer agents run in: its command, socket and inside_env"),
"work_args": i18n.N("the agent's arguments before the start prompt, {key} replaced"),
"work_create": i18n.N("a command that makes a missing worktree: {branch}, {base}, {key}"),
"code_theme": i18n.N("the chroma style code blocks use (terminal only: the browser's follow its theme)"),
Expand Down
8 changes: 8 additions & 0 deletions internal/herdr/herdr.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ func Default() *Client {
return &Client{path: path}
}

// At addresses the socket at path, nil when nothing listens there.
func At(path string) *Client {
if fi, err := os.Stat(path); err != nil || fi.Mode()&os.ModeSocket == 0 {
return nil
}
return &Client{path: path}
}

// New addresses the socket at path.
func New(path string) *Client { return &Client{path: path} }

Expand Down
15 changes: 15 additions & 0 deletions internal/herdr/herdr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,18 @@ func TestWorktree(t *testing.T) {
t.Errorf("worktree = %+v", wt)
}
}

func TestAtNeedsASocket(t *testing.T) {
if At(filepath.Join(t.TempDir(), "missing.sock")) != nil {
t.Fatal("At must be nil without a socket")
}
path := filepath.Join(t.TempDir(), "s.sock")
l, err := net.Listen("unix", path)
if err != nil {
t.Fatal(err)
}
defer l.Close()
if c := At(path); c == nil || c.Path() != path {
t.Fatalf("At(%s) = %v", path, c)
}
}
1 change: 1 addition & 0 deletions internal/i18n/nl/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"where U opens: everyone, or the first person": "waar U opent: `everyone` of de eerste persoon",
"where a lane card's fields sit: top, top_right, bottom, bottom_right around the summary": "waar de velden van een kaart in een baan staan: top, top_right, bottom, bottom_right rond de samenvatting",
"where an attached agent shows: fullscreen or in the panel": "waar een gekoppelde agent verschijnt: `fullscreen` of in het paneel",
"the herdr-compatible multiplexer agents run in: its command, socket and inside_env": "de herdr-compatibele multiplexer waarin agents draaien: zijn `command`, `socket` en `inside_env`",
"where attachments are saved": "waar bijlagen worden opgeslagen",
"your own commands on the issue: in the palette, on a key if given": "je eigen commando's op het issue: in het palet, op een toets als die is opgegeven",
"your own lanes over a board's columns: stacked, split by status, reordered, renamed, hidden; alt+l switches": "je eigen banen over de kolommen van een bord: gestapeld, per status gesplitst, herordend, hernoemd, verborgen; alt+l wisselt",
Expand Down
24 changes: 21 additions & 3 deletions internal/ui/agents.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ui

import (
"cmp"
"context"
"errors"
"github.com/cornedor/laneway/internal/i18n"
Expand Down Expand Up @@ -154,6 +155,23 @@ func (m *Model) agentMark(key string) string {
// herdrBin is the herdr CLI; tests swap it.
var herdrBin = "herdr"

// multiplexer is the API client for ui.multiplexer's socket, else herdr's;
// nil when nothing runs.
func multiplexer(o options) *herdr.Client {
if o.muxSocket != "" {
return herdr.At(expandUserPath(o.muxSocket))
}
return herdr.Default()
}

// muxBin is the multiplexer CLI: ui.multiplexer.command, else herdr.
func (m *Model) muxBin() string { return cmp.Or(m.opts.muxCommand, herdrBin) }

// insideMux is whether laneway runs in one of the multiplexer's panes.
func (m *Model) insideMux() bool {
return os.Getenv(cmp.Or(m.opts.muxInsideEnv, "HERDR_ENV")) != ""
}

// statusMark is one agent's state as a mark.
func statusMark(s herdr.Status) string {
switch s {
Expand Down Expand Up @@ -184,16 +202,16 @@ func (m *Model) attachAgentIn(key, pane string, panel bool) tea.Cmd {
m.status = i18n.T("attach needs herdr running")
return nil
}
bin, err := exec.LookPath(herdrBin)
bin, err := exec.LookPath(m.muxBin())
if err != nil {
m.fail(i18n.T("attach: no herdr on PATH"))
return nil
}
done := func(err error) tea.Msg { return agentAttachedMsg{key: key, err: err} }
if os.Getenv("HERDR_ENV") != "1" && panel && m.opts.agentView == "panel" {
if !m.insideMux() && panel && m.opts.agentView == "panel" {
return m.openAgentPanel(key, pane, bin)
}
if os.Getenv("HERDR_ENV") == "1" {
if m.insideMux() {
cmd := herdrCommand(bin, m.herdr.Path(), "agent", "focus", pane)
m.status = i18n.Tf("%s: focusing its agent", key)
return func() tea.Msg { return done(cmd.Run()) }
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/agents_screen.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ func (m Model) handleAgentTermDue(msg agentTermDueMsg) (tea.Model, tea.Cmd) {
}
m.closeAgentPanel() // the one terminal, in the panel or here
s.termFor, s.termErr, s.typing = r.agent.PaneID, "", false
bin, err := exec.LookPath(herdrBin)
bin, err := exec.LookPath(m.muxBin())
if err != nil {
s.termErr = i18n.T("no herdr on PATH")
return m, nil
Expand Down
34 changes: 34 additions & 0 deletions internal/ui/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (
"encoding/json"
"errors"
"net"
"os"
"path/filepath"
"strings"
"testing"

tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"

"github.com/cornedor/laneway/internal/config"
"github.com/cornedor/laneway/internal/herdr"
"github.com/cornedor/laneway/internal/jira"
)
Expand Down Expand Up @@ -347,3 +349,35 @@ func TestAgentMarkClick(t *testing.T) {
})
}
}

// TestAttachUsesConfiguredMultiplexer: with ui.multiplexer set, attaching from
// inside it runs its CLI's agent focus, not herdr's, and its inside_env counts.
func TestAttachUsesConfiguredMultiplexer(t *testing.T) {
t.Setenv("HERDR_ENV", "")
t.Setenv("OTHER_MUX_PANE", "p9")
dir := t.TempDir()
log := filepath.Join(dir, "calls")
bin := filepath.Join(dir, "othermux")
script := "#!/bin/sh\necho \"$@\" >> " + log + "\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
opts, warn := optionsFrom(config.UIConfig{Multiplexer: config.MultiplexerConfig{Command: bin, InsideEnv: "OTHER_MUX_PANE"}})
if len(warn) > 0 {
t.Fatalf("warnings: %v", warn)
}
m := jiraTabModel(t)
m.opts.muxCommand, m.opts.muxInsideEnv = opts.muxCommand, opts.muxInsideEnv
m.herdr = herdr.New("/nowhere.sock")
cmd := m.attachAgent("ABC-1", "p1")
if cmd == nil {
t.Fatalf("attach gave no command: %q", m.status)
}
if msg, ok := cmd().(agentAttachedMsg); !ok || msg.err != nil {
t.Fatalf("attach: %#v", msg)
}
got, err := os.ReadFile(log)
if err != nil || strings.TrimSpace(string(got)) != "agent focus p1" {
t.Fatalf("multiplexer CLI got %q (%v), want agent focus p1", got, err)
}
}
2 changes: 1 addition & 1 deletion internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@ func New(ctx context.Context, cfg config.JiraConfig, ui config.UIConfig, rs []ru
rules: ruleSet,
actions: actions,
rulesLog: rulesLog,
herdr: herdr.Default(),
herdr: multiplexer(opts),
refView: viewport.New(),
fieldCursor: -1,
started: time.Now(),
Expand Down
6 changes: 6 additions & 0 deletions internal/ui/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ type options struct {
workBranch string // start work's new branch
workAgent string // the herdr agent kind start work launches
agentView string // where an attached agent shows: fullscreen or panel
muxCommand string // multiplexer CLI; "" is herdrBin
muxSocket string // multiplexer API socket; "" finds herdr's
muxInsideEnv string // env var the multiplexer sets in its panes; "" is HERDR_ENV
workArgs []string // its arguments before the start prompt
workCreate []string // the command that makes a missing worktree, nil: herdr's
kanbanDoneDays int // done work older than this leaves kanban boards
Expand Down Expand Up @@ -173,6 +176,9 @@ func optionsFrom(c config.UIConfig) (options, []string) {
default:
warn = append(warn, i18n.Tf("ui.agent_view: %q is not fullscreen or panel", c.AgentView))
}
o.muxCommand = strings.TrimSpace(c.Multiplexer.Command)
o.muxSocket = strings.TrimSpace(c.Multiplexer.Socket)
o.muxInsideEnv = strings.TrimSpace(c.Multiplexer.InsideEnv)
switch v := strings.ToLower(strings.TrimSpace(c.CardColors)); v {
case "":
case "ribbon", "off":
Expand Down
41 changes: 28 additions & 13 deletions internal/web/api_agents.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package web

import (
"cmp"
"context"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -30,6 +31,20 @@ var herdrClient = herdr.Default
// herdrBin is the herdr CLI, for reading a pane and focusing it.
var herdrBin = "herdr"

// muxClient is ui.multiplexer's API socket, else the running herdr server;
// nil for none.
func muxClient(s *Server) *herdr.Client {
if p := strings.TrimSpace(s.UIConfig().Multiplexer.Socket); p != "" {
return herdr.At(expandUserPath(p))
}
return herdrClient()
}

// muxBin is ui.multiplexer's CLI, else herdr.
func muxBin(s *Server) string {
return cmp.Or(strings.TrimSpace(s.UIConfig().Multiplexer.Command), herdrBin)
}

// AgentOut is one agent as the browser gets it.
type AgentOut struct {
PaneID, Name, WorkspaceID, TabID, Agent string
Expand Down Expand Up @@ -119,9 +134,9 @@ func agentIssues(ctx context.Context, s *Server, r *http.Request) (any, error) {
}

func agentsStatus(ctx context.Context, s *Server, r *http.Request) (any, error) {
_, cliErr := exec.LookPath(herdrBin)
_, cliErr := exec.LookPath(muxBin(s))
return map[string]any{
"Available": herdrClient() != nil,
"Available": muxClient(s) != nil,
"CLI": cliErr == nil,
"GH": cli.Have("gh"),
"GLab": cli.Have("glab"),
Expand Down Expand Up @@ -176,7 +191,7 @@ func unavailable() AgentsSnapshot {
}

func snapshot(ctx context.Context, s *Server) AgentsSnapshot {
c := herdrClient()
c := muxClient(s)
if c == nil {
return unavailable()
}
Expand Down Expand Up @@ -221,7 +236,7 @@ func agentEvents(s *Server, w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "retry: 3000\n\n")
fl.Flush()
for ctx.Err() == nil {
c := herdrClient()
c := muxClient(s)
if c == nil {
send(unavailable())
if !sleep(10 * time.Second) {
Expand Down Expand Up @@ -307,8 +322,8 @@ func paneIDs(as []herdr.Agent) []string {
return ids
}

func needHerdr() (*herdr.Client, error) {
c := herdrClient()
func needHerdr(s *Server) (*herdr.Client, error) {
c := muxClient(s)
if c == nil {
return nil, httpError{http.StatusServiceUnavailable, i18n.T("herdr is not running")}
}
Expand Down Expand Up @@ -341,7 +356,7 @@ func paneAgent(ctx context.Context, c *herdr.Client, r *http.Request) (herdr.Age

// agentOutput is the pane's recent terminal text, read through the herdr CLI.
func agentOutput(ctx context.Context, s *Server, r *http.Request) (any, error) {
c, err := needHerdr()
c, err := needHerdr(s)
if err != nil {
return nil, err
}
Expand All @@ -353,7 +368,7 @@ func agentOutput(ctx context.Context, s *Server, r *http.Request) (any, error) {
if n, err := strconv.Atoi(Q(r, "lines")); err == nil && n > 0 && n <= 1000 {
lines = n
}
bin, err := exec.LookPath(herdrBin)
bin, err := exec.LookPath(muxBin(s))
if err != nil {
return nil, httpError{http.StatusNotImplemented, i18n.T("no herdr on PATH to read the terminal")}
}
Expand All @@ -369,7 +384,7 @@ func agentOutput(ctx context.Context, s *Server, r *http.Request) (any, error) {
}

func agentPrompt(ctx context.Context, s *Server, r *http.Request) (any, error) {
c, err := needHerdr()
c, err := needHerdr(s)
if err != nil {
return nil, err
}
Expand All @@ -394,15 +409,15 @@ func agentPrompt(ctx context.Context, s *Server, r *http.Request) (any, error) {

// agentFocus brings the pane to the front in herdr.
func agentFocus(ctx context.Context, s *Server, r *http.Request) (any, error) {
c, err := needHerdr()
c, err := needHerdr(s)
if err != nil {
return nil, err
}
a, err := paneAgent(ctx, c, r)
if err != nil {
return nil, err
}
bin, err := exec.LookPath(herdrBin)
bin, err := exec.LookPath(muxBin(s))
if err != nil {
return nil, httpError{http.StatusNotImplemented, i18n.T("no herdr on PATH")}
}
Expand All @@ -416,7 +431,7 @@ func agentFocus(ctx context.Context, s *Server, r *http.Request) (any, error) {

// agentStop closes the agent's tab.
func agentStop(ctx context.Context, s *Server, r *http.Request) (any, error) {
c, err := needHerdr()
c, err := needHerdr(s)
if err != nil {
return nil, err
}
Expand All @@ -429,7 +444,7 @@ func agentStop(ctx context.Context, s *Server, r *http.Request) (any, error) {

// agentNew starts another agent in the same directory as the one in pane.
func agentNew(ctx context.Context, s *Server, r *http.Request) (any, error) {
c, err := needHerdr()
c, err := needHerdr(s)
if err != nil {
return nil, err
}
Expand Down
Loading
Loading