From 137486d7212b413e3ea66f459eb19f4aff37043f Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 17:42:29 +0900 Subject: [PATCH 01/12] feat: add rule-based log syntax highlighter and tests --- pkg/utils/logs.go | 41 +++++++++++++++++++++++++++++++++++++++++ pkg/utils/logs_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 pkg/utils/logs.go create mode 100644 pkg/utils/logs_test.go diff --git a/pkg/utils/logs.go b/pkg/utils/logs.go new file mode 100644 index 00000000..00271113 --- /dev/null +++ b/pkg/utils/logs.go @@ -0,0 +1,41 @@ +package utils + +import ( + "regexp" + "strings" +) + +var ( + errRegex = regexp.MustCompile(`(?i)\b(error|err|critical|fatal)\b`) + warnRegex = regexp.MustCompile(`(?i)\b(warning|warn)\b`) + infoRegex = regexp.MustCompile(`(?i)\b(info)\b`) + debugRegex = regexp.MustCompile(`(?i)\b(debug)\b`) + + timestampRegex = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b`) +) + +// ColoriseLog applies basic ANSI syntax highlighting to typical log statements. +func ColoriseLog(line string) string { + if strings.Contains(line, "\x1b[") { + return line + } + + line = timestampRegex.ReplaceAllStringFunc(line, func(ts string) string { + return "\x1b[90m" + ts + "\x1b[0m" + }) + + line = errRegex.ReplaceAllStringFunc(line, func(match string) string { + return "\x1b[31;1m" + match + "\x1b[0m" + }) + line = warnRegex.ReplaceAllStringFunc(line, func(match string) string { + return "\x1b[33;1m" + match + "\x1b[0m" + }) + line = infoRegex.ReplaceAllStringFunc(line, func(match string) string { + return "\x1b[32m" + match + "\x1b[0m" + }) + line = debugRegex.ReplaceAllStringFunc(line, func(match string) string { + return "\x1b[34m" + match + "\x1b[0m" + }) + + return line +} diff --git a/pkg/utils/logs_test.go b/pkg/utils/logs_test.go new file mode 100644 index 00000000..ded6b218 --- /dev/null +++ b/pkg/utils/logs_test.go @@ -0,0 +1,41 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestColoriseLog(t *testing.T) { + tests := []struct { + name string + input string + contains []string + }{ + { + name: "Highlight error log level", + input: "ERROR: database failed to connect", + contains: []string{"\x1b[31;1mERROR\x1b[0m"}, + }, + { + name: "Highlight warning log level", + input: "[WARN] deprecation warning", + contains: []string{"\x1b[33;1mWARN\x1b[0m"}, + }, + { + name: "Highlight timestamp", + input: "2026-06-04T12:00:00Z something happened", + contains: []string{"\x1b[90m2026-06-04T12:00:00Z\x1b[0m"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ColoriseLog(tt.input) + for _, c := range tt.contains { + if !strings.Contains(got, c) { + t.Errorf("expected %q to contain %q, but it was %q", got, c, got) + } + } + }) + } +} From 7ee9844285fdc47bdaa83749aedf97a0f6c52db5 Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 17:44:08 +0900 Subject: [PATCH 02/12] config: add logs.pager configuration option --- pkg/config/app_config.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 0ebc1796..efdf28cc 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -344,6 +344,7 @@ type LogsConfig struct { Timestamps bool `yaml:"timestamps,omitempty"` Since string `yaml:"since,omitempty"` Tail string `yaml:"tail,omitempty"` + Pager string `yaml:"pager,omitempty"` // Configuration for external logs viewer (e.g. "lnav", "less -R") } // GetDefaultConfig returns the application default configuration NOTE (to @@ -383,6 +384,7 @@ func GetDefaultConfig() UserConfig { Timestamps: false, Since: "60m", Tail: "", + Pager: "", }, CommandTemplates: CommandTemplatesConfig{ DockerCompose: "docker compose", From 9e4735d48ab3a13034f23e90c70e480ba199d551 Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 17:44:38 +0900 Subject: [PATCH 03/12] feat: intercept logs output to colorize stderr, and implement external pager handler --- pkg/gui/container_logs.go | 124 +++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/pkg/gui/container_logs.go b/pkg/gui/container_logs.go index bdb81608..80f6d26d 100644 --- a/pkg/gui/container_logs.go +++ b/pkg/gui/container_logs.go @@ -1,16 +1,19 @@ package gui import ( + "bytes" "context" "fmt" "io" "os" + "os/exec" "os/signal" "time" "github.com/docker/docker/api/types/container" "github.com/docker/docker/pkg/stdcopy" "github.com/fatih/color" + "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/tasks" "github.com/jesseduffield/lazydocker/pkg/utils" @@ -136,13 +139,18 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context, } } + stdoutWriter := NewLogWriter(writer, false) + stderrWriter := NewLogWriter(writer, true) + defer stdoutWriter.Close() + defer stderrWriter.Close() + if ctr.Details.Config.Tty { - _, err = io.Copy(writer, readCloser) + _, err = io.Copy(stdoutWriter, readCloser) if err != nil { return err } } else { - _, err = stdcopy.StdCopy(writer, writer, readCloser) + _, err = stdcopy.StdCopy(stdoutWriter, stderrWriter, readCloser) if err != nil { return err } @@ -150,3 +158,115 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context, return nil } + +type LogWriter struct { + writer io.Writer + lineBuffer []byte + isStderr bool +} + +func NewLogWriter(writer io.Writer, isStderr bool) *LogWriter { + return &LogWriter{ + writer: writer, + lineBuffer: make([]byte, 0), + isStderr: isStderr, + } +} + +func (lw *LogWriter) Write(p []byte) (n int, err error) { + n = len(p) + lw.lineBuffer = append(lw.lineBuffer, p...) + + for { + idx := bytes.IndexByte(lw.lineBuffer, '\n') + if idx == -1 { + break + } + + line := string(lw.lineBuffer[:idx]) + lw.lineBuffer = lw.lineBuffer[idx+1:] + + colorised := utils.ColoriseLog(line) + if lw.isStderr { + colorised = "\x1b[31;1m" + colorised + "\x1b[0m" + } + + _, err = fmt.Fprintln(lw.writer, colorised) + if err != nil { + return n, err + } + } + return n, nil +} + +func (lw *LogWriter) Close() error { + if len(lw.lineBuffer) > 0 { + line := string(lw.lineBuffer) + colorised := utils.ColoriseLog(line) + if lw.isStderr { + colorised = "\x1b[31;1m" + colorised + "\x1b[0m" + } + _, err := fmt.Fprint(lw.writer, colorised) + return err + } + return nil +} + +func (gui *Gui) handleContainerViewLogsExternal(g *gocui.Gui, v *gocui.View) error { + ctr, err := gui.Panels.Containers.GetSelectedItem() + if err != nil { + return nil + } + return gui.handleViewLogsExternal(ctr) +} + +func (gui *Gui) handleViewLogsExternal(container *commands.Container) error { + pager := gui.Config.UserConfig.Logs.Pager + if pager == "" { + return gui.createErrorPanel("No external pager configured. Please set 'logs.pager' in config.yml") + } + + stop := make(chan os.Signal, 1) + defer signal.Stop(stop) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + signal.Notify(stop, os.Interrupt) + <-stop + cancel() + }() + + if err := gui.g.Suspend(); err != nil { + gui.Log.Error(err) + return err + } + + defer func() { + if err := gui.g.Resume(); err != nil { + gui.Log.Error(err) + } + }() + + cmd := exec.CommandContext(ctx, "sh", "-c", pager) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + reader, writer := io.Pipe() + cmd.Stdin = reader + + if err := cmd.Start(); err != nil { + reader.Close() + writer.Close() + fmt.Fprintf(os.Stdout, "\nError starting pager command: %v\n", err) + gui.promptToReturn() + return err + } + + go func() { + defer writer.Close() + _ = gui.writeContainerLogs(container, ctx, writer) + }() + + _ = cmd.Wait() + return nil +} From 540d275fdc799240c398e29f47ddcb86a5e4bffa Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 17:45:01 +0900 Subject: [PATCH 04/12] feat: add mouse capture toggle keybinding with 15s auto-restore countdown --- pkg/gui/main_panel.go | 83 +++++++++++++++++++++++++++++++++++++++++++ pkg/gui/views.go | 8 +++++ 2 files changed, 91 insertions(+) diff --git a/pkg/gui/main_panel.go b/pkg/gui/main_panel.go index 004147be..9a6eca3f 100644 --- a/pkg/gui/main_panel.go +++ b/pkg/gui/main_panel.go @@ -2,6 +2,8 @@ package gui import ( "math" + "sync" + "time" "github.com/jesseduffield/gocui" ) @@ -111,3 +113,84 @@ func (gui *Gui) handleMainClick() error { return gui.switchFocus(gui.Views.Main) } + +var ( + mouseTimerMutex sync.Mutex + mouseTimer *time.Timer + mouseTicker *time.Ticker + mouseTicksLeft int +) + +func (gui *Gui) handleToggleMouse() error { + mouseTimerMutex.Lock() + defer mouseTimerMutex.Unlock() + + if gui.g.Mouse { + // Disable Mouse + gui.g.Mouse = false + gocui.Screen.DisableMouse() + + if mouseTimer != nil { + mouseTimer.Stop() + } + if mouseTicker != nil { + mouseTicker.Stop() + } + + mouseTicksLeft = 15 + mouseTimer = time.AfterFunc(15*time.Second, func() { + gui.g.Update(func(g *gocui.Gui) error { + mouseTimerMutex.Lock() + defer mouseTimerMutex.Unlock() + + if !gui.g.Mouse { + gui.g.Mouse = true + gocui.Screen.EnableMouse() + if mouseTicker != nil { + mouseTicker.Stop() + } + _ = gui.renderString(gui.g, "information", gui.getInformationContent()) + } + return nil + }) + }) + + mouseTicker = time.NewTicker(1 * time.Second) + go func() { + for range mouseTicker.C { + gui.g.Update(func(g *gocui.Gui) error { + mouseTimerMutex.Lock() + defer mouseTimerMutex.Unlock() + + if gui.g.Mouse { + return nil + } + mouseTicksLeft-- + if mouseTicksLeft <= 0 { + if mouseTicker != nil { + mouseTicker.Stop() + } + return nil + } + _ = gui.renderString(gui.g, "information", gui.getInformationContent()) + return nil + }) + } + }() + + } else { + // Enable Mouse manually + gui.g.Mouse = true + gocui.Screen.EnableMouse() + + if mouseTimer != nil { + mouseTimer.Stop() + } + if mouseTicker != nil { + mouseTicker.Stop() + } + } + + _ = gui.renderString(gui.g, "information", gui.getInformationContent()) + return nil +} diff --git a/pkg/gui/views.go b/pkg/gui/views.go index f231c3c6..c29924e1 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -1,6 +1,7 @@ package gui import ( + "fmt" "os" "github.com/fatih/color" @@ -195,6 +196,13 @@ func (gui *Gui) setInitialViewContent() error { func (gui *Gui) getInformationContent() string { informationStr := gui.Config.Version + + mouseStatus := "Mouse: ON" + if !gui.g.Mouse { + mouseStatus = fmt.Sprintf("Mouse: OFF (%ds left)", mouseTicksLeft) + } + informationStr = mouseStatus + " | " + informationStr + if !gui.g.Mouse { return informationStr } From 2fb00c354c32b50d5244a2c9078169ad6ca90a00 Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 17:46:05 +0900 Subject: [PATCH 05/12] feat: bind Shift+M for mouse toggling and l for external logs pager --- pkg/gui/keybindings.go | 21 +++++++++++++++++++++ pkg/gui/services_panel.go | 11 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index df706024..302ffb46 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -74,6 +74,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Modifier: gocui.ModNone, Handler: gui.quit, }, + { + ViewName: "", + Key: 'M', // Shift+M + Modifier: gocui.ModNone, + Handler: wrappedHandler(gui.handleToggleMouse), + Description: "Toggle mouse mode (ON/OFF)", + }, { ViewName: "", Key: gocui.KeyPgup, @@ -234,6 +241,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Handler: gui.handleContainerViewLogs, Description: gui.Tr.ViewLogs, }, + { + ViewName: "containers", + Key: 'l', + Modifier: gocui.ModNone, + Handler: gui.handleContainerViewLogsExternal, + Description: "View logs with external pager", + }, { ViewName: "containers", Key: 'E', @@ -318,6 +332,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Handler: gui.handleServiceRenderLogsToMain, Description: gui.Tr.ViewLogs, }, + { + ViewName: "services", + Key: 'l', + Modifier: gocui.ModNone, + Handler: gui.handleServiceViewLogsExternal, + Description: "View logs with external pager", + }, { ViewName: "services", Key: 'U', diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go index edc894e9..7097ebc5 100644 --- a/pkg/gui/services_panel.go +++ b/pkg/gui/services_panel.go @@ -333,6 +333,17 @@ func (gui *Gui) handleServiceRenderLogsToMain(g *gocui.Gui, v *gocui.View) error return gui.runSubprocess(c) } +func (gui *Gui) handleServiceViewLogsExternal(g *gocui.Gui, v *gocui.View) error { + service, err := gui.Panels.Services.GetSelectedItem() + if err != nil { + return nil + } + if service.Container == nil { + return gui.createErrorPanel("No container running for this service") + } + return gui.handleViewLogsExternal(service.Container) +} + func (gui *Gui) handleProjectUp(g *gocui.Gui, v *gocui.View) error { project, _ := gui.Panels.Projects.GetSelectedItem() if project != nil && project.Name != gui.DockerCommand.LocalProjectName { From fd6e2f9be3e19014dcc4476ed6b830b95a3f8934 Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 17:53:07 +0900 Subject: [PATCH 06/12] config: set default logs.pager to less -R --- pkg/config/app_config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index efdf28cc..20668c97 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -384,7 +384,7 @@ func GetDefaultConfig() UserConfig { Timestamps: false, Since: "60m", Tail: "", - Pager: "", + Pager: "less -R", }, CommandTemplates: CommandTemplatesConfig{ DockerCompose: "docker compose", From fef700ed415225e50996b35978911e0c643c06bb Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 18:01:08 +0900 Subject: [PATCH 07/12] feat: implement automatic focus-based mouse toggling for logs view --- pkg/gui/focus.go | 17 +++++++++ pkg/gui/keybindings.go | 8 +--- pkg/gui/main_panel.go | 83 ------------------------------------------ pkg/gui/views.go | 3 +- 4 files changed, 19 insertions(+), 92 deletions(-) diff --git a/pkg/gui/focus.go b/pkg/gui/focus.go index cc414a53..5ced85e0 100644 --- a/pkg/gui/focus.go +++ b/pkg/gui/focus.go @@ -46,6 +46,23 @@ func (gui *Gui) switchFocusAux(newView *gocui.View) error { gui.g.Cursor = newView.Editable + // Automatically control mouse capture mode depending on focus + if newView.Name() == "main" { + if gui.g.Mouse { + gui.g.Mouse = false + gocui.Screen.DisableMouse() + } + } else { + if !gui.Config.UserConfig.Gui.IgnoreMouseEvents { + if !gui.g.Mouse { + gui.g.Mouse = true + gocui.Screen.EnableMouse() + } + } + } + // Force refresh the bottom information panel with updated status text + _ = gui.renderString(gui.g, "information", gui.getInformationContent()) + if err := gui.renderPanelOptions(); err != nil { return err } diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 302ffb46..79d0fad6 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -74,13 +74,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Modifier: gocui.ModNone, Handler: gui.quit, }, - { - ViewName: "", - Key: 'M', // Shift+M - Modifier: gocui.ModNone, - Handler: wrappedHandler(gui.handleToggleMouse), - Description: "Toggle mouse mode (ON/OFF)", - }, + { ViewName: "", Key: gocui.KeyPgup, diff --git a/pkg/gui/main_panel.go b/pkg/gui/main_panel.go index 9a6eca3f..004147be 100644 --- a/pkg/gui/main_panel.go +++ b/pkg/gui/main_panel.go @@ -2,8 +2,6 @@ package gui import ( "math" - "sync" - "time" "github.com/jesseduffield/gocui" ) @@ -113,84 +111,3 @@ func (gui *Gui) handleMainClick() error { return gui.switchFocus(gui.Views.Main) } - -var ( - mouseTimerMutex sync.Mutex - mouseTimer *time.Timer - mouseTicker *time.Ticker - mouseTicksLeft int -) - -func (gui *Gui) handleToggleMouse() error { - mouseTimerMutex.Lock() - defer mouseTimerMutex.Unlock() - - if gui.g.Mouse { - // Disable Mouse - gui.g.Mouse = false - gocui.Screen.DisableMouse() - - if mouseTimer != nil { - mouseTimer.Stop() - } - if mouseTicker != nil { - mouseTicker.Stop() - } - - mouseTicksLeft = 15 - mouseTimer = time.AfterFunc(15*time.Second, func() { - gui.g.Update(func(g *gocui.Gui) error { - mouseTimerMutex.Lock() - defer mouseTimerMutex.Unlock() - - if !gui.g.Mouse { - gui.g.Mouse = true - gocui.Screen.EnableMouse() - if mouseTicker != nil { - mouseTicker.Stop() - } - _ = gui.renderString(gui.g, "information", gui.getInformationContent()) - } - return nil - }) - }) - - mouseTicker = time.NewTicker(1 * time.Second) - go func() { - for range mouseTicker.C { - gui.g.Update(func(g *gocui.Gui) error { - mouseTimerMutex.Lock() - defer mouseTimerMutex.Unlock() - - if gui.g.Mouse { - return nil - } - mouseTicksLeft-- - if mouseTicksLeft <= 0 { - if mouseTicker != nil { - mouseTicker.Stop() - } - return nil - } - _ = gui.renderString(gui.g, "information", gui.getInformationContent()) - return nil - }) - } - }() - - } else { - // Enable Mouse manually - gui.g.Mouse = true - gocui.Screen.EnableMouse() - - if mouseTimer != nil { - mouseTimer.Stop() - } - if mouseTicker != nil { - mouseTicker.Stop() - } - } - - _ = gui.renderString(gui.g, "information", gui.getInformationContent()) - return nil -} diff --git a/pkg/gui/views.go b/pkg/gui/views.go index c29924e1..f3361c6d 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -1,7 +1,6 @@ package gui import ( - "fmt" "os" "github.com/fatih/color" @@ -199,7 +198,7 @@ func (gui *Gui) getInformationContent() string { mouseStatus := "Mouse: ON" if !gui.g.Mouse { - mouseStatus = fmt.Sprintf("Mouse: OFF (%ds left)", mouseTicksLeft) + mouseStatus = "Mouse: OFF (Drag to copy)" } informationStr = mouseStatus + " | " + informationStr From c5ceddd15efe75bf5457816479b2c79da851db56 Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 18:07:56 +0900 Subject: [PATCH 08/12] fix: resolve stdin hanging issue by streaming logs to a temporary file when launching external pager --- pkg/gui/container_logs.go | 49 +++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/pkg/gui/container_logs.go b/pkg/gui/container_logs.go index 80f6d26d..d676db22 100644 --- a/pkg/gui/container_logs.go +++ b/pkg/gui/container_logs.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "os/signal" + "strings" "time" "github.com/docker/docker/api/types/container" @@ -226,16 +227,36 @@ func (gui *Gui) handleViewLogsExternal(container *commands.Container) error { return gui.createErrorPanel("No external pager configured. Please set 'logs.pager' in config.yml") } + tmpFile, err := os.CreateTemp("", "lazydocker-*.log") + if err != nil { + return gui.createErrorPanel(err.Error()) + } + tmpPath := tmpFile.Name() + defer func() { + tmpFile.Close() + _ = os.Remove(tmpPath) + }() + stop := make(chan os.Signal, 1) defer signal.Stop(stop) ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { signal.Notify(stop, os.Interrupt) <-stop cancel() }() + // Start writing container logs to the temporary file in background + go func() { + _ = gui.writeContainerLogs(container, ctx, tmpFile) + }() + + // Wait briefly for some logs to be written so the pager has content on start + time.Sleep(200 * time.Millisecond) + if err := gui.g.Suspend(); err != nil { gui.Log.Error(err) return err @@ -247,26 +268,18 @@ func (gui *Gui) handleViewLogsExternal(container *commands.Container) error { } }() - cmd := exec.CommandContext(ctx, "sh", "-c", pager) + var cmdStr string + if strings.Contains(pager, "{{filename}}") { + cmdStr = strings.ReplaceAll(pager, "{{filename}}", tmpPath) + } else { + cmdStr = pager + " " + tmpPath + } + + cmd := exec.CommandContext(ctx, "sh", "-c", cmdStr) + cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - reader, writer := io.Pipe() - cmd.Stdin = reader - - if err := cmd.Start(); err != nil { - reader.Close() - writer.Close() - fmt.Fprintf(os.Stdout, "\nError starting pager command: %v\n", err) - gui.promptToReturn() - return err - } - - go func() { - defer writer.Close() - _ = gui.writeContainerLogs(container, ctx, writer) - }() - - _ = cmd.Wait() + _ = cmd.Run() return nil } From 3396d26d8b40a1ccbde404137d1c04e0f5959c32 Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 18:13:18 +0900 Subject: [PATCH 09/12] feat: add keybinding 'l' to main logs view for launching external pager directly --- pkg/gui/container_logs.go | 10 ++++++++++ pkg/gui/keybindings.go | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/pkg/gui/container_logs.go b/pkg/gui/container_logs.go index d676db22..84ff6fb5 100644 --- a/pkg/gui/container_logs.go +++ b/pkg/gui/container_logs.go @@ -221,6 +221,16 @@ func (gui *Gui) handleContainerViewLogsExternal(g *gocui.Gui, v *gocui.View) err return gui.handleViewLogsExternal(ctr) } +func (gui *Gui) handleMainViewLogsExternal(g *gocui.Gui, v *gocui.View) error { + currentSideViewName := gui.currentSideViewName() + if currentSideViewName == "containers" { + return gui.handleContainerViewLogsExternal(g, v) + } else if currentSideViewName == "services" { + return gui.handleServiceViewLogsExternal(g, v) + } + return nil +} + func (gui *Gui) handleViewLogsExternal(container *commands.Container) error { pager := gui.Config.UserConfig.Logs.Pager if pager == "" { diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 79d0fad6..8e106ad5 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -452,6 +452,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Handler: gui.handleExitMain, Description: gui.Tr.Return, }, + { + ViewName: "main", + Key: 'l', + Modifier: gocui.ModNone, + Handler: gui.handleMainViewLogsExternal, + Description: "View logs with external pager", + }, { ViewName: "main", Key: gocui.KeyArrowLeft, From 03c26a05e06c49e6c46e044dbb848de98309050e Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 18:29:10 +0900 Subject: [PATCH 10/12] feat: add Korean keyboard layout support for keybindings --- pkg/gui/keybindings.go | 86 ++++++++++++++++++++++++++++++++++++- pkg/gui/keybindings_test.go | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 pkg/gui/keybindings_test.go diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 8e106ad5..aa6e96ea 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -3,6 +3,7 @@ package gui import ( "fmt" + "github.com/gdamore/tcell/v2" "github.com/jesseduffield/gocui" ) @@ -609,7 +610,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { } } - return bindings + return translateKeybindingsToKorean(bindings) } func (gui *Gui) keybindings(g *gocui.Gui) error { @@ -633,3 +634,86 @@ func wrappedHandler(f func() error) func(*gocui.Gui, *gocui.View) error { return f() } } + +type koreanKey struct { + key rune + modifier gocui.Modifier +} + +var englishToKorean = map[rune]koreanKey{ + // Lowercase + 'q': {key: 'ㅂ', modifier: gocui.ModNone}, + 'w': {key: 'ㅈ', modifier: gocui.ModNone}, + 'e': {key: 'ㄷ', modifier: gocui.ModNone}, + 'r': {key: 'ㄱ', modifier: gocui.ModNone}, + 't': {key: 'ㅅ', modifier: gocui.ModNone}, + 'y': {key: 'ㅛ', modifier: gocui.ModNone}, + 'u': {key: 'ㅕ', modifier: gocui.ModNone}, + 'i': {key: 'ㅑ', modifier: gocui.ModNone}, + 'o': {key: 'ㅐ', modifier: gocui.ModNone}, + 'p': {key: 'ㅔ', modifier: gocui.ModNone}, + 'a': {key: 'ㅁ', modifier: gocui.ModNone}, + 's': {key: 'ㄴ', modifier: gocui.ModNone}, + 'd': {key: 'ㅇ', modifier: gocui.ModNone}, + 'f': {key: 'ㄹ', modifier: gocui.ModNone}, + 'g': {key: 'ㅎ', modifier: gocui.ModNone}, + 'h': {key: 'ㅗ', modifier: gocui.ModNone}, + 'j': {key: 'ㅓ', modifier: gocui.ModNone}, + 'k': {key: 'ㅏ', modifier: gocui.ModNone}, + 'l': {key: 'ㅣ', modifier: gocui.ModNone}, + 'z': {key: 'ㅋ', modifier: gocui.ModNone}, + 'x': {key: 'ㅌ', modifier: gocui.ModNone}, + 'c': {key: 'ㅊ', modifier: gocui.ModNone}, + 'v': {key: 'ㅍ', modifier: gocui.ModNone}, + 'b': {key: 'ㅠ', modifier: gocui.ModNone}, + 'n': {key: 'ㅜ', modifier: gocui.ModNone}, + 'm': {key: 'ㅡ', modifier: gocui.ModNone}, + + // Uppercase (Double Consonants - ModNone) + 'Q': {key: 'ㅃ', modifier: gocui.ModNone}, + 'W': {key: 'ㅉ', modifier: gocui.ModNone}, + 'E': {key: 'ㄸ', modifier: gocui.ModNone}, + 'R': {key: 'ㄲ', modifier: gocui.ModNone}, + 'T': {key: 'ㅆ', modifier: gocui.ModNone}, + 'O': {key: 'ㅒ', modifier: gocui.ModNone}, + 'P': {key: 'ㅖ', modifier: gocui.ModNone}, + + // Uppercase (No Double Consonant - ModShift) + 'A': {key: 'ㅁ', modifier: gocui.Modifier(tcell.ModShift)}, + 'S': {key: 'ㄴ', modifier: gocui.Modifier(tcell.ModShift)}, + 'D': {key: 'ㅇ', modifier: gocui.Modifier(tcell.ModShift)}, + 'F': {key: 'ㄹ', modifier: gocui.Modifier(tcell.ModShift)}, + 'G': {key: 'ㅎ', modifier: gocui.Modifier(tcell.ModShift)}, + 'H': {key: 'ㅗ', modifier: gocui.Modifier(tcell.ModShift)}, + 'J': {key: 'ㅓ', modifier: gocui.Modifier(tcell.ModShift)}, + 'K': {key: 'ㅏ', modifier: gocui.Modifier(tcell.ModShift)}, + 'L': {key: 'ㅣ', modifier: gocui.Modifier(tcell.ModShift)}, + 'Z': {key: 'ㅋ', modifier: gocui.Modifier(tcell.ModShift)}, + 'X': {key: 'ㅌ', modifier: gocui.Modifier(tcell.ModShift)}, + 'C': {key: 'ㅊ', modifier: gocui.Modifier(tcell.ModShift)}, + 'V': {key: 'ㅍ', modifier: gocui.Modifier(tcell.ModShift)}, + 'B': {key: 'ㅠ', modifier: gocui.Modifier(tcell.ModShift)}, + 'N': {key: 'ㅜ', modifier: gocui.Modifier(tcell.ModShift)}, + 'M': {key: 'ㅡ', modifier: gocui.Modifier(tcell.ModShift)}, + 'Y': {key: 'ㅛ', modifier: gocui.Modifier(tcell.ModShift)}, + 'U': {key: 'ㅕ', modifier: gocui.Modifier(tcell.ModShift)}, + 'I': {key: 'ㅑ', modifier: gocui.Modifier(tcell.ModShift)}, +} + +func translateKeybindingsToKorean(bindings []*Binding) []*Binding { + var translated []*Binding + for _, binding := range bindings { + if r, ok := binding.Key.(rune); ok { + if kor, exists := englishToKorean[r]; exists { + translated = append(translated, &Binding{ + ViewName: binding.ViewName, + Handler: binding.Handler, + Key: kor.key, + Modifier: binding.Modifier | kor.modifier, + Description: binding.Description, + }) + } + } + } + return append(bindings, translated...) +} diff --git a/pkg/gui/keybindings_test.go b/pkg/gui/keybindings_test.go new file mode 100644 index 00000000..ee3b7ae4 --- /dev/null +++ b/pkg/gui/keybindings_test.go @@ -0,0 +1,75 @@ +package gui + +import ( + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/jesseduffield/gocui" + "github.com/stretchr/testify/assert" +) + +func TestTranslateKeybindingsToKorean(t *testing.T) { + inputBindings := []*Binding{ + { + ViewName: "services", + Key: 's', + Modifier: gocui.ModNone, + Handler: nil, + }, + { + ViewName: "services", + Key: 'S', + Modifier: gocui.ModNone, + Handler: nil, + }, + { + ViewName: "services", + Key: 'l', + Modifier: gocui.ModNone, + Handler: nil, + }, + { + ViewName: "global", + Key: gocui.KeyCtrlC, + Modifier: gocui.ModNone, + Handler: nil, + }, + } + + result := translateKeybindingsToKorean(inputBindings) + + // The original bindings must remain intact, and new ones should be appended + assert.Len(t, result, len(inputBindings)+3) + + // Verify original bindings are still present and unaltered + for i, b := range inputBindings { + assert.Equal(t, b.Key, result[i].Key) + assert.Equal(t, b.Modifier, result[i].Modifier) + } + + // Verify translated Korean bindings are appended correctly + // 's' -> 'ㄴ' (gocui.ModNone) + // 'S' -> 'ㄴ' (gocui.Modifier(tcell.ModShift)) + // 'l' -> 'ㅣ' (gocui.ModNone) + + // We expect translated bindings to be appended: + foundS := false + foundShiftS := false + foundL := false + + for _, b := range result[len(inputBindings):] { + if b.Key == 'ㄴ' && b.Modifier == gocui.ModNone { + foundS = true + } + if b.Key == 'ㄴ' && b.Modifier == gocui.Modifier(tcell.ModShift) { + foundShiftS = true + } + if b.Key == 'ㅣ' && b.Modifier == gocui.ModNone { + foundL = true + } + } + + assert.True(t, foundS, "Expected to find translated Korean binding for 's'") + assert.True(t, foundShiftS, "Expected to find translated Korean binding for 'S'") + assert.True(t, foundL, "Expected to find translated Korean binding for 'l'") +} From 3d9a6c78d81f7e29a08f77f6b140a0c969a54f51 Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Thu, 4 Jun 2026 18:29:44 +0900 Subject: [PATCH 11/12] docs: document new logs.pager configuration option --- docs/Config.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/Config.md b/docs/Config.md index d386ff31..a2f77e08 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -64,6 +64,7 @@ logs: timestamps: false since: '60m' # set to '' to show all logs tail: '' # set to 200 to show last 200 lines of logs + pager: 'less -R' # command for external logs pager (e.g. 'less -R', 'lnav') commandTemplates: dockerCompose: docker compose # Determines the Docker Compose command to run, referred to as .DockerCompose in commandTemplates restartService: '{{ .DockerCompose }} restart {{ .Service.Name }}' From 0f0783ae3cd100ead3e2284e201101800aa497dd Mon Sep 17 00:00:00 2001 From: hunchulchoi Date: Mon, 15 Jun 2026 16:08:02 +0900 Subject: [PATCH 12/12] config: add fork example config with logs pager Personal fork config template for external log viewer (lnav). Co-authored-by: Cursor --- config/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/config.yml b/config/config.yml index e69de29b..12b0e163 100644 --- a/config/config.yml +++ b/config/config.yml @@ -0,0 +1,2 @@ +logs: + pager: "lnav"