broadcast kitten: Go TUI implementation and registration

This commit is contained in:
OBrutus 2026-08-12 12:52:40 +05:30
parent 59438927a9
commit aa8bf26b02
5 changed files with 352 additions and 0 deletions

66
kittens/broadcast/cli_generated.go generated Normal file
View file

@ -0,0 +1,66 @@
// Code generated by go_code.py; DO NOT EDIT.
package broadcast
import "fmt"
import "github.com/kovidgoyal/kitty/tools/cli"
var _ = fmt.Sprintf
func create_cmd(root *cli.Command, run_func func(*cli.Command, *Options, []string)(int, error)) {
ans := root.AddSubCommand(&cli.Command{
Name: "broadcast",
ShortDescription: "Broadcast typed text to kitty windows",
Usage: "[options] [initial text to send ...]",
HelpText: "Broadcast typed text to kitty windows. By default text is sent to all windows, unless one of the matching options is specified",
Run: func(cmd *cli.Command, args []string) (int, error) {
opts := Options{}
err := cmd.GetOptionValues(&opts)
if err != nil { return 1, err }
return run_func(cmd, &opts, args)},
})
ans.Add(cli.OptionSpec{
Name: "--hide-input-toggle",
Type: "",
Dest: "HideInputToggle",
Help: "Key to press that will toggle hiding of the input in the broadcast window itself. Useful while typing a password, prevents the password from being visible on the screen.",
Default: "Ctrl+Alt+Esc",
})
ans.Add(cli.OptionSpec{
Name: "--end-session",
Type: "",
Dest: "EndSession",
Help: "Key to press to end the broadcast session.",
Default: "Ctrl+Esc",
})
ans.Add(cli.OptionSpec{
Name: "--match -m",
Type: "",
Dest: "Match",
Help: "The window to match. Match specifications are of the form: :italic:`field:query`. Where :italic:`field` can be one of: :code:`id`, :code:`title`, :code:`pid`, :code:`cwd`, :code:`cmdline`, :code:`num`, :code:`env`, :code:`var`, :code:`state`, :code:`neighbor`, :code:`session` and :code:`recent`. :italic:`query` is the expression to match. Expressions can be either a number or a regular expression, and can be :ref:`combined using Boolean operators <search_syntax>`.\n\nThe special value :code:`all` matches all windows.\n\nFor numeric fields: :code:`id`, :code:`pid`, :code:`num` and :code:`recent`, the expression is interpreted as a number, not a regular expression. Negative values for :code:`id` match from the highest id number down, in particular, -1 is the most recently created window.\n\nThe field :code:`num` refers to the window position in the current tab, starting from zero and counting clockwise (this is the same as the order in which the windows are reported by the :ref:`kitten @ ls <at-ls>` command).\n\nThe window id of the current window is available as the :envvar:`KITTY_WINDOW_ID` environment variable.\n\nThe field :code:`recent` refers to recently active windows in the currently active tab, with zero being the currently active window, one being the previously active window and so on.\n\nThe field :code:`neighbor` refers to a neighbor of the active window in the specified direction, which can be: :code:`left`, :code:`right`, :code:`top` or :code:`bottom`.\n\nThe field :code:`session` matches windows that were created in the specified session. Use the expression :code:`^$` to match windows that were not created in a session and :code:`.` to match the currently active session and :code:`~` to match either the currently active session or the last active session when no session is active.\n\nWhen using the :code:`env` field to match on environment variables, you can specify only the environment variable name or a name and value, for example, :code:`env:MY_ENV_VAR=2`.\n\nSimilarly, the :code:`var` field matches on user variables set on the window. You can specify name or name and value as with the :code:`env` field.\n\nThe field :code:`state` matches on the state of the window. Supported states are: :code:`active`, :code:`focused`, :code:`needs_attention`, :code:`parent_active`, :code:`parent_focused`, :code:`focused_os_window`, :code:`self`, :code:`overlay_parent`. Active windows are the windows that are active in their parent tab. There is only one focused window and it is the window to which keyboard events are delivered. If no window is focused, the last focused window is matched. The value :code:`focused_os_window` matches all windows in the currently focused OS window. The value :code:`self` matches the window in which the remote control command is run. The value :code:`overlay_parent` matches the window that is under the :code:`self` window, when the self window is an overlay.\n\nNote that you can use the :ref:`kitten @ ls <at-ls>` command to get a list of windows.",
})
ans.Add(cli.OptionSpec{
Name: "--match-tab -t",
Type: "",
Dest: "MatchTab",
Help: "The tab to match. Match specifications are of the form: :italic:`field:query`. Where :italic:`field` can be one of: :code:`id`, :code:`index`, :code:`title`, :code:`window_id`, :code:`window_title`, :code:`pid`, :code:`cwd`, :code:`cmdline` :code:`env`, :code:`var`, :code:`state`, :code:`session` and :code:`recent`. :italic:`query` is the expression to match. Expressions can be either a number or a regular expression, and can be :ref:`combined using Boolean operators <search_syntax>`.\n\nThe special value :code:`all` matches all tabs.\n\nFor numeric fields: :code:`id`, :code:`index`, :code:`window_id`, :code:`pid` and :code:`recent`, the expression is interpreted as a number, not a regular expression. Negative values for :code:`id`/:code:`window_id` match from the highest id number down, in particular, -1 is the most recently created tab/window.\n\nWhen using :code:`title` or :code:`id`, first a matching tab is looked for, and if not found a matching window is looked for, and the tab for that window is used.\n\nYou can also use :code:`window_id` and :code:`window_title` to match the tab that contains the window with the specified id or title.\n\nThe :code:`index` number is used to match the nth tab in the currently active OS window. The :code:`recent` number matches recently active tabs in the currently active OS window, with zero being the currently active tab, one the previously active tab and so on.\n\nThe field :code:`session` matches tabs that were created in the specified session. Use the expression :code:`^$` to match windows that were not created in a session and :code:`.` to match the currently active session and :code:`~` to match either the currently active session or the last active session when no session is active.\n\nWhen using the :code:`env` field to match on environment variables, you can specify only the environment variable name or a name and value, for example, :code:`env:MY_ENV_VAR=2`. Tabs containing any window with the specified environment variables are matched. Similarly, :code:`var` matches tabs containing any window with the specified user variable.\n\nThe field :code:`state` matches on the state of the tab. Supported states are: :code:`active`, :code:`focused`, :code:`needs_attention`, :code:`parent_active`, :code:`parent_focused` and :code:`focused_os_window`. Active tabs are the tabs that are active in their parent OS window. There is only one focused tab and it is the tab to which keyboard events are delivered. If no tab is focused, the last focused tab is matched. The value :code:`focused_os_window` matches all tabs in the currently focused OS window.\n\nNote that you can use the :ref:`kitten @ ls <at-ls>` command to get a list of tabs.",
})
}
type Options struct {
HideInputToggle string
EndSession string
Match string
MatchTab string
}
func (opts Options) AsCommandLine() (ans []string) {
sval := ""
_ = sval
sval = opts.HideInputToggle
if (sval != "Ctrl+Alt+Esc") { ans = append(ans, `--hide-input-toggle=` + sval)}
sval = opts.EndSession
if (sval != "Ctrl+Esc") { ans = append(ans, `--end-session=` + sval)}
sval = opts.Match
if (sval != "") { ans = append(ans, `--match=` + sval)}
sval = opts.MatchTab
if (sval != "") { ans = append(ans, `--match-tab=` + sval)}
return
}

