Go
Terminal
tmux
golang

tmux3日目の初心者ですがGoでtmuxをちょっと使やすくしたツール[got]を作りました

こんにちわ

3日前にtmuxを使い始めましたが、結構便利だなと思ったのでこれから使っていこうと考えていますが、
セッション一覧からattachしたいセッションを簡単に選んだりできたら良いなと思ってツールを作りました。

こんな感じです。

got-demo.gif

リポジトリはこちらです。
対応OSはMacOSとLinuxになります。

使ってみたい方はREAME.mdを参照頂ければと思います。

アジェンダ

できること

  • 新セッション開始
    新たなセッションを開始します。
    すでにセッション中の場合はこのメニューは表示されません。
    セッションを抜けたあとはgotに戻ります。

  • セッション一覧表示
    現在attachedとunattacedのセッション一覧(tmux ls)を確認できます。
    Ctrl + d でgotを終了できます。

  • セッションの再開
    セッション一覧から選択したセッションに対して、再開(tmux attach -t name)することができます。
    ただ、すでにセッション中の場合はattachできないので、このメニューは表示されません。
    Ctrl + d でgotを終了で、
    Ctrl + c でMenuに戻ります。

  • セッションの終了
    セッション一覧から選択したセッションに対して、終了(tmux kill-session -t name)することができます。
    Ctrl + d でgotを終了で、
    Ctrl + c でMenuに戻ります。

使用ライブラリ

promptuiというのを使っています。
かなりシンプルで構造体と関数がこれだけです。
image.png

使ったことがないモノは説明できないので、使ったことがあるモノだけサンプルをもとに説明していきます。

Prompt

簡単な入力インターフェイスを用意できます。
主な機能として…

  • 入力バリデーションを定義できる
  • Goの標準テンプレート機能使用してレウアウトをカスタマイズできる

というところになります。
細かい説明はサンプルのコメントに書いてあります。

package main

import (
    "fmt"
    "strconv"

    "github.com/manifoldco/promptui"
)

func main() {
    // 入力値のバリデーションを定義し、入力するたびにこの関数実行されます。
    // バリデーションの型は`func(input string) error`になります。
    validate := func(input string) error {
        _, err := strconv.ParseFloat(input, 64)
        return err
    }

    // 入力インターフェイスのフォーマットを定義します。
    // Goの標準テンプレート機能をそのまま使えます。
    // `{{ . }}`は入力した文字をそのまま出力します。
    templates := &promptui.PromptTemplates{
        Prompt:  "{{ . }} ",         // Prompt.Labelのフォーマット
        Valid:   "{{ . | green }} ", // バリデーションを通った時のフォーマット、この例では緑になります。
        Invalid: "{{ . | red }} ",   // バリデーションが通らなかった時のフォーマット
        Success: "{{ . | bold }} ",  // 選択後(Enter)のPrompt.Labelのフォーマット
    }

    // 入力インターフェイスの本体設定になります。
    // Labelはターミナルでいうとパスの箇所の定義になります。(用語がわからない…)
    prompt := promptui.Prompt{
        Label:     "Spicy Level",
        Templates: templates,
        Validate:  validate,
    }

    // 実行処理の戻り値は入力した値とerrorが返ってきます。
    // ここでは特定操作でエラーが返ってくることがありますので、その判定処理が必要になります。
    // エラー処理についてはgotの処理にて説明します。
    result, err := prompt.Run()

    if err != nil {
        fmt.Printf("Prompt failed %v\n", err)
        return
    }

    fmt.Printf("You answered %s\n", result)
}

ちなみに、上記のサンプルを実行するとこんな感じになります。
sample1.gif

Select

名前の通りですが、以下の機能が用意されています。

  • データ一覧表示
  • データ選択
  • データ一覧の絞り込み

細かい説明はサンプルのソースをどうぞ。

package main

import (
    "fmt"
    "strings"

    "github.com/manifoldco/promptui"
)

// 一覧に表示させるデータは構造体で用意します。
// フィールドのスコープはパブリックにしないと正しく動作しないのでご注意ください。
type pepper struct {
    Name     string
    HeatUnit int
    Peppers  int
}

