legion-tty 0.4.40 → 0.4.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/lib/legion/tty/keybinding_manager.rb +143 -0
- data/lib/legion/tty/notify.rb +147 -0
- data/lib/legion/tty/session_store.rb +25 -7
- data/lib/legion/tty/version.rb +1 -1
- data/lib/legion/tty.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e5ecb9de86922a5a3d4dd3b3eca6b4f7864103c89399a8a945bbae95e749814
|
|
4
|
+
data.tar.gz: 0d25f75d79303fd7a42a9fbf125768503c48f16e5c749b799cd2013d68bae99a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 804bc560650b61a6987a8f9d4d02cd7a315caeaccd1823f1b11a4e1b1fe024fc0eb37a35c59bafe654c0738f2e201af9b04d7274bd9c7d8df02b563c6551336b
|
|
7
|
+
data.tar.gz: 15ab28737a1677ae28285400d1e9ae6b794f1ea021e4cc7c194e949e59a97df56c054419d21fd351c70ea97636608d09a8a6737ca7a690e17b398e87531e6c1c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.41] - 2026-03-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `KeybindingManager`: context-aware keybinding system with named contexts (`global`, `chat`, `dashboard`, `extensions`, `config`, `command_palette`, `session_picker`, `history`), chord sequence support (`@pending_chord` state machine), `resolve(key, active_contexts:)` method, and user-customizable overrides via `~/.legionio/keybindings.json` (closes #10)
|
|
7
|
+
- `Legion::TTY::Notify`: OS-level terminal notification module with auto-detection of iTerm2 (OSC 9), Kitty (`kitten notify`), Ghostty (OSC 99), Linux (`notify-send`), macOS (`osascript`), and bell fallback; settings integration via `notifications.terminal.enabled` / `notifications.terminal.backend` (closes #11)
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- `SessionStore#load` now normalizes both TTY v1 format and CLI legacy format (RubyLLM `model`+`stats`+`summary` fields) into a canonical `{ role: :symbol, content:, tool_panels: [] }` shape; fills in default `version`, `metadata`, `name`, and `saved_at` when absent (closes #9)
|
|
11
|
+
- `SessionStore::SESSION_DIR` confirmed as `~/.legionio/sessions/` (no legacy `~/.legion/` references)
|
|
12
|
+
|
|
3
13
|
## [0.4.40] - 2026-03-28
|
|
4
14
|
|
|
5
15
|
### Changed
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module TTY
|
|
8
|
+
# KeybindingManager — context-aware keybinding system with chord support and user customization.
|
|
9
|
+
#
|
|
10
|
+
# Named contexts:
|
|
11
|
+
# :global, :chat, :dashboard, :extensions, :config, :command_palette, :session_picker, :history
|
|
12
|
+
#
|
|
13
|
+
# Chord sequences: two-key combos stored as "key1+key2" strings. Set @pending_chord between
|
|
14
|
+
# key presses; the second key resolves the chord action.
|
|
15
|
+
#
|
|
16
|
+
# User overrides loaded from ~/.legionio/keybindings.json at boot.
|
|
17
|
+
class KeybindingManager
|
|
18
|
+
CONTEXTS = %i[global chat dashboard extensions config command_palette session_picker history].freeze
|
|
19
|
+
|
|
20
|
+
OVERRIDES_PATH = File.expand_path('~/.legionio/keybindings.json')
|
|
21
|
+
|
|
22
|
+
DEFAULT_BINDINGS = {
|
|
23
|
+
ctrl_d: { contexts: %i[global chat], action: :toggle_dashboard, description: 'Toggle dashboard (Ctrl+D)' },
|
|
24
|
+
ctrl_k: { contexts: %i[global chat], action: :command_palette, description: 'Open command palette (Ctrl+K)' },
|
|
25
|
+
ctrl_s: { contexts: %i[global chat], action: :session_picker, description: 'Open session picker (Ctrl+S)' },
|
|
26
|
+
ctrl_l: { contexts: %i[global chat dashboard], action: :refresh, description: 'Refresh screen (Ctrl+L)' },
|
|
27
|
+
escape: { contexts: CONTEXTS, action: :back, description: 'Go back / dismiss overlay (Escape)' },
|
|
28
|
+
tab: { contexts: %i[chat], action: :autocomplete, description: 'Auto-complete (Tab)' },
|
|
29
|
+
ctrl_c: { contexts: CONTEXTS, action: :interrupt, description: 'Interrupt / quit (Ctrl+C)' }
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(overrides_path: OVERRIDES_PATH)
|
|
33
|
+
@overrides_path = overrides_path
|
|
34
|
+
@bindings = {}
|
|
35
|
+
@pending_chord = nil
|
|
36
|
+
load_defaults
|
|
37
|
+
load_user_overrides
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Resolve a key press given the currently active contexts.
|
|
41
|
+
#
|
|
42
|
+
# @param key [Symbol, String] normalized key (e.g. :ctrl_d, :escape)
|
|
43
|
+
# @param active_contexts [Array<Symbol>] contexts currently in scope (most specific last)
|
|
44
|
+
# @return [Symbol, nil] action name, or nil if no binding matches
|
|
45
|
+
def resolve(key, active_contexts: [:global])
|
|
46
|
+
key_sym = key.to_s.to_sym
|
|
47
|
+
|
|
48
|
+
# Chord resolution: if a chord is pending, try to complete it
|
|
49
|
+
if @pending_chord
|
|
50
|
+
chord = :"#{@pending_chord}+#{key_sym}"
|
|
51
|
+
@pending_chord = nil
|
|
52
|
+
return action_for(chord, active_contexts)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if this key starts a chord
|
|
56
|
+
if chord_starter?(key_sym)
|
|
57
|
+
@pending_chord = key_sym
|
|
58
|
+
return :chord_pending
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
action_for(key_sym, active_contexts)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Cancel any in-progress chord sequence.
|
|
65
|
+
def cancel_chord
|
|
66
|
+
@pending_chord = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Whether a chord is currently waiting for its second key.
|
|
70
|
+
def chord_pending?
|
|
71
|
+
!@pending_chord.nil?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Register or override a single binding.
|
|
75
|
+
# @param key [Symbol, String] normalized key
|
|
76
|
+
# @param action [Symbol] action name
|
|
77
|
+
# @param contexts [Array<Symbol>] applicable contexts (:global means all)
|
|
78
|
+
# @param description [String]
|
|
79
|
+
def bind(key, action:, contexts: [:global], description: '')
|
|
80
|
+
@bindings[key.to_s.to_sym] = { contexts: contexts, action: action, description: description }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Remove a binding.
|
|
84
|
+
def unbind(key)
|
|
85
|
+
@bindings.delete(key.to_s.to_sym)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# All registered bindings as an array of hashes.
|
|
89
|
+
def list
|
|
90
|
+
@bindings.map do |key, b|
|
|
91
|
+
{ key: key, action: b[:action], contexts: b[:contexts], description: b[:description] }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Reload default bindings (resets user overrides).
|
|
96
|
+
def load_defaults
|
|
97
|
+
@bindings = {}
|
|
98
|
+
DEFAULT_BINDINGS.each do |key, binding|
|
|
99
|
+
@bindings[key] = binding.dup
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Load user overrides from ~/.legionio/keybindings.json.
|
|
104
|
+
# File format: { "ctrl_d": { "action": "toggle_dashboard", "contexts": ["global"], "description": "..." } }
|
|
105
|
+
def load_user_overrides
|
|
106
|
+
return unless File.exist?(@overrides_path)
|
|
107
|
+
|
|
108
|
+
raw = Legion::JSON.parse(File.read(@overrides_path), symbolize_names: true)
|
|
109
|
+
raw.each { |key, cfg| apply_override(key, cfg) }
|
|
110
|
+
rescue Legion::JSON::ParseError => e
|
|
111
|
+
Legion::Logging.warn("keybindings load failed: #{e.message}") if defined?(Legion::Logging)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def action_for(key_sym, active_contexts)
|
|
117
|
+
binding_entry = @bindings[key_sym]
|
|
118
|
+
return nil unless binding_entry
|
|
119
|
+
|
|
120
|
+
binding_contexts = binding_entry[:contexts]
|
|
121
|
+
return binding_entry[:action] if binding_contexts.include?(:global)
|
|
122
|
+
return binding_entry[:action] if binding_contexts.intersect?(active_contexts)
|
|
123
|
+
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def apply_override(key, cfg)
|
|
128
|
+
return unless cfg.is_a?(Hash) && cfg[:action]
|
|
129
|
+
|
|
130
|
+
contexts = Array(cfg[:contexts] || [:global]).map(&:to_sym)
|
|
131
|
+
@bindings[key.to_s.to_sym] = {
|
|
132
|
+
contexts: contexts,
|
|
133
|
+
action: cfg[:action].to_sym,
|
|
134
|
+
description: cfg[:description].to_s
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def chord_starter?(key_sym)
|
|
139
|
+
@bindings.keys.any? { |k| k.to_s.start_with?("#{key_sym}+") }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module TTY
|
|
5
|
+
# Notify — OS-level terminal notification dispatcher with auto-detection.
|
|
6
|
+
#
|
|
7
|
+
# Auto-detects the terminal environment via TERM_PROGRAM and TERM env vars, then
|
|
8
|
+
# dispatches notifications using the most appropriate backend:
|
|
9
|
+
# - iTerm2: OSC 9 escape sequence
|
|
10
|
+
# - kitty: kitten notify subprocess
|
|
11
|
+
# - Ghostty: OSC 99 escape sequence
|
|
12
|
+
# - Linux: notify-send subprocess
|
|
13
|
+
# - macOS: osascript subprocess
|
|
14
|
+
# - fallback: terminal bell (\a)
|
|
15
|
+
#
|
|
16
|
+
# Settings integration (read from Legion::Settings when available):
|
|
17
|
+
# notifications.terminal.enabled (default: true)
|
|
18
|
+
# notifications.terminal.backend (default: 'auto')
|
|
19
|
+
module Notify
|
|
20
|
+
BACKENDS = %w[iterm2 kitty ghostty notify_send osascript bell].freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Send a notification.
|
|
24
|
+
# @param message [String] notification body
|
|
25
|
+
# @param title [String] notification title
|
|
26
|
+
def send(message, title: 'LegionIO')
|
|
27
|
+
return unless enabled?
|
|
28
|
+
|
|
29
|
+
backend = configured_backend
|
|
30
|
+
dispatch(backend, message: message, title: title)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Detect the current terminal program from environment variables.
|
|
34
|
+
# @return [String] one of: 'iterm2', 'kitty', 'ghostty', 'linux', 'macos', 'unknown'
|
|
35
|
+
def detect_terminal
|
|
36
|
+
term_prog = ::ENV.fetch('TERM_PROGRAM', '').downcase
|
|
37
|
+
term = ::ENV.fetch('TERM', '').downcase
|
|
38
|
+
|
|
39
|
+
return 'iterm2' if term_prog == 'iterm.app'
|
|
40
|
+
return 'kitty' if term_prog == 'kitty' || term == 'xterm-kitty'
|
|
41
|
+
return 'ghostty' if term_prog == 'ghostty'
|
|
42
|
+
return 'linux' if linux?
|
|
43
|
+
return 'macos' if macos?
|
|
44
|
+
|
|
45
|
+
'unknown'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def enabled?
|
|
51
|
+
return true unless defined?(Legion::Settings)
|
|
52
|
+
|
|
53
|
+
setting = settings_dig(:notifications, :terminal, :enabled)
|
|
54
|
+
setting.nil? || setting
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def configured_backend
|
|
58
|
+
backend_setting = settings_dig(:notifications, :terminal, :backend)
|
|
59
|
+
return resolve_auto_backend if backend_setting.nil? || backend_setting.to_s == 'auto'
|
|
60
|
+
|
|
61
|
+
backend_setting.to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_auto_backend
|
|
65
|
+
case detect_terminal
|
|
66
|
+
when 'iterm2' then 'iterm2'
|
|
67
|
+
when 'kitty' then 'kitty'
|
|
68
|
+
when 'ghostty' then 'ghostty'
|
|
69
|
+
when 'linux' then 'notify_send'
|
|
70
|
+
when 'macos' then 'osascript'
|
|
71
|
+
else 'bell'
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def dispatch(backend, message:, title:)
|
|
76
|
+
case backend.to_s
|
|
77
|
+
when 'iterm2' then notify_iterm2(message: message)
|
|
78
|
+
when 'kitty' then notify_kitty(message: message, title: title)
|
|
79
|
+
when 'ghostty' then notify_ghostty(message: message, title: title)
|
|
80
|
+
when 'notify_send' then notify_send(message: message, title: title)
|
|
81
|
+
when 'osascript' then notify_osascript(message: message, title: title)
|
|
82
|
+
else notify_bell
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
Legion::Logging.warn("Notify dispatch failed: #{e.message}") if defined?(Legion::Logging)
|
|
86
|
+
notify_bell
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# iTerm2: OSC 9 — "Application-specific notification"
|
|
90
|
+
def notify_iterm2(message:)
|
|
91
|
+
$stdout.print("\e]9;#{message}\a")
|
|
92
|
+
$stdout.flush
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Kitty: kitten notify subprocess
|
|
96
|
+
def notify_kitty(message:, title:)
|
|
97
|
+
::Kernel.system('kitten', 'notify', '--title', title, message)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Ghostty: OSC 99 (freedesktop desktop notifications via escape sequence)
|
|
101
|
+
def notify_ghostty(message:, title:)
|
|
102
|
+
payload = "i=1:p=body;#{message}\e\\\\#{title}"
|
|
103
|
+
$stdout.print("\e]99;#{payload}\a")
|
|
104
|
+
$stdout.flush
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Linux: notify-send
|
|
108
|
+
def notify_send(message:, title:)
|
|
109
|
+
::Kernel.system('notify-send', title, message)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# macOS: osascript display notification
|
|
113
|
+
def notify_osascript(message:, title:)
|
|
114
|
+
script = "display notification #{message.inspect} with title #{title.inspect}"
|
|
115
|
+
::Kernel.system('osascript', '-e', script)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def notify_bell
|
|
119
|
+
$stdout.print("\a")
|
|
120
|
+
$stdout.flush
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def linux?
|
|
124
|
+
!macos? && (::ENV.key?('DISPLAY') || ::ENV.key?('WAYLAND_DISPLAY') || RUBY_PLATFORM.include?('linux'))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def macos?
|
|
128
|
+
RUBY_PLATFORM.include?('darwin')
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def settings_dig(*keys)
|
|
132
|
+
return nil unless defined?(Legion::Settings)
|
|
133
|
+
|
|
134
|
+
first, *rest = keys
|
|
135
|
+
obj = Legion::Settings[first]
|
|
136
|
+
rest.reduce(obj) do |acc, key|
|
|
137
|
+
break nil unless acc.is_a?(Hash)
|
|
138
|
+
|
|
139
|
+
acc[key]
|
|
140
|
+
end
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'json'
|
|
3
|
+
require 'legion/json'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
|
|
6
6
|
module Legion
|
|
@@ -21,17 +21,16 @@ module Legion
|
|
|
21
21
|
saved_at: Time.now.iso8601,
|
|
22
22
|
version: 1
|
|
23
23
|
}
|
|
24
|
-
File.write(session_path(name), ::JSON.generate(data))
|
|
24
|
+
File.write(session_path(name), Legion::JSON.generate(data))
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def load(name)
|
|
28
28
|
path = session_path(name)
|
|
29
29
|
return nil unless File.exist?(path)
|
|
30
30
|
|
|
31
|
-
data = ::JSON.parse(File.read(path), symbolize_names: true)
|
|
32
|
-
data
|
|
33
|
-
|
|
34
|
-
rescue ::JSON::ParserError => e
|
|
31
|
+
data = Legion::JSON.parse(File.read(path), symbolize_names: true)
|
|
32
|
+
normalize_session(data)
|
|
33
|
+
rescue Legion::JSON::ParseError => e
|
|
35
34
|
Legion::Logging.warn("session load failed: #{e.message}") if defined?(Legion::Logging)
|
|
36
35
|
nil
|
|
37
36
|
end
|
|
@@ -39,7 +38,7 @@ module Legion
|
|
|
39
38
|
def list
|
|
40
39
|
entries = Dir.glob(File.join(@dir, '*.json')).map do |path|
|
|
41
40
|
name = File.basename(path, '.json')
|
|
42
|
-
data = ::JSON.parse(File.read(path), symbolize_names: true)
|
|
41
|
+
data = Legion::JSON.parse(File.read(path), symbolize_names: true)
|
|
43
42
|
{ name: name, saved_at: data[:saved_at], message_count: data[:messages]&.size || 0 }
|
|
44
43
|
rescue StandardError => e
|
|
45
44
|
Legion::Logging.warn("session list entry failed: #{e.message}") if defined?(Legion::Logging)
|
|
@@ -77,6 +76,25 @@ module Legion
|
|
|
77
76
|
def deserialize_message(msg)
|
|
78
77
|
{ role: msg[:role].to_sym, content: msg[:content], tool_panels: [] }
|
|
79
78
|
end
|
|
79
|
+
|
|
80
|
+
# Normalize a loaded session to the canonical TTY format.
|
|
81
|
+
# Handles two shapes:
|
|
82
|
+
# - TTY format (v1): { version: 1, name:, messages: [{role:, content:}], metadata:, saved_at: }
|
|
83
|
+
# - CLI format (legacy): { messages: [{role:, content:, model:, stats:, summary:}] }
|
|
84
|
+
def normalize_session(data)
|
|
85
|
+
data[:messages] = (data[:messages] || []).map { |m| normalize_message(m) }
|
|
86
|
+
data[:version] ||= 1
|
|
87
|
+
data[:metadata] ||= {}
|
|
88
|
+
data[:name] ||= 'imported'
|
|
89
|
+
data[:saved_at] ||= Time.now.iso8601
|
|
90
|
+
data
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def normalize_message(msg)
|
|
94
|
+
role = msg[:role].to_s.to_sym
|
|
95
|
+
content = msg[:content].to_s
|
|
96
|
+
{ role: role, content: content, tool_panels: [] }
|
|
97
|
+
end
|
|
80
98
|
end
|
|
81
99
|
end
|
|
82
100
|
end
|
data/lib/legion/tty/version.rb
CHANGED
data/lib/legion/tty.rb
CHANGED
|
@@ -4,6 +4,8 @@ require_relative 'tty/version'
|
|
|
4
4
|
require_relative 'tty/boot_logger'
|
|
5
5
|
require_relative 'tty/theme'
|
|
6
6
|
require_relative 'tty/hotkeys'
|
|
7
|
+
require_relative 'tty/keybinding_manager'
|
|
8
|
+
require_relative 'tty/notify'
|
|
7
9
|
require_relative 'tty/screen_manager'
|
|
8
10
|
require_relative 'tty/screens/base'
|
|
9
11
|
require_relative 'tty/components/digital_rain'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-tty
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.41
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -246,6 +246,8 @@ files:
|
|
|
246
246
|
- lib/legion/tty/components/wizard_prompt.rb
|
|
247
247
|
- lib/legion/tty/daemon_client.rb
|
|
248
248
|
- lib/legion/tty/hotkeys.rb
|
|
249
|
+
- lib/legion/tty/keybinding_manager.rb
|
|
250
|
+
- lib/legion/tty/notify.rb
|
|
249
251
|
- lib/legion/tty/screen_manager.rb
|
|
250
252
|
- lib/legion/tty/screens/base.rb
|
|
251
253
|
- lib/legion/tty/screens/chat.rb
|