View file

@ -0,0 +1,80 @@
// License: GPLv3 Copyright: 2026, Kovid Goyal, <kovid at kovidgoyal.net>
package broadcast
import "github.com/kovidgoyal/kitty/tools/tui/loop"
// Minimal line editor: covers the subset of kittens/tui/line_edit.py that
// the broadcast kitten actually uses (insert, backspace, arrows, home/end).
type line_edit struct {
text []rune
cursor int
}
func (l *line_edit) String() string { return string(l.text) }
func (l *line_edit) on_text(t string) {
r := []rune(t)
l.text = append(l.text[:l.cursor], append(r, l.text[l.cursor:]...)...)
l.cursor += len(r)
}
func (l *line_edit) backspace() bool {
if l.cursor == 0 {
return false
}
l.text = append(l.text[:l.cursor-1], l.text[l.cursor:]...)
l.cursor--
return true
}
func (l *line_edit) left() {
if l.cursor > 0 {
l.cursor--
}
}
func (l *line_edit) right() {
if l.cursor < len(l.text) {
l.cursor++
}
}
func (l *line_edit) home() { l.cursor = 0 }
func (l *line_edit) end() { l.cursor = len(l.text) }
func (l *line_edit) clear() {
l.text = l.text[:0]
l.cursor = 0
}
// on_key returns true if the key was consumed as an editing action.
func (l *line_edit) on_key(e *loop.KeyEvent) bool {
switch {
case e.MatchesPressOrRepeat("backspace"):
e.Handled = true
return l.backspace()
case e.MatchesPressOrRepeat("left"):
e.Handled = true
l.left()
return true
case e.MatchesPressOrRepeat("right"):
e.Handled = true
l.right()
return true
case e.MatchesPressOrRepeat("home"), e.MatchesPressOrRepeat("ctrl+a"):
e.Handled = true
l.home()
return true
case e.MatchesPressOrRepeat("end"), e.MatchesPressOrRepeat("ctrl+e"):
e.Handled = true
l.end()
return true
}
return false
}
// write draws the current line and positions the cursor.
func (l *line_edit) write(lp *loop.Loop) {
lp.QueueWriteString(string(l.text))
if diff := len(l.text) - l.cursor; diff > 0 {
lp.MoveCursorHorizontally(-diff)
}
}