func main() {
    // 用意した構造たいは配列で渡す必要があるので、複数データの登録を行います。
    peppers := []pepper{
        {Name: "Bell Pepper", HeatUnit: 0, Peppers: 0},
        {Name: "Banana Pepper", HeatUnit: 100, Peppers: 1},
        {Name: "Poblano", HeatUnit: 1000, Peppers: 2},
        {Name: "Jalapeño", HeatUnit: 3500, Peppers: 3},
        {Name: "Aleppo", HeatUnit: 10000, Peppers: 4},
        {Name: "Tabasco", HeatUnit: 30000, Peppers: 5},
        {Name: "Malagueta", HeatUnit: 50000, Peppers: 6},
        {Name: "Habanero", HeatUnit: 100000, Peppers: 7},
        {Name: "Red Savina Habanero", HeatUnit: 350000, Peppers: 8},
        {Name: "Dragon’s Breath", HeatUnit: 855000, Peppers: 9},
    }

    // Promptと同様、Goのテンプレート機能をそのまま使用して、出力フォーマットを定義できます。
    templates := &promptui.SelectTemplates{
        Label:    "{{ . }}?",                                              // PromptのLabel同様。
        Active:   "\U0001F336 {{ .Name | cyan }} ({{ .HeatUnit | red }})", // 選択したモノだけ色を変えたいなどの場合、選択したデータの出力フォーマット定義できます。
        Inactive: "  {{ .Name | cyan }} ({{ .HeatUnit | red }})",          // 選択されていない状態のフォーマット定義
        Selected: "\U0001F336 {{ .Name | red | cyan }}",                   // 選択後のフォーマット定義
        Details: `
--------- Pepper ----------
{{ "Name:" | faint }}   {{ .Name }}
{{ "Heat Unit:" | faint }}  {{ .HeatUnit }}
{{ "Peppers:" | faint }}    {{ .Peppers }}`,
    } // 複数行渡り、詳細データを表示させたい時のフォーマット定義

    // 一覧結果絞り込み処理を自前で定義できます。
    // 絞り込みの条件など細かく処理をかけますが、特に定義しない場合は絞り込み機能が無効になります。
    searcher := func(input string, index int) bool {
        pepper := peppers[index]
        name := strings.Replace(strings.ToLower(pepper.Name), " ", "", -1)
        input = strings.Replace(strings.ToLower(input), " ", "", -1)

        return strings.Contains(name, input)
    }

    // 本体の定義
    prompt := promptui.Select{
        Label:     "Spicy Level",
        Items:     peppers, //  Itemsに定義した配列のデータをそのまま渡せばOKです。
        Templates: templates,
        Size:      4, // 一覧に表示するデータの件数を定義、この数を超えた場合はスクロールします。
        Searcher:  searcher,
    }

    // 実行処理、Prompt同様、特定の操作でエラーが返ってくるので、同様のエラー処理が必要になります。
    // 戻り値は、第1は選択したデータのindex、第2は選択した行の文字列が返ってきます。
    i, _, err := prompt.Run()

    if err != nil {
        fmt.Printf("Prompt failed %v\n", err)
        return
    }

    fmt.Printf("You choose number %d: %s\n", i+1, peppers[i].Name)
}

ちなみに、今サンプルを実行するとこんな感じになります。
sample2.gif

gotについて

got自体は本当にシンプルで、読めばわかると思います。

Go初学者の方でもpromptuiを使えば簡単にCLIを作れると思いますので、
ぜひ使ってみてください。

ステップ数はたったの260行程度です。

