legion-tty 0.4.39 → 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: 7a46889a85e5da8000964e9ac91d0c8e8fba67591fc12ca4bc8a4057339ed09b
4
- data.tar.gz: d769157e7b76054bc01148650474b0e621a22173428628ce2edf41e2c58aafc8
3
+ metadata.gz: 2e5ecb9de86922a5a3d4dd3b3eca6b4f7864103c89399a8a945bbae95e749814
4
+ data.tar.gz: 0d25f75d79303fd7a42a9fbf125768503c48f16e5c749b799cd2013d68bae99a
5
5
  SHA512:
6
- metadata.gz: cfb3a079a43835daf8cb922627ccb2f19e36c9fcfd0577f9cfa9a468f38d0ff1f658eaaeeda82ff202b6213013442e798cca11e9b61ca544ce171173ada7ce8e
7
- data.tar.gz: 4bc9d5d5e2b7173740d4d1a72193e31d77c7bd22bfa745cd39861704804d2f78489dafc915c5a35b916252a554b80df685b4e0bb6136f0cc1bef356b24e57a0f
6
+ metadata.gz: 804bc560650b61a6987a8f9d4d02cd7a315caeaccd1823f1b11a4e1b1fe024fc0eb37a35c59bafe654c0738f2e201af9b04d7274bd9c7d8df02b563c6551336b
7
+ data.tar.gz: 15ab28737a1677ae28285400d1e9ae6b794f1ea021e4cc7c194e949e59a97df56c054419d21fd351c70ea97636608d09a8a6737ca7a690e17b398e87531e6c1c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
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
+
13
+ ## [0.4.40] - 2026-03-28
14
+
15
+ ### Changed
16
+ - All LLM calls in the chat screen now route exclusively through the LegionIO daemon API (`POST /api/llm/inference`) via `Legion::TTY::DaemonClient.inference`
17
+ - Removed soft fallback to raw RubyLLM (`send_via_direct` / `@llm_chat.ask`) — if the daemon is not running, a clear error is displayed: "LegionIO daemon is not running. Start it with: legionio start"
18
+ - Fixed `send_via_daemon`: was calling `Legion::LLM.ask` (which never returns `{status: :done}`) and falling through to direct on every call; now calls `DaemonClient.inference` with the full conversation history
19
+ - Fixed `daemon_available?`: was checking `Legion::LLM::DaemonClient` (an unrelated module); now calls `Legion::TTY::DaemonClient.available?` directly
20
+ - `try_settings_llm` in `App` no longer creates a raw `Legion::LLM.chat` session; daemon availability is logged instead
21
+ - System prompt is now injected per-request as part of the messages array sent to `/api/llm/inference`, not pre-set on a session object
22
+ - Added `DaemonClient.inference` method: `POST /api/llm/inference` with messages array, tools, model, provider; returns `{status: :ok/:error/:unavailable, data:}`
23
+ - Added `build_inference_messages`: assembles system + conversation history + current message for the inference call
24
+ - Added `track_inference_tokens`: tracks input/output tokens from the `data` hash returned by `/api/llm/inference`
25
+
3
26
  ## [0.4.39] - 2026-03-28
4
27
 
5
28
  ### Fixed
@@ -468,15 +468,16 @@ module Legion
468
468
  end
469
469
 
470
470
  def try_settings_llm
471
- return nil unless defined?(Legion::LLM)
472
-
473
- Legion::LLM.start unless Legion::LLM.started?
474
- return nil unless Legion::LLM.started?
475
-
476
- provider = Legion::LLM.settings[:default_provider]
477
- return nil unless provider
478
-
479
- Legion::LLM.chat(provider: provider, caller: { source: 'tty', screen: 'chat' })
471
+ # All LLM calls route through the LegionIO daemon API.
472
+ # No raw RubyLLM session is created here — nil signals "use daemon path".
473
+ if Legion::TTY::DaemonClient.available?
474
+ Legion::Logging.debug('TTY: daemon available, LLM routed through daemon') if defined?(Legion::Logging)
475
+ elsif defined?(Legion::Logging)
476
+ if defined?(Legion::Logging)
477
+ Legion::Logging.warn('TTY: daemon not running; LLM unavailable until daemon starts')
478
+ end
479
+ end
480
+ nil
480
481
  rescue StandardError => e