View file

@ -0,0 +1,35 @@
// License: GPLv3 Copyright: 2026, Kovid Goyal, <kovid at kovidgoyal.net>
package broadcast
import "testing"
func TestLineEdit(t *testing.T) {
le := line_edit{}
le.on_text("hello")
if le.String() != "hello" || le.cursor != 5 {
t.Fatalf("insert failed: %q cursor=%d", le.String(), le.cursor)
}
le.backspace()
if le.String() != "hell" {
t.Fatalf("backspace failed: %q", le.String())
}
le.left()
le.left()
le.on_text("X")
if le.String() != "heXll" || le.cursor != 3 {
t.Fatalf("mid-insert failed: %q cursor=%d", le.String(), le.cursor)
}
le.home()
if le.cursor != 0 {
t.Fatal("home failed")
}
le.end()
if le.cursor != 5 {
t.Fatal("end failed")
}
le.clear()
if le.String() != "" || le.cursor != 0 {
t.Fatal("clear failed")
}
}

168
kittens/broadcast/main.go Normal file
View file

@ -0,0 +1,168 @@
// License: GPLv3 Copyright: 2026, Kovid Goyal, <kovid at kovidgoyal.net>
package broadcast
import (
"encoding/base64"
"os"
"strings"
"github.com/kovidgoyal/kitty/tools/cli"
"github.com/kovidgoyal/kitty/tools/cli/markup"
"github.com/kovidgoyal/kitty/tools/tui/loop"
"github.com/kovidgoyal/kitty/tools/utils"
)
type handler struct {
lp *loop.Loop
opts *Options
ctx *markup.Context
payload broadcast_payload
initial_strings []string
hide_input bool
session_started bool
line_edit line_edit
}
func b64text(text string) string {
return "base64:" + base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(text))
}
func (h *handler) broadcast_data(data string) error {
p := h.payload
p.Data = data
ec, err := send_text_escape_code(p)
if err != nil {
return err
}
h.lp.QueueWriteString(ec)
return nil
}
func (h *handler) broadcast_text(text string) error { return h.broadcast_data(b64text(text)) }
func (h *handler) commit_line() {
h.lp.RestoreCursorPosition()
h.lp.SaveCursorPosition()
h.lp.ClearToEndOfScreen()
h.line_edit.write(h.lp)
}
func (h *handler) end_line() {
h.lp.Println()
h.line_edit.clear()
h.lp.SaveCursorPosition()
}
func (h *handler) initialize() (string, error) {
h.session_started = true
ec, err := session_escape_code(h.payload, true)
if err != nil {
return "", err
}
h.lp.QueueWriteString(ec)
h.lp.Println("Type the text to broadcast below, press", h.ctx.Yellow(h.opts.EndSession), "to quit:")
for _, x := range h.initial_strings {
if err := h.broadcast_text(x); err != nil {
return "", err
}
}
h.lp.SaveCursorPosition()
return "", nil
}
func (h *handler) on_text(text string) error {
if err := h.broadcast_text(text); err != nil {
return err
}
if !h.hide_input {
h.line_edit.on_text(text)
}
h.commit_line()
return nil
}
func (h *handler) on_key(e *loop.KeyEvent) error {
if e.MatchesPressOrRepeat(strings.ToLower(h.opts.HideInputToggle)) {
e.Handled = true
h.hide_input = !h.hide_input
h.lp.SetCursorVisible(!h.hide_input)
if h.hide_input {
h.end_line()
h.lp.Println("Input hidden, press", h.ctx.Yellow(h.opts.HideInputToggle), "to unhide:")
h.end_line()
}
return nil
}
if e.MatchesPressOrRepeat(strings.ToLower(h.opts.EndSession)) {
e.Handled = true
h.lp.Quit(0)
return nil
}
if e.MatchesPressOrRepeat("ctrl+c") {
e.Handled = true
if err := h.broadcast_text("\x03"); err != nil {
return err
}
h.line_edit.clear()
h.commit_line()
return nil
}
if e.MatchesPressOrRepeat("ctrl+d") {
e.Handled = true
return h.broadcast_text("\x04")
}
if !h.hide_input && h.line_edit.on_key(e) {
h.commit_line()
}
if e.MatchesPressOrRepeat("enter") {
e.Handled = true
if err := h.broadcast_text("\r"); err != nil {
return err
}
h.end_line()
return nil
}
if e.Text == "" { // non-text key: forward in kitty-key encoding
e.Handled = true
return h.broadcast_data("kitty-key:" + base64.StdEncoding.EncodeToString([]byte(e.AsCSI())))
}
return nil
}
func run_loop(opts *Options, args []string) (rc int, err error) {
lp, err := loop.New(loop.FullKeyboardProtocol, loop.NoAlternateScreen)
if err != nil {
return 1, err
}
h := &handler{lp: lp, opts: opts, ctx: markup.New(true), initial_strings: args}
h.payload = broadcast_payload{
ExcludeActive: true, Match: opts.Match, MatchTab: opts.MatchTab, SessionId: new_session_id(),
}
if opts.Match == "" && opts.MatchTab == "" {
h.payload.All = true
}
lp.OnInitialize = h.initialize
lp.OnResize = func(old, new loop.ScreenSize) error { h.commit_line(); return nil }
lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error { return h.on_text(text) }
lp.OnKeyEvent = h.on_key
err = lp.Run()
if h.session_started {
if ec, err2 := session_escape_code(h.payload, false); err2 == nil {
os.Stdout.WriteString(ec)
}
}
if err != nil {
return 1, err
}
lp.KillIfSignalled()
return lp.ExitCode(), nil
}
func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) {
return run_loop(opts, args)
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"github.com/kovidgoyal/kitty/kittens/ask"
"github.com/kovidgoyal/kitty/kittens/broadcast"
"github.com/kovidgoyal/kitty/kittens/choose_files"
"github.com/kovidgoyal/kitty/kittens/choose_fonts"
"github.com/kovidgoyal/kitty/kittens/clipboard"
@ -69,6 +70,8 @@ func KittyToolEntryPoints(root *cli.Command) {
quick_access_terminal.EntryPoint(root)
// resize_window
resize_window.EntryPoint(root)
// broadcast
broadcast.EntryPoint(root)
// unicode_input
unicode_input.EntryPoint(root)
// show_key