legion-tty 0.4.40 → 0.4.42

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: 544115343036952adfc2f3accc8ce189ec8bb13c06f04ce383d0536549118e49
4
+ data.tar.gz: 806f22df6bed2c34ce6c47c6eab8c1af6bb611cc458db878087a86163f7e0248
5
5
  SHA512:
6
- metadata.gz: 757d74c3e7e0d7711cc9bb0cb410c145e7e8ca31d484d62ad53160df64c34205be19d6eb0fa91d7aede125af63ff1d005f596ea10c285fcbdc97adb5c74eee19
7
- data.tar.gz: df6acc61f03cfc4631ccd28eaea40813314aadc8211497e857507ac7453ab700952bf6588a4d34016d3d5196651680583a293ff1bfba204101408b62a5c7fd4d
6
+ metadata.gz: 4a90450fe8ba541b1823f43cfc89eb97ed2ef6185029afffc022771c87f8909583fab80715098789240b8bab4ee3c8c2ae240987dc9630352c76d89ee3acad6a
7
+ data.tar.gz: '0667478fd7867da23c0c5f4a83fdeac3056cc97ff18a2bf1dbe0c447f4a29088b0e1d6aafeb6d9b19ffc8a1784b4cd80f1a94a2d9ea5a2563fca063004020ee5'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.42] - 2026-04-08
4
+
5
+ ### Changed
6
+ - `DaemonClient` now uses `Legion::Logging::Helper` (structured logging via `log.info`/`log.debug`) instead of inline `Legion::Logging.method if defined?` guards for all logging and exception handling
7
+ - Bumped `legion-logging` minimum dependency from `>= 1.2.8` to `>= 1.5.0` to use `handle_exception` from `Legion::Logging::Helper`
8
+ - Extracted `store_manifest` and `parse_inference_response` private helpers to reduce method complexity in `fetch_manifest` and `inference`
9
+
10
+ ### Fixed
11
+ - `KerberosProbe#days_in_month` used `Time.new(year, month, -1)` which raises `ArgumentError` for day `-1`; replaced with `Date.new(year, month, -1).day` (correct Ruby idiom for last day of month)
12
+
13
+ ## [0.4.41] - 2026-03-31
14
+
15
+ ### Added
16
+ - `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)
17
+ - `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)
18
+
19
+ ### Changed
20
+ - `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)
21
+ - `SessionStore::SESSION_DIR` confirmed as `~/.legionio/sessions/` (no legacy `~/.legion/` references)
22
+
3
23
  ## [0.4.40] - 2026-03-28
4
24
 
5
25
  ### Changed
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Rich terminal UI for the LegionIO async cognition engine.
4
4
 
5
- **Version**: 0.4.35
5
+ **Version**: 0.4.42
6
6
 
7
7
  Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell with 115 slash commands, operational dashboard, extensions browser, config editor, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
8
8
 
@@ -289,8 +289,8 @@ Boot logs go to `~/.legionio/logs/tty-boot.log`.
289
289
 
290
290
  ```bash
291
291
  bundle install