481
482
  Legion::Logging.warn("try_settings_llm failed: #{e.message}") if defined?(Legion::Logging)
482
483
  nil
@@ -10,6 +10,7 @@ module Legion
10
10
  module DaemonClient
11
11
  SUCCESS_CODES = [200, 201, 202].freeze
12
12
 
13
+ # rubocop:disable Metrics/ClassLength
13
14
  class << self
14
15
  def configure(daemon_url: 'http://127.0.0.1:4567', cache_file: nil, timeout: 5)
15
16
  @daemon_url = daemon_url
@@ -89,6 +90,19 @@ module Legion
89
90
  nil
90
91
  end
91
92
 
93
+ def inference(messages:, tools: [], model: nil, provider: nil, timeout: 120)
94
+ response = post_inference(messages: messages, tools: tools, model: model,
95
+ provider: provider, timeout: timeout)
96
+ return inference_error_result(response) unless SUCCESS_CODES.include?(response.code.to_i)
97
+
98
+ body = Legion::JSON.load(response.body)
99
+ data = body[:data] || body
100
+ { status: :ok, data: data }
101
+ rescue StandardError => e
102
+ Legion::Logging.warn("inference failed: #{e.message}") if defined?(Legion::Logging)
103
+ { status: :unavailable, error: { message: e.message } }
104
+ end
105
+
92
106
  def reset!
93
107
  @daemon_url = nil
94
108
  @cache_file = nil
@@ -98,6 +112,29 @@ module Legion
98
112
 
99
113
  private
100
114
 
115
+ def post_inference(messages:, tools:, model:, provider:, timeout:)
116
+ uri = URI("#{daemon_url}/api/llm/inference")
117
+ payload = Legion::JSON.dump({ messages: messages, tools: tools,
118
+ model: model, provider: provider }.compact)
119
+ http_timeout = [timeout, @timeout || 5].max
120
+ req = Net::HTTP::Post.new(uri)
121
+ req['Content-Type'] = 'application/json'
122
+ req.body = payload
123
+ Net::HTTP.start(uri.hostname, uri.port,
124
+ open_timeout: @timeout || 5,
125
+ read_timeout: http_timeout) { |h| h.request(req) }
126
+ end
127
+
128
+ def inference_error_result(response)
129
+ body = begin
130
+ Legion::JSON.load(response.body)
131
+ rescue StandardError
132
+ {}
133
+ end
134
+ err = body.dig(:error, :message) || body.dig(:data, :error, :message) || "HTTP #{response.code}"
135
+ { status: :error, error: { message: err } }
136
+ end
137
+
101
138
  def daemon_url
102
139
  @daemon_url || 'http://127.0.0.1:4567'
103
140
  end
@@ -119,6 +156,7 @@ module Legion
119
156
  nil
120
157
  end
121
158
  end
159
+ # rubocop:enable Metrics/ClassLength
122
160
  end
123
161
  end
124
162
  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
@@ -18,33 +18,18 @@ module Legion
18
18
  end
19
19
 
20
20
  def switch_model(name)
21
- unless @llm_chat
22
- @message_stream.add_message(role: :system, content: 'No active LLM session.')
23
- return
24
- end
25
-
26
- apply_model_switch(name)
21
+ @preferred_model = name
22
+ @status_bar.update(model: name)
23
+ @token_tracker.update_model(name)
24
+ @message_stream.add_message(role: :system,
25
+ content: "Model preference set to: #{name} (applied on next daemon request)")
27
26
  rescue StandardError => e
28
27
  Legion::Logging.warn("switch_model failed: #{e.message}") if defined?(Legion::Logging)
29
28
  @message_stream.add_message(role: :system, content: "Failed to switch model: #{e.message}")
30
29
  end
31
30
 
32
31
  def apply_model_switch(name)
