8 Commits

Author SHA1 Message Date
github-actions[bot]
8e6ded3040 chore(master): release 2.4.1 2026-04-21 10:46:08 +03:00
627fde8b01 fix: use -B for background flag to prevent flags conflict 2026-04-21 10:44:28 +03:00
github-actions[bot]
53c0c4654b chore(master): release 2.4.0 2026-04-08 00:17:00 +03:00
54fa59be46 feat: add background flag 2026-04-08 00:02:37 +03:00
github-actions[bot]
022b4de720 chore(master): release 2.3.0 2026-04-04 23:28:48 +03:00
7a9ea1b41d feat: ls sessions as table 2026-04-04 23:27:17 +03:00
github-actions[bot]
779940ea3d chore(master): release 2.2.0 2026-03-24 11:31:05 +02:00
d067a6e964 feat: allow pane size config 2026-03-24 11:28:27 +02:00
13 changed files with 212 additions and 56 deletions

View File

@@ -1,5 +1,33 @@
# Changelog
## [2.4.1](https://github.com/chenasraf/tx/compare/v2.4.0...v2.4.1) (2026-04-21)
### Bug Fixes
* use -B for background flag to prevent flags conflict ([627fde8](https://github.com/chenasraf/tx/commit/627fde8b01f685ff8ba0705fd2735b89fd92b356))
## [2.4.0](https://github.com/chenasraf/tx/compare/v2.3.0...v2.4.0) (2026-04-07)
### Features
* add background flag ([54fa59b](https://github.com/chenasraf/tx/commit/54fa59be464c226e9da0510b8d2a290023782d4a))
## [2.3.0](https://github.com/chenasraf/tx/compare/v2.2.0...v2.3.0) (2026-04-04)
### Features
* ls sessions as table ([7a9ea1b](https://github.com/chenasraf/tx/commit/7a9ea1b41d1fff87cbd160409453502b56dad29d))
## [2.2.0](https://github.com/chenasraf/tx/compare/v2.1.0...v2.2.0) (2026-03-24)
### Features
* allow pane size config ([d067a6e](https://github.com/chenasraf/tx/commit/d067a6e964f3ccd6c7e1cf322aa3997e943381c5))
## [2.1.0](https://github.com/chenasraf/tx/compare/v2.0.0...v2.1.0) (2026-03-23)

View File

@@ -82,24 +82,32 @@ tx create -S # save only (don't create)
tx prj [name]
tx prj -s # save to config
# Create session in background (don't switch to it)
tx -b my-session
tx create -b
tx prj -b myproject
# Attach to existing session
tx attach [name]
# Remove a configuration
# Remove configurations
tx rm <name>
tx rm foo bar baz # remove multiple at once
tx rm <name> -c ~/local.yaml # remove from a specific config file
# Kill a running session
# Kill running sessions
tx kill # kill current session
tx kill <name> # kill specific session
tx kill foo bar baz # kill multiple sessions
```
### Global Flags
| Flag | Description |
| --------------- | ----------------------------------------- |
| `-v, --verbose` | Verbose logging |
| `-d, --dry` | Dry run (show commands without executing) |
| Flag | Description |
| ------------------ | ---------------------------------------------------- |
| `-V, --verbose` | Verbose logging |
| `-d, --dry` | Dry run (show commands without executing) |
| `-b, --background` | Create session in background without switching to it |
---
@@ -202,7 +210,27 @@ windows:
### Layout Configuration
Layouts define pane splits and commands:
Layouts define pane splits and commands. When no layout is specified, the following default is used:
```yaml
# Default layout - horizontal split with vertical sub-split and clock
layout:
cwd: .
split:
direction: h
child:
cwd: .
split:
direction: v
child:
cwd: .
clock: true
```
You can customize the default for all windows via [`default_layout`](#default-layout) in global
settings, or override per window.
#### Layout Formats
**String** - just a directory:
@@ -229,16 +257,36 @@ layout:
clock: false # show tmux clock mode
split:
direction: h # h (horizontal) or v (vertical)
size: 30 # percentage of space for the child pane (1-100, default: 50)
child:
cwd: ./other
cmd: npm test
split: # nested splits
direction: v
size: 40
child:
cwd: .
clock: true # show clock in this pane
```
#### Pane Options
| Setting | Description |
| ------- | ---------------------------------------- |
| `cwd` | Working directory (defaults to window's) |
| `cmd` | Command to run in the pane |
| `zoom` | Zoom this pane (default: false) |
| `clock` | Show tmux clock mode (default: false) |
| `split` | Split this pane (see below) |
#### Split Options
| Setting | Description |
| ----------- | ----------------------------------------------------------- |
| `direction` | `h` (horizontal / side-by-side) or `v` (vertical / stacked) |
| `size` | Percentage of space for the child pane (1-100, default: 50) |
| `child` | Pane configuration for the new pane created by the split |
### Global Settings
The special `.config` key is reserved for global settings and won't be treated as a session:
@@ -266,17 +314,8 @@ myproject:
#### Default Layout
The `default_layout` setting configures the default pane arrangement for windows. Each pane can
have:
| Setting | Description |
| ------- | ------------------------------------------------------------- |
| `cwd` | Working directory (defaults to window's directory) |
| `cmd` | Command to run (defaults to none) |
| `clock` | Show tmux clock mode (defaults to false) |
| `split` | Create a split with direction (`h` or `v`) and a `child` pane |
Example - single pane with clock:
The `default_layout` setting overrides the built-in default pane arrangement for all windows. It
accepts the same [pane options](#pane-options) as a regular layout.
```yaml
.config:
@@ -285,23 +324,6 @@ Example - single pane with clock:
clock: true
```
Example - horizontal split with vertical sub-split (default):
```yaml
.config:
default_layout:
cwd: .
split:
direction: h
child:
cwd: .
split:
direction: v
child:
cwd: .
clock: true
```
#### Named Layouts
Define reusable layouts that can be referenced by name in session configurations:
@@ -430,6 +452,9 @@ tx create -r ~/myproject -w src -w lib -w test
# Create and save to config
tx create -r ~/myproject -s
# Create in background (don't switch to it)
tx create -b -r ~/myproject
```
### Project Workflow

View File

@@ -75,6 +75,10 @@ func runCreate(cmd *cobra.Command, args []string) error {
// Check if session exists
if tmux.SessionExists(opts, parsed.Name) {
if background {
exec.Log(opts, "Session already exists (background mode, not attaching)")
return nil
}
exec.Log(opts, "Session already exists, attaching")
return tmux.AttachToSession(opts, parsed.Name)
}
@@ -92,5 +96,5 @@ func runCreate(cmd *cobra.Command, args []string) error {
}
// Create the session
return tmux.CreateFromConfig(opts, parsed)
return tmux.CreateFromConfig(opts, parsed, background)
}

View File

@@ -2,10 +2,12 @@ package cli
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/table"
"github.com/chenasraf/tx/internal/tmux"
"github.com/spf13/cobra"
)
@@ -61,13 +63,7 @@ func runList(cmd *cobra.Command, args []string) error {
sessionsOutput, err := tmux.ListSessions(opts)
sessionsStr := ""
if err == nil && sessionsOutput != "" {
// Format sessions output
lines := strings.Split(strings.TrimSpace(sessionsOutput), "\n")
for _, line := range lines {
if line != "" {
sessionsStr += " " + line + "\n"
}
}
sessionsStr = formatSessionsTable(sessionsOutput, " ")
} else {
sessionsStr = " No tmux sessions\n"
}
@@ -107,3 +103,28 @@ func runList(cmd *cobra.Command, args []string) error {
return nil
}
var sessionLineRe = regexp.MustCompile(`^([^:]+):\s*(\d+)\s+windows?\s*\(created\s+([^)]+)\)\s*(.*)$`)
// formatSessionsTable parses `tmux ls` output and renders it as a bordered
// table with headers. Each output line is prefixed with indent.
func formatSessionsTable(raw, indent string) string {
lines := strings.Split(strings.TrimSpace(raw), "\n")
rows := make([][]string, 0, len(lines))
for _, line := range lines {
if line == "" {
continue
}
m := sessionLineRe.FindStringSubmatch(line)
if m == nil {
// Fallback: dump the whole line into the Name column
rows = append(rows, []string{line, "", "", ""})
continue
}
status := strings.TrimSpace(m[4])
status = strings.TrimPrefix(status, "(")
status = strings.TrimSuffix(status, ")")
rows = append(rows, []string{m[1], m[2], m[3], status})
}
return table.Render([]string{"Name", "Windows", "Created", "Status"}, rows, indent)
}

View File

@@ -53,10 +53,14 @@ func runMain(cmd *cobra.Command, args []string) error {
// Check if session exists
if tmux.SessionExists(opts, parsed.Name) {
if background {
exec.Log(opts, "Session exists (background mode, not attaching)")
return nil
}
exec.Log(opts, "Session exists, attaching...")
return tmux.AttachToSession(opts, parsed.Name)
}
// Create session
return tmux.CreateFromConfig(opts, parsed)
return tmux.CreateFromConfig(opts, parsed, background)
}

View File

@@ -119,7 +119,7 @@ func runPrj(cmd *cobra.Command, args []string) error {
}
// Create session
return tmux.CreateFromConfig(opts, parsed)
return tmux.CreateFromConfig(opts, parsed, background)
}
// getProjects returns directory names in the given path

View File

@@ -16,8 +16,9 @@ var (
Version string
// Global flags
verbose bool
dry bool
verbose bool
dry bool
background bool
)
// GetOpts returns the current execution options
@@ -166,6 +167,7 @@ func init() {
// Global flags
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "V", false, "Verbose logging")
rootCmd.PersistentFlags().BoolVarP(&dry, "dry", "d", false, "Dry run (log commands, don't execute)")
rootCmd.PersistentFlags().BoolVarP(&background, "background", "B", false, "Create session in background without attaching")
rootCmd.Flags().BoolP("version", "v", false, "Print version")
// Add subcommands

View File

@@ -188,6 +188,7 @@ func parsePaneLayout(pane *TmuxPaneLayout, root string) TmuxPaneLayout {
if pane.Split != nil {
result.Split = &TmuxSplitLayout{
Direction: pane.Split.Direction,
Size: pane.Split.Size,
}
if result.Split.Direction == "" {
result.Split.Direction = "h"
@@ -208,6 +209,7 @@ func copyTmuxSplitLayout(split *TmuxSplitLayout, root string) *TmuxSplitLayout {
}
result := &TmuxSplitLayout{
Direction: split.Direction,
Size: split.Size,
}
if split.Child != nil {
child := TmuxPaneLayout{

View File

@@ -141,7 +141,8 @@ type TmuxPaneLayout struct {
// TmuxSplitLayout represents a split configuration
type TmuxSplitLayout struct {
Direction string `yaml:"direction"` // "h" or "v"
Direction string `yaml:"direction"` // "h" or "v"
Size int `yaml:"size,omitempty"` // percentage (1-100) of the split given to the child pane
Child *TmuxPaneLayout `yaml:"child"`
}

57
internal/table/table.go Normal file
View File

@@ -0,0 +1,57 @@
// Package table renders simple bordered text tables with headers.
package table
import (
"strings"
"unicode/utf8"
)
// Render returns a bordered table string with the given headers and rows.
// Every line in the output is prefixed with indent. Column widths auto-size
// to the widest cell (header included).
func Render(headers []string, rows [][]string, indent string) string {
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = utf8.RuneCountInString(h)
}
for _, row := range rows {
for i, cell := range row {
if i >= len(widths) {
break
}
if w := utf8.RuneCountInString(cell); w > widths[i] {
widths[i] = w
}
}
}
border := func(left, mid, right string) string {
parts := make([]string, len(widths))
for i, w := range widths {
parts[i] = strings.Repeat("─", w+2)
}
return left + strings.Join(parts, mid) + right
}
rowLine := func(cells []string) string {
parts := make([]string, len(widths))
for i := range widths {
cell := ""
if i < len(cells) {
cell = cells[i]
}
pad := max(widths[i]-utf8.RuneCountInString(cell), 0)
parts[i] = " " + cell + strings.Repeat(" ", pad) + " "
}
return "│" + strings.Join(parts, "│") + "│"
}
var b strings.Builder
b.WriteString(indent + border("┌", "┬", "┐") + "\n")
b.WriteString(indent + rowLine(headers) + "\n")
b.WriteString(indent + border("├", "┼", "┤") + "\n")
for _, row := range rows {
b.WriteString(indent + rowLine(row) + "\n")
}
b.WriteString(indent + border("└", "┴", "┘") + "\n")
return b.String()
}

View File

@@ -8,8 +8,9 @@ import (
"github.com/chenasraf/tx/internal/exec"
)
// CreateFromConfig creates a tmux session from a parsed config
func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) error {
// CreateFromConfig creates a tmux session from a parsed config.
// If background is true, the session is created but not attached to.
func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem, background bool) error {
root := tmuxConfig.Root
windows := tmuxConfig.Windows
sessionName := config.NameFix(tmuxConfig.Name)
@@ -19,6 +20,10 @@ func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) er
// Check if session already exists
if SessionExists(opts, sessionName) {
if background {
exec.Log(opts, fmt.Sprintf("tmux session %s already exists (background mode, not attaching)", sessionName))
return nil
}
exec.Log(opts, fmt.Sprintf("tmux session %s already exists, attaching...", sessionName))
return AttachToSession(opts, sessionName)
}
@@ -73,7 +78,10 @@ func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) er
}
}
// Attach to the session
// Attach to the session unless background mode
if background {
return nil
}
return AttachToSession(opts, sessionName)
}
@@ -119,10 +127,14 @@ func getPaneCommands(opts exec.Opts, pane config.TmuxPaneLayout, sessionName, wi
// Increment pane index for the new pane created by split
*paneIndex++
commands = append(commands, fmt.Sprintf(
splitCmd := fmt.Sprintf(
"tmux split-window -%s -t %s:%s -c %s",
direction, sessionName, windowName, cwd,
))
)
if pane.Split.Size > 0 && pane.Split.Size <= 100 {
splitCmd += fmt.Sprintf(" -p %d", pane.Split.Size)
}
commands = append(commands, splitCmd)
// Handle child pane
if pane.Split.Child != nil {

View File

@@ -251,7 +251,7 @@ func TestCreateFromConfig_DryRun(t *testing.T) {
}
// In dry mode, this should succeed without actually running tmux
err := CreateFromConfig(opts, tmuxConfig)
err := CreateFromConfig(opts, tmuxConfig, false)
if err != nil {
t.Errorf("expected no error in dry mode, got %v", err)
}
@@ -281,7 +281,7 @@ func TestCreateFromConfig_MultipleWindows(t *testing.T) {
},
}
err := CreateFromConfig(opts, tmuxConfig)
err := CreateFromConfig(opts, tmuxConfig, false)
if err != nil {
t.Errorf("expected no error in dry mode, got %v", err)
}

View File

@@ -1 +1 @@
2.1.0
2.4.1