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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60e92790f0f6281b48346af3972e2631f1952b3b68f3d51922aa52da8682cfc9
4
- data.tar.gz: 85491c70182ced9142b483c4edd903f3e5948bac1544c01cba69a86f565d2483
3
+ metadata.gz: 2e5ecb9de86922a5a3d4dd3b3eca6b4f7864103c89399a8a945bbae95e749814
4
+ data.tar.gz: 0d25f75d79303fd7a42a9fbf125768503c48f16e5c749b799cd2013d68bae99a
5
5
  SHA512:
6
- metadata.gz: 757d74c3e7e0d7711cc9bb0cb410c145e7e8ca31d484d62ad53160df64c34205be19d6eb0fa91d7aede125af63ff1d005f596ea10c285fcbdc97adb5c74eee19
7
- data.tar.gz: df6acc61f03cfc4631ccd28eaea40813314aadc8211497e857507ac7453ab700952bf6588a4d34016d3d5196651680583a293ff1bfba204101408b62a5c7fd4d
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[:messages] = data[:messages].map { |m| deserialize_message(m) }
33
- data
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.40'
5
+ VERSION = '0.4.41'
6
6
  end
7
7
  end
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.40
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