33
- new_chat = try_provider_switch(name)
34
- if new_chat
35
- @llm_chat = new_chat
36
- @status_bar.update(model: name)
37
- @token_tracker.update_model(name)
38
- @message_stream.add_message(role: :system, content: "Switched to provider: #{name}")
39
- elsif @llm_chat.respond_to?(:with_model)
40
- @llm_chat.with_model(name)
41
- @status_bar.update(model: name)
42
- @token_tracker.update_model(name)
43
- @message_stream.add_message(role: :system, content: "Model switched to: #{name}")
44
- else
45
- @status_bar.update(model: name)
46
- @message_stream.add_message(role: :system, content: "Model set to: #{name}")
47
- end
32
+ switch_model(name)
48
33
  end
49
34
 
50
35
  def try_provider_switch(name)
@@ -75,9 +60,8 @@ module Legion
75
60
  end
76
61
 
77
62
  def show_current_model
78
- model = @llm_chat.respond_to?(:model) ? @llm_chat.model : nil
79
63
  provider = safe_config[:provider] || 'unknown'
80
- info = model ? "#{model} (#{provider})" : provider
64
+ info = @preferred_model ? "#{@preferred_model} (#{provider})" : provider
81
65
  @message_stream.add_message(role: :system, content: "Current model: #{info}")
82
66
  end
83
67
 
@@ -73,10 +73,10 @@ module Legion
73
73
  @output = output
74
74
  @message_stream = Components::MessageStream.new
75
75
  @status_bar = Components::StatusBar.new
76
- @llm_chat = app.respond_to?(:llm_chat) ? app.llm_chat : nil
76
+ @llm_chat = nil
77
77
  @token_tracker = Components::TokenTracker.new(
78
78
  provider: detect_provider,
79
- model: @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : nil
79
+ model: nil
80
80
  )
81
81
  @session_store = SessionStore.new
82
82
  @session_name = 'default'
@@ -174,16 +174,14 @@ module Legion
174
174
  end
175
175
 
176
176
  def send_to_llm(message)
177
- unless @llm_chat || daemon_available?
178
- @message_stream.append_streaming('LLM not configured. Use /help for commands.')
177
+ unless daemon_available?
178
+ @message_stream.append_streaming(
179
+ 'LegionIO daemon is not running. Start it with: legionio start'
180
+ )
179
181
  return
180
182
  end
181
183
 
182
- if daemon_available?
183
- send_via_daemon(message)
184
- else
185
- send_via_direct(message)
186
- end
184
+ send_via_daemon(message)
187
185
  rescue StandardError => e
188
186
  Legion::Logging.error("send_to_llm failed: #{e.message}") if defined?(Legion::Logging)
189
187
  @status_bar.update(thinking: false)
@@ -239,60 +237,47 @@ module Legion
239
237
  end
240
238
 
241
239
  def setup_system_prompt
242
- cfg = safe_config
243
- return unless @llm_chat && cfg.is_a?(Hash) && !cfg.empty?
244
-
245
- prompt = build_system_prompt(cfg)
246
- @llm_chat.with_instructions(prompt) if @llm_chat.respond_to?(:with_instructions)
240
+ # System prompt is injected per-request in build_inference_messages.
241
+ # Nothing to do at activation time.
247
242
  end
248
243
 
244
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
249
245
  def send_via_daemon(message)
250
- result = Legion::LLM.ask(message: message)
246
+ @status_bar.update(thinking: true)
247
+ @streaming = true
248
+ @app.render_frame if @app.respond_to?(:render_frame)
249
+
250
+ start_time = Time.now
251
+ messages = build_inference_messages(message)
252
+ result = Legion::TTY::DaemonClient.inference(
253
+ messages: messages,
254
+ model: @preferred_model
255
+ )
251
256
 
252
- case result&.dig(:status)
253
- when :done
257
+ case result[:status]
258
+ when :ok
259
+ data = result[:data] || {}
260
+ content = data[:content].to_s
254
261
  parser = build_tool_call_parser
255
- parser.feed(result[:response])
262
+ parser.feed(content)
256
263
  parser.flush
257
- track_daemon_tokens(result)
264
+ record_response_time(Time.now - start_time)
265
+ track_inference_tokens(data)
266
+ speak_response(content) if @speak_mode
258
267
  when :error
259
268
  err = result.dig(:error, :message) || 'Unknown error'
260
269
  @message_stream.append_streaming("\n[Daemon error: #{err}]")