292
- bundle exec rspec # 1817 examples, 0 failures
293
- bundle exec rubocop # 150 files, 0 offenses
292
+ bundle exec rspec # 1952 examples, 0 failures
293
+ bundle exec rubocop # 163 files, 0 offenses
294
294
  ```
295
295
 
296
296
  ## License
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
3
4
  require 'resolv'
4
5
  require 'shellwords'
5
6
 
@@ -185,7 +186,7 @@ module Legion
185
186
  # rubocop:enable Metrics/AbcSize
186
187
 
187
188
  def days_in_month(month, year)
188
- Time.new(year, month, -1).day
189
+ Date.new(year, month, -1).day
189
190
  end
190
191
 
191
192
  def log_result(result, elapsed)
@@ -4,6 +4,7 @@ require 'net/http'
4
4
  require 'uri'
5
5
  require 'fileutils'
6
6
  require 'legion/json'
7
+ require 'legion/logging'
7
8
 
8
9
  module Legion
9
10
  module TTY
@@ -12,11 +13,14 @@ module Legion
12
13
 
13
14
  # rubocop:disable Metrics/ClassLength
14
15
  class << self
16
+ include Legion::Logging::Helper
17
+
15
18
  def configure(daemon_url: 'http://127.0.0.1:4567', cache_file: nil, timeout: 5)
16
19
  @daemon_url = daemon_url
17
20
  @cache_file = cache_file || File.expand_path('~/.legionio/catalog.json')
18
21
  @timeout = timeout
19
22
  @manifest = nil
23
+ log.info { "TTY daemon client configured daemon_url=#{@daemon_url} timeout=#{@timeout}" }
20
24
  end
21
25
 
22
26
  def available?
@@ -26,7 +30,7 @@ module Legion
26
30
  end
27
31
  response.code.to_i == 200
28
32
  rescue StandardError => e
29
- Legion::Logging.debug("daemon available? check failed: #{e.message}") if defined?(Legion::Logging)
33
+ handle_exception(e, level: :debug, operation: 'tty.daemon_client.available?', daemon_url: daemon_url)
30
34
  false
31
35
  end
32
36
 
@@ -37,12 +41,9 @@ module Legion
37
41
  end
38
42
  return nil unless response.code.to_i == 200
39
43
 
40
- body = Legion::JSON.load(response.body)
41
- @manifest = body[:data]
42
- write_cache(@manifest)
43
- @manifest
44
+ store_manifest(Legion::JSON.load(response.body)[:data])
44
45
  rescue StandardError => e
45
- Legion::Logging.warn("fetch_manifest failed: #{e.message}") if defined?(Legion::Logging)
46
+ handle_exception(e, level: :warn, operation: 'tty.daemon_client.fetch_manifest', daemon_url: daemon_url)
46
47
  nil
47
48
  end
48
49
 
@@ -53,7 +54,7 @@ module Legion
53
54
 
54
55
  @manifest = Legion::JSON.load(File.read(@cache_file))
55
56
  rescue StandardError => e
56
- Legion::Logging.warn("cached_manifest failed: #{e.message}") if defined?(Legion::Logging)
57
+ handle_exception(e, level: :warn, operation: 'tty.daemon_client.cached_manifest', cache_file: @cache_file)
57
58
  nil
58
59
  end
59
60
 
@@ -80,26 +81,28 @@ module Legion
80
81
 
81
82
  uri = URI("#{daemon_url}/api/llm/chat")
82
83
  payload = Legion::JSON.dump({ message: message, model: model, provider: provider })
84
+ log.debug { "TTY chat request model=#{model} provider=#{provider} message_length=#{message.to_s.length}" }
83
85
  response = post_json(uri, payload)
84
86
 
85
87
  return nil unless response && SUCCESS_CODES.include?(response.code.to_i)
86
88
 
87
89
  Legion::JSON.load(response.body)
88
90
  rescue StandardError => e
89
- Legion::Logging.warn("chat failed: #{e.message}") if defined?(Legion::Logging)
91
+ handle_exception(e, level: :warn, operation: 'tty.daemon_client.chat',
92
+ daemon_url: daemon_url, model: model, provider: provider)
90
93
  nil
91
94
  end
92
95
 
93
96
  def inference(messages:, tools: [], model: nil, provider: nil, timeout: 120)
97
+ log.debug { "TTY inference model=#{model} provider=#{provider} msgs=#{Array(messages).size}" }
94
98
  response = post_inference(messages: messages, tools: tools, model: model,
95
99
  provider: provider, timeout: timeout)
96
100
  return inference_error_result(response) unless SUCCESS_CODES.include?(response.code.to_i)
97
101
 
98
- body = Legion::JSON.load(response.body)
99
- data = body[:data] || body
100
- { status: :ok, data: data }
102
+ parse_inference_response(response)
101
103
  rescue StandardError => e
102
- Legion::Logging.warn("inference failed: #{e.message}") if defined?(Legion::Logging)
104
+ handle_exception(e, level: :warn, operation: 'tty.daemon_client.inference',
105
+ daemon_url: daemon_url, model: model, provider: provider, timeout: timeout)
103
106
  { status: :unavailable, error: { message: e.message } }
104
107
  end
105
108
 
@@ -112,6 +115,19 @@ module Legion
112
115
 
113
116
  private
114
117
 
118
+ def parse_inference_response(response)
119
+ body = Legion::JSON.load(response.body)
120
+ data = body[:data] || body
121
+ { status: :ok, data: data }
122
+ end
123
+
124
+ def store_manifest(data)
125
+ @manifest = data
126
+ write_cache(@manifest)
127
+ log.info { "TTY fetched daemon manifest entries=#{Array(@manifest).size}" }
128
+ @manifest
129
+ end
130
+
115
131
  def post_inference(messages:, tools:, model:, provider:, timeout:)
116
132
  uri = URI("#{daemon_url}/api/llm/inference")
117
133
  payload = Legion::JSON.dump({ messages: messages, tools: tools,
@@ -151,8 +167,9 @@ module Legion
151
167
 
152
168
  FileUtils.mkdir_p(File.dirname(@cache_file))
153
169
  File.write(@cache_file, Legion::JSON.dump(data))
170
+ log.debug { "TTY daemon manifest cache updated file=#{@cache_file}" }
154
171
  rescue StandardError => e
155
- Legion::Logging.warn("write_cache failed: #{e.message}") if defined?(Legion::Logging)
172
+ handle_exception(e, level: :warn, operation: 'tty.daemon_client.write_cache', cache_file: @cache_file)
156
173
  nil
157
174
  end
158
175
  end
@@ -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.42'
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.42
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -197,14 +197,14 @@ dependencies:
197
197
  requirements:
198
198
  - - ">="
199
199
  - !ruby/object:Gem::Version
200
- version: 1.2.8
200
+ version: 1.5.0
201
201
  type: :runtime
202
202
  prerelease: false
203
203
  version_requirements: !ruby/object:Gem::Requirement
204
204
  requirements:
205
205
  - - ">="
206
206
  - !ruby/object:Gem::Version
207
- version: 1.2.8
207
+ version: 1.5.0
208
208
  description: Rich TUI with onboarding wizard, AI chat shell, and operational dashboards
209
209
  for LegionIO
210
210
  email:
@@ -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