github.com/AlDanial/cloc v 1.78  T=1.02 s (4.9 files/s, 370.1 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Go                               3             66              0            262
Markdown                         1              8              0             32
Bourne Shell                     1              3              2              5
-------------------------------------------------------------------------------
SUM:                             5             77              2            299
-------------------------------------------------------------------------------

ディレクトリ構成

got
├── README.md
├── app
│   └── app.go
├── build.sh
├── main.go
└── tmux
    └── tmux.go

パッケージはapptmuxだけになります。

appはPromptとSelectの制御を担っており、
tmuxはコマンドの実行をラップしたパッケージになります。

関数一覧

パッケージがどんな関数がある紹介していきます。

app.gp

func New() *App {}
func (a *App) Run() {} // gotの起動
func (a *App) newSession() {} // tmuxの`Newsession()`を呼び、新たなセッションを確立
func (a *App) menu() {} // Selectでメニューを作成
func (a *App) sessionList() {} // tmuxの`SessionList()`の結果を使用し、Selectでセッション一覧を表示
func (a *App) selectAction(name string) error {} // 選択したセッション名が渡され、Selectでattachかkillかを選択を表示
func listTemplate(color string) string {} // menuで使用するGoテンプレートの定義
func isEOF(err error) bool {}
func isInterrupt(err error) bool {}

isEOFisInterruptについてはPromptで後ほど説明するという箇所になります。
promptuiでは以下のようにキーマッピングがエラーが返します。

操作 エラー 関数
Ctrl + c errors.New("^C") sInterrupt(err error) bool
Ctrl + d errors.New("^D") isEOF(err error) bool

Menuとそれ以外とで、上記の操作による動作の差異があります。

Ctrl + dはどこでも終了しますが。
Ctrl + cはMenuに戻るようにしています。

tmux.go

func New() *Tmux {}
func currentSessionName() string {} // 現在のattachしているセッション名を取得しますが、attachしていない場合は空になります
func (t *Tmux) SessionList() []*Session {} // `tmux ls` の出力結果を構造体煮に詰めて返す
func (t *Tmux) NewSession() error {} // `tmux new-session`を実行
func (t *Tmux) AttachSession(name string) error {} // `tmux attach -t`を実行
func (t *Tmux) KillSession(name string) error {} // `tmux kill-session -t`を実行
func (t *Tmux) parseOutput(output string) []*Session {} // `tmux ls`で取得したデータをSession構造体に詰める
func (t *Tmux) parseDate(date string) string {} // `tmux ls`で出力した日付を所定フォーマットに変換

フローチャート

全体の流れをフローチャート図にするとこんな感じになります。

flowchart.png

処理の簡易説明

ソースを処理順に掻い摘んで説明していきます。

main.go
// シンプルにAppを`New()`して`Run()`実行しているだけづです。
func main() {
    app.New().Run()
}
app.go
// Appの初期化と同時にTmuxの初期化もしています。
func New() *App {
    return &App{
        tmux: tmux.New(),
    }
}
tmux.go
// セッション名のありなしをもとに状態をセット
func New() *Tmux {
    tmux := &Tmux{}
    var attached bool
    name := currentSessionName()

    if name != "" {
        attached = true
    }

    tmux.Name = name
    tmux.Attached = attached

    return tmux
}

// tmuxセッションattach中かどうかを環境変数$TMUで確認
func currentSessionName() string {
    env := os.Getenv("TMUX")
    if env == "" {
        return ""
    }

    result := strings.Split(env, ",")
    return result[len(result)-1]
}

app.go
// Ctrl + d で終了するまでfor文で回す
func (a *App) Run() {
    for {
        a.menu()
    }
}
app.go
func (a *App) menu() {
    // メニュー名と選択したときに実行される関数を格納する構造体を定義
    type menu struct {
        Name string
        Do   func()
    }
    var menus []menu

    // セッションアタッチ中では"New Session"を出さない
    if !a.tmux.Attached {
        menus = append(menus, menu{"New Session", func() { a.newSession() }})
    }

    menus = append(menus, menu{"Session List", func() { a.sessionList() }})

    prompt := promptui.Select{
        Label: "Menu",
        Templates: &promptui.SelectTemplates{
            Label:    `{{ . | green }}`,
            Active:   `{{ .Name | red }}`,
            Inactive: ` {{ .Name | cyan }}`,
            Selected: `{{ .Name | yellow }}`,
        },
        Items: menus,
        Size:  20,
    }

    i, _, err := prompt.Run()

    // Ctrl + c もしくは Ctrl + dの場合はエラーが返ってくるので、型チェックして真の場合は終了
    // それ以外のエラーは出力してから異常終了
    if err != nil {
        if isEOF(err) || isInterrupt(err) {
            os.Exit(0)
        }
        fmt.Println(err)
        os.Exit(-1)
    }

    menus[i].Do()
}
app.go
// TmuxのNewSession()を呼び出し、エラーの場合は出力してから異常終了
func (a *App) newSession() {
    if err := a.tmux.NewSession(); err != nil {
        fmt.Println(err)
        os.Exit(-1)
    }
}
tmux.go
// execパッケージでコマンドを実行し、OSの標準出力/入力/エラーをそのまま受け取る
// そのため、tmuxセッションを抜けると正常終了して、gotのメニューに戻る
func (t *Tmux) NewSession() error {
    cmd := exec.Command("tmux")
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    return cmd.Run()
}
app.go
func (a *App) sessionList() {
    // TmuxのSessionListからSession構造体の配列データを取得
    // セッションがない場合はデフォルトで"No results"が出力される
    sessions := a.tmux.SessionList()

    list := promptui.Select{
        Label: "Attaching Session: " + a.tmux.Name,
        Templates: &promptui.SelectTemplates{
            Label:    ` {{ . | green }}`,
            Active:   listTemplate("red"),
            Inactive: listTemplate("cyan"),
            Selected: listTemplate("yellow"),
        },
        Searcher: func(input string, index int) bool {
            session := sessions[index]
            name := strings.Replace(strings.ToLower(session.SessionName), " ", "", -1)
            input = strings.Replace(strings.ToLower(input), " ", "", -1)
            return strings.Contains(name, input)
        },
        Items: sessions,
        Size:  20,
    }

    i, _, err := list.Run()

    // Ctrl + d は終了
    // Ctrl + c はメニュー画面に戻る
    // それ以外はエラーを出力して異常終了
    if err != nil {
        if isEOF(err) {
            os.Exit(0)
        }

        if isInterrupt(err) {
            return
        }

        fmt.Println(err)
        os.Exit(-1)
    }

    // 選択したセッションに対して操作メニューを表示
    // Ctrl + d は終了
    // Ctrl + c はメニュー画面に戻る
    if err := a.selectAction(sessions[i].SessionName); err != nil {
        if isEOF(err) {
            os.Exit(0)
        }

        if isInterrupt(err) {
            return
        }

        fmt.Println(err)
        os.Exit(-1)
    }
}

func (a *App) selectAction(name string) error {
    // セッション一覧から選んだセッションに対して、AttachかKillかのメニューを表示するための構造体を定義
    type action struct {
        Name string
        Do   func(name string) error
    }

    var actions []action

    // セッションアタッチ中は"Attach"をメニューに出さない
    if !a.tmux.Attached {
        actions = append(actions,
            action{
                Name: "Attach",
                Do: func(name string) error {
                    return a.tmux.AttachSession(name)
                },
            })
    }

    actions = append(actions,
        action{
            Name: "Kill",
            Do: func(name string) error {
                return a.tmux.KillSession(name)
            },
        })

    prompt := promptui.Select{
        Label: "Action",
        Templates: &promptui.SelectTemplates{
            Label:    `{{ . | green }}`,
            Active:   `{{ .Name | red }}`,
            Inactive: ` {{ .Name | cyan }}`,
            Selected: `{{ .Name | yellow }}`,
        },
        Items: actions,
        Size:  20,
    }

    i, _, err := prompt.Run()

    // エラーは呼び出しもとで判別するので、ここではそのまま返す
    if err != nil {
        return err
    }

    // Tmuxの AttachSession(name string) か KillSession(name string) を実行する
    return actions[i].Do(name)
}
tmux.go
// execパッケージでコマンドを実行し、OSの標準出力/入力/エラーをそのまま受け取る
// そのため、tmuxセッションを抜けると正常終了して、gotのメニューに戻る
func (t *Tmux) AttachSession(name string) error {
    cmd := exec.Command("tmux", "attach", "-t", name)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    return cmd.Run()
}

// セッションを終了してメニューに戻る
func (t *Tmux) KillSession(name string) error {
    cmd := exec.Command("tmux", "kill-session", "-t", name)
    return cmd.Run()
}

最後に

一通り読んで見てわかるかと思いますが、
promptuiを使用すると簡単なCLIツールを簡単に作成できます。

シンプルが故に、若干複雑なことはできないので、
Forkしてカスタマイズするか他のライブラリを使用するのが良いかなと思います。

例えば、複数のセッションを選んでまとめてkillするインターフェイスは、
標準で用意されている関数で実現は難しいので、内部処理を読んで関数を自作することになるかと思います。

ちなみに、
先日にpromptuiを使用してQiitaをターミナルで検索できるツールも作ったので、よければ使って見ください。