261
- else
262
- send_via_direct(message)
270
+ when :unavailable
271
+ err = result.dig(:error, :message) || 'Daemon unavailable'
272
+ @message_stream.append_streaming(
273
+ "\nLegionIO daemon is not running. Start it with: legionio start\n[#{err}]"
274
+ )
263
275
  end
264
- rescue StandardError => e
265
- Legion::Logging.warn("send_via_daemon failed: #{e.message}") if defined?(Legion::Logging)
266
- send_via_direct(message)
267
- end
268
-
269
- # rubocop:disable Metrics/AbcSize
270
- def send_via_direct(message)
271
- return unless @llm_chat
272
-
273
- @status_bar.update(thinking: true)
274
- @streaming = true
275
- @app.render_frame if @app.respond_to?(:render_frame)
276
- start_time = Time.now
277
- response_text = +''
278
- parser = build_tool_call_parser
279
- response = @llm_chat.ask(message) do |chunk|
280
- @status_bar.update(thinking: false)
281
- if chunk.content
282
- response_text << chunk.content
283
- parser.feed(chunk.content)
284
- end
285
- @app.render_frame if @app.respond_to?(:render_frame)
286
- end
287
- parser.flush
288
- record_response_time(Time.now - start_time)
289
- @status_bar.update(thinking: false)
290
- track_response_tokens(response)
291
- speak_response(response_text) if @speak_mode
292
276
  ensure
277
+ @status_bar.update(thinking: false)
293
278
  @streaming = false
294
279
  end
295
- # rubocop:enable Metrics/AbcSize
280
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
296
281
 
297
282
  def speak_response(text)
298
283
  return unless RUBY_PLATFORM =~ /darwin/
@@ -310,7 +295,47 @@ module Legion
310
295
  end
311
296
 
312
297
  def daemon_available?
313
- !!(defined?(Legion::LLM::DaemonClient) && Legion::LLM::DaemonClient.available?)
298
+ Legion::TTY::DaemonClient.available?
299
+ rescue StandardError => e
300
+ Legion::Logging.debug("daemon_available? check failed: #{e.message}") if defined?(Legion::Logging)
301
+ false
302
+ end
303
+
304
+ def build_inference_messages(current_message)
305
+ msgs = []
306
+ inject_system_message(msgs)
307
+ inject_history_messages(msgs)
308
+ msgs.pop if msgs.last&.dig(:role) == 'user'
309
+ msgs << { role: 'user', content: current_message }
310
+ msgs
311
+ end
312
+
313
+ def inject_system_message(msgs)
314
+ prompt = build_system_prompt(safe_config)
315
+ msgs << { role: 'system', content: prompt } if prompt && !prompt.strip.empty?
316
+ end
317
+
318
+ def inject_history_messages(msgs)
319
+ @message_stream.messages.each do |m|
320
+ next if m[:tool_panel]
321
+ next unless %i[user assistant].include?(m[:role])
322
+
323
+ content = m[:content].to_s
324
+ next if content.strip.empty?
325
+
326
+ msgs << { role: m[:role].to_s, content: content }
327
+ end
328
+ end
329
+
330
+ def track_inference_tokens(data)
331
+ return unless data.is_a?(Hash) && (data[:input_tokens] || data[:output_tokens])
332
+
333
+ @token_tracker.track(
334
+ input_tokens: data[:input_tokens].to_i,
335
+ output_tokens: data[:output_tokens].to_i,
336
+ model: data[:model]&.to_s
337
+ )
338
+ update_status_bar_tokens
314
339
  end
315
340
 
316
341
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -638,18 +663,6 @@ module Legion
638
663
  update_status_bar_tokens
639
664
  end
640
665
 
641
- def track_daemon_tokens(result)
642
- meta = result[:meta]
643
- return unless meta.is_a?(Hash) && (meta[:tokens_in] || meta[:tokens_out])
644
-
645
- @token_tracker.track(
646
- input_tokens: meta[:tokens_in].to_i,
647
- output_tokens: meta[:tokens_out].to_i,
648
- model: meta[:model]&.to_s
649
- )
650
- update_status_bar_tokens
651
- end
652
-
653
666
  def update_status_bar_tokens
654
667
  @status_bar.update(
655
668
  tokens: @token_tracker.total_input_tokens + @token_tracker.total_output_tokens,
@@ -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.39'
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.39
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