legion-tty 0.4.25 → 0.4.27

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: c2baad187737411e10cb975111c2e766cd5b1b1c20bc27b7731cc990672597a0
4
- data.tar.gz: e2d8eaabf4459beba0805f830025321c11fa70ea572aba05640b005e20dbd12a
3
+ metadata.gz: f1f14c29bb844005733b887586662c8c08947f757567ec9438029b423b0c991e
4
+ data.tar.gz: 3e0244816b27e6174541fceba9aea3df6d87a7698f40766cf7341cf12b8ab0b0
5
5
  SHA512:
6
- metadata.gz: 53b05dc182ec33a56c75c58a3936c9753b018c8a78ac3ae83f2fac588bafdf5ecb09026a9f70120b86d7685ce75d484bd993ef83efabf84ee0f63c4d81daefb8
7
- data.tar.gz: 6f6849814bba37b3ebee42a88be6a927efaa913437d1c050586902aa4850cbf334df571e310818b6ddc711ff5d0828ef89e12cb8e249d9b704248e73898e200c
6
+ metadata.gz: 3af49d57dacb01af6ffa504d2e547d0ca920e0b675c119cdfdf95bf6f5926e5ba3f212144e3f139c005929abd52c147fa57dc06848aaae0a098a2444b84db20a
7
+ data.tar.gz: cb4b0902b3c3669231ff0ffc9bbe7b198f334fa8f3f68454a79345f808313c4f13ad63ea3c01efb36225beac388b84b28dc8c2a3d2558aead4f8c0a61acb69e6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.27] - 2026-03-19
4
+
5
+ ### Added
6
+ - `/transform <op>` command: apply string transformations (upcase/downcase/reverse/strip/squeeze) to last assistant message
7
+ - `/concat` command: concatenate all assistant messages into a single system message
8
+ - `/split <N> [pattern]` command: split a message at index N by pattern (default: paragraph break)
9
+ - `/swap <A> <B>` command: swap two messages by index
10
+ - `/prefix [text|clear]` command: set/show/clear auto-prefix for outgoing messages
11
+ - `/suffix [text|clear]` command: set/show/clear auto-suffix for outgoing messages
12
+ - `/timer <seconds> [message] | cancel` command: countdown timer with background thread and status bar notification
13
+ - `/notify <message>` command: send a manual toast notification to status bar
14
+ - `apply_message_decorators` method: prepends prefix and appends suffix to user messages before LLM send
15
+
16
+ ### Changed
17
+ - Total slash commands: 115
18
+ - Total specs: 1817 examples, 150 files
19
+
20
+ ## [0.4.26] - 2026-03-19
21
+
22
+ ### Added
23
+ - `/goto <N>` command: jump to specific message by index
24
+ - `/inject <role> <text>` command: inject a message with specific role (user/assistant/system)
25
+ - `/stopwatch [start|stop|lap|reset]` command: built-in stopwatch with MM:SS.ms format
26
+ - `/ago <N>` command: show what was said N messages ago
27
+
28
+ ### Changed
29
+ - Updated README.md and CLAUDE.md to reflect 107 slash commands, 1732 specs, 146 files
30
+
3
31
  ## [0.4.25] - 2026-03-19
4
32
 
5
33
  ### Added
data/README.md CHANGED
@@ -2,15 +2,15 @@
2
2
 
3
3
  Rich terminal UI for the LegionIO async cognition engine.
4
4
 
5
- **Version**: 0.4.18
5
+ **Version**: 0.4.27
6
6
 
7
- Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell with 60 slash commands, operational dashboard, extensions browser, config editor, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
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
 
9
9
  ## Features
10
10
 
11
11
  - **Onboarding wizard** - First-run setup with Kerberos identity detection, GitHub profile probing, environment scanning, and LLM provider selection
12
12
  - **Digital rain intro** - Matrix-style rain using discovered LEX extension names
13
- - **AI chat shell** - Streaming LLM chat with 60 slash commands, tab completion, markdown rendering, and tool panels
13
+ - **AI chat shell** - Streaming LLM chat with 115 slash commands, tab completion, markdown rendering, and tool panels
14
14
  - **Operational dashboard** - Service/LLM status, extension inventory, system info, panel navigation (Ctrl+D or `/dashboard`)
15
15
  - **Extensions browser** - Browse installed LEX gems by category with detail view and homepage opener ('o' key)
16
16
  - **Config viewer/editor** - View and edit `~/.legionio/settings/*.json` with vault:// masking and JSON validation
@@ -35,6 +35,26 @@ Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with iden
35
35
  - **Animated spinner** - Status bar spinner during LLM thinking
36
36
  - **Daemon routing** - Routes through LegionIO daemon when available, falls back to direct
37
37
  - **Second-run flow** - Skips onboarding, re-scans environment, drops into chat
38
+ - **Session chaining** - `/chain` sends pipe-separated prompts sequentially; `/info` shows full session state
39
+ - **Scroll navigation** - `/scroll`, `/top`, `/bottom`, `/head`, `/tail` for precise history navigation
40
+ - **Conversation editing** - `/replace`, `/reset`, `/prompt`, `/highlight` for post-hoc message editing
41
+ - **Multi-line input** - `/multiline` toggles submit-on-empty-line mode with `[ML]` status indicator
42
+ - **Message annotations** - `/annotate` and `/annotations` for inline notes on specific messages
43
+ - **Message filtering** - `/filter` by role, tag, or pinned status
44
+ - **Export YAML** - `/export yaml` alongside existing md/json/html formats
45
+ - **Session archiving** - `/archive` moves session to `~/.legionio/archives/` and starts fresh
46
+ - **Shell integration** - `/tee` mirrors messages to a file in real-time; `/pipe` pipes output through a shell command
47
+ - **Math utilities** - `/calc` for safe expression evaluation; `/rand` for random number generation
48
+ - **Shell-like commands** - `/ls`, `/pwd`, `/echo`, `/env` for quick filesystem and environment inspection
49
+ - **Display controls** - `/wrap`, `/number`, `/color`, `/timestamps`, `/truncate`, `/silent`, `/speak`
50
+ - **Draft buffer** - `/draft` and `/revise` for composing and editing messages before sending
51
+ - **Word frequency** - `/freq` shows top-20 words in conversation (excludes stop words)
52
+ - **Named markers** - `/mark` inserts named bookmarks; list all markers with `/mark`
53
+ - **Persistent preferences** - `/prefs` reads/writes `~/.legionio/prefs.json` across sessions
54
+ - **Quick Q&A** - `/ask` and `/define` for concise one-paragraph LLM answers
55
+ - **Status overview** - `/status` shows all 18 toggleable modes and settings at a glance
56
+ - **Command discovery** - `/commands [pattern]` lists all slash commands with optional pattern filter
57
+ - **About info** - `/about` shows gem name, version, author, license, and GitHub URL
38
58
 
39
59
  ## Installation
40
60
 
@@ -76,64 +96,120 @@ legion chat prompt "explain async cognition"
76
96
 
77
97
  | Command | Description |
78
98
  |---------|-------------|
79
- | `/help` | Show all commands and hotkeys |
80
- | `/quit` | Exit (auto-saves session) |
99
+ | `/about` | Show gem name, version, author, license, and GitHub URL |
100
+ | `/alias [name] [cmd]` | Create or list command aliases |
101
+ | `/annotate [N] <text>` | Add a note to a specific message |
102
+ | `/annotations` | List all annotated messages with their notes |
103
+ | `/archive [name]` | Archive session to `~/.legionio/archives/` and start fresh |
104
+ | `/archives` | List all archived sessions with file sizes |
105
+ | `/ask <question>` | Quick concise Q&A mode (LLM answers in one paragraph) |
106
+ | `/autosave [N\|off]` | Toggle periodic auto-save with configurable interval |
107
+ | `/bookmark` | Export pinned messages to a markdown file |
108
+ | `/bottom` | Scroll to bottom of message history |
109
+ | `/calc <expression>` | Evaluate a math expression (supports Math functions) |
110
+ | `/chain <p1\|p2\|...>` | Send pipe-separated prompts to LLM sequentially |
81
111
  | `/clear` | Clear message history |
82
- | `/model <name>` | Switch LLM model at runtime |
83
- | `/session <name>` | Set session name |
112
+ | `/color [on\|off]` | Toggle colorized output (strips ANSI codes when off) |
113
+ | `/commands [pattern]` | List all slash commands with optional pattern filter |
114
+ | `/compact [N]` | Keep last N message pairs, remove older |
115
+ | `/config` | View and edit `~/.legionio/settings/*.json` files |
116
+ | `/context` | Show active session state summary |
117
+ | `/copy` | Copy last assistant response to clipboard |
84
118
  | `/cost` | Show token usage and estimated cost |
85
- | `/export [md\|json\|html]` | Export chat history to file |
86
- | `/tools` | List discovered LEX extensions |
119
+ | `/count <pattern>` | Count messages matching a pattern with per-role breakdown |
87
120
  | `/dashboard` | Toggle operational dashboard |
88
- | `/hotkeys` | Show registered hotkey bindings |
89
- | `/save [name]` | Save current session |
90
- | `/load <name>` | Load a saved session |
91
- | `/sessions` | List all saved sessions |
92
- | `/system <prompt>` | Set or override system prompt |
121
+ | `/debug` | Toggle debug mode |
122
+ | `/define <term>` | Ask LLM for a concise definition |
93
123
  | `/delete <session>` | Delete a saved session |
94
- | `/plan` | Toggle read-only bookmark mode |
95
- | `/palette` | Open command palette (fuzzy search) |
124
+ | `/diff` | Show new messages since last session load |
125
+ | `/draft [text\|send\|clear]` | Save text to draft buffer, show, send, or clear it |
126
+ | `/echo <text>` | Add a user-defined system message note |
127
+ | `/env` | Show environment info (Ruby version, platform, terminal, PID) |
128
+ | `/export [md\|json\|html\|yaml]` | Export chat history to file |
96
129
  | `/extensions` | Browse installed LEX extensions |
97
- | `/config` | View and edit settings files |
98
- | `/theme [name]` | Switch color theme (purple/green/blue/amber) |
99
- | `/search <text>` | Search message history |
130
+ | `/fav [N]` | Favorite a message (persists to `~/.legionio/favorites.json`) |
131
+ | `/favs` | Show all favorited messages |
132
+ | `/filter [role\|tag\|pinned\|clear]` | Filter displayed messages |
133
+ | `/focus` | Toggle minimal UI (hide status bar) |
134
+ | `/freq` | Word frequency analysis with top 20 words (excludes stop words) |
100
135
  | `/grep <pattern>` | Regex search across messages |
101
- | `/compact [N]` | Keep last N message pairs, remove older |
102
- | `/copy` | Copy last assistant response to clipboard |
103
- | `/diff` | Show new messages since last session load |
104
- | `/stats` | Show conversation statistics |
105
- | `/personality [style]` | Switch personality (default/concise/detailed/friendly/technical) |
106
- | `/undo` | Remove last user+assistant message pair |
136
+ | `/head [N]` | Peek at first N messages (default 5) |
137
+ | `/help` | Show all commands and hotkeys |
138
+ | `/highlight <pattern>` | Highlight text patterns in message rendering |
107
139
  | `/history` | Show input history |
140
+ | `/hotkeys` | Show registered hotkey bindings |
141
+ | `/import <path>` | Import session from a JSON file |
142
+ | `/info` | Comprehensive session info (modes, counts, aliases, macros, provider) |
143
+ | `/load <name>` | Load a saved session |
144
+ | `/log [N]` | View last N lines of boot log (default 20) |
145
+ | `/ls [path]` | List directory contents |
146
+ | `/macro <action>` | Record/stop/play/list/delete command macros |
147
+ | `/mark <label>` | Insert a named marker/bookmark in conversation |
148
+ | `/merge <session>` | Merge another session into current |
149
+ | `/model <name>` | Switch LLM model at runtime |
150
+ | `/multiline` | Toggle multi-line input mode (submit with empty line) |
151
+ | `/mute` | Toggle system message display |
152
+ | `/number [on\|off]` | Toggle message numbering with `[N]` prefix |
153
+ | `/palette` | Open command palette (fuzzy search) |
154
+ | `/personality [style]` | Switch personality (default/concise/detailed/friendly/technical) |
108
155
  | `/pin [N]` | Pin a message (last assistant or by index) |
109
156
  | `/pins` | Show all pinned messages |
157
+ | `/pipe <command>` | Pipe last assistant response through a shell command |
158
+ | `/plan` | Toggle read-only bookmark mode |
159
+ | `/prefs [key] [value]` | Read or write persistent user preferences |
160
+ | `/prompt save\|load\|list\|delete` | Persist and reuse custom system prompts |
161
+ | `/pwd` | Show current working directory |
162
+ | `/quit` | Exit (auto-saves session) |
163
+ | `/rand [N\|min..max]` | Generate random numbers (float, integer, or range) |
164
+ | `/react <emoji>` | Add emoji reaction to a message |
110
165
  | `/rename <name>` | Rename current session (moves saved file) |
111
- | `/context` | Show active session state summary |
112
- | `/alias [name] [cmd]` | Create or list command aliases |
166
+ | `/repeat` | Re-execute the last slash command |
167
+ | `/replace old >>> new` | Find and replace text across all messages |
168
+ | `/reset` | Reset session to clean state |
169
+ | `/retry` | Resend last user message to LLM |
170
+ | `/revise <text>` | Replace the content of the last user message |
171
+ | `/save [name]` | Save current session |
172
+ | `/scroll [top\|bottom\|N]` | Navigate to a specific scroll position |
173
+ | `/search <text>` | Search message history |
174
+ | `/session <name>` | Set session name |
175
+ | `/sessions` | List all saved sessions |
176
+ | `/silent` | Toggle silent mode (responses tracked but not displayed) |
113
177
  | `/snippet <action>` | Save/load/list/delete code snippets |
114
- | `/debug` | Toggle debug mode |
115
- | `/uptime` | Show session elapsed time |
116
- | `/bookmark` | Export pinned messages to file |
117
- | `/time` | Show current date and time |
118
- | `/autosave [N\|off]` | Toggle periodic auto-save with interval |
119
- | `/react <emoji>` | Add emoji reaction to a message |
120
- | `/macro <action>` | Record/stop/play/list/delete command macros |
178
+ | `/sort [length\|role]` | Show messages sorted by length or grouped by role |
179
+ | `/speak [on\|off]` | Toggle text-to-speech for assistant messages (macOS only) |
180
+ | `/stats` | Show conversation statistics |
181
+ | `/status` | Show all 18 toggleable modes and settings |
182
+ | `/summary` | Generate a local conversation summary |
183
+ | `/system <prompt>` | Set or override system prompt |
121
184
  | `/tag <label>` | Tag a message with a label |
122
185
  | `/tags [label]` | Show tag statistics or filter by tag |
123
- | `/repeat` | Re-execute the last slash command |
124
- | `/count <pattern>` | Count messages matching a pattern |
125
- | `/template [name]` | List or use prompt templates |
126
- | `/fav [N]` | Favorite a message (persists to disk) |
127
- | `/favs` | Show all favorited messages |
128
- | `/log [N]` | View last N lines of boot log |
186
+ | `/tail [N]` | Peek at last N messages (default 5) |
187
+ | `/tee <path>` | Copy new messages to a file in real-time |
188
+ | `/template [name]` | List or use prompt templates (8 built-in) |
189
+ | `/theme [name]` | Switch color theme (purple/green/blue/amber) |
190
+ | `/time` | Show current date and time |
191
+ | `/timestamps [on\|off]` | Toggle timestamp display on messages |
192
+ | `/tools` | List discovered LEX extensions |
193
+ | `/top` | Scroll to top of message history |
194
+ | `/truncate [N\|off]` | Display-only truncation of long messages |
195
+ | `/undo` | Remove last user+assistant message pair |
196
+ | `/uptime` | Show session elapsed time |
129
197
  | `/version` | Show version and platform info |
130
- | `/focus` | Toggle minimal UI (hide status bar) |
131
- | `/retry` | Resend last message to LLM |
132
- | `/merge <session>` | Merge another session into current |
133
- | `/sort [length\|role]` | Show messages sorted by length or role |
134
- | `/import <path>` | Import session from a JSON file |
135
- | `/mute` | Toggle system message display |
136
- | `/wc` | Show word count statistics |
198
+ | `/wc` | Show word count statistics per role |
199
+ | `/welcome` | Redisplay the welcome message |
200
+ | `/wrap [N\|off]` | Set custom word wrap width |
201
+ | `/ago <N>` | Show what was said N messages ago |
202
+ | `/concat` | Concatenate all assistant messages into one |
203
+ | `/goto <N>` | Jump to specific message by index |
204
+ | `/inject <role> <text>` | Inject a message with specific role |
205
+ | `/notify <message>` | Send a toast notification to status bar |
206
+ | `/prefix [text\|clear]` | Set/show/clear auto-prefix for outgoing messages |
207
+ | `/split <N> [pattern]` | Split a message by pattern into multiple messages |
208
+ | `/stopwatch [start\|stop\|lap\|reset]` | Built-in stopwatch with MM:SS.ms format |
209
+ | `/suffix [text\|clear]` | Set/show/clear auto-suffix for outgoing messages |
210
+ | `/swap <A> <B>` | Swap two messages by index |
211
+ | `/timer <seconds> [message]` | Countdown timer with notification on expiry |
212
+ | `/transform <op>` | Apply string transformation to last assistant message |
137
213
 
138
214
  ## Hotkeys
139
215
 
@@ -158,13 +234,13 @@ legion-tty
158
234
 
159
235
  Screens/
160
236
  Onboarding # First-run wizard (rain -> intro -> wizard -> reveal)
161
- Chat # AI chat REPL with streaming + 60 slash commands
162
- SessionCommands # save/load/sessions/delete/rename
163
- ExportCommands # export/bookmark/html/json/markdown
164
- MessageCommands # compact/copy/diff/search/grep/undo/pin/pins
165
- UiCommands # help/clear/dashboard/hotkeys/palette/context/stats/debug/history/uptime/time
166
- ModelCommands # model/system/personality switching
167
- CustomCommands # alias/snippet management
237
+ Chat # AI chat REPL with streaming + 115 slash commands
238
+ SessionCommands # save/load/sessions/delete/rename/import/merge/autosave
239
+ ExportCommands # export/bookmark/html/json/markdown/yaml
240
+ MessageCommands # compact/copy/diff/search/grep/undo/pin/pins/react/tag/fav/sort/count/transform/concat/split/swap
241
+ UiCommands # help/clear/dashboard/hotkeys/palette/context/stats/debug/history/uptime/time/tips/welcome/focus/wc/log/version/mute + calc/rand/mark/freq/color/timestamps/top/bottom/head/tail/echo/env/speak/silent/wrap/number/truncate/about/commands/ask/define/status/prefs/timer/notify
242
+ ModelCommands # model/system/personality switching/retry/chain/info/scroll/summary/prompt/reset/replace/highlight/multiline/filter/annotate/annotations
243
+ CustomCommands # alias/snippet/template/macro/draft/revise/tee/pipe/archive/archives/ls/pwd/prefix/suffix
168
244
  Dashboard # Service/LLM status, panel navigation (j/k/1-5)
169
245
  Extensions # LEX gem browser by category with homepage opener
170
246
  Config # Settings file viewer/editor with JSON validation
@@ -213,8 +289,8 @@ Boot logs go to `~/.legionio/logs/tty-boot.log`.
213
289
 
214
290
  ```bash
215
291
  bundle install
216
- bundle exec rspec # 1143 examples, 0 failures
217
- bundle exec rubocop # 106 files, 0 offenses
292
+ bundle exec rspec # 1817 examples, 0 failures
293
+ bundle exec rubocop # 150 files, 0 offenses
218
294
  ```
219
295
 
220
296
  ## License
@@ -408,6 +408,42 @@ module Legion
408
408
  :handled
409
409
  end
410
410
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
411
+
412
+ def handle_prefix(input)
413
+ arg = input.split(nil, 2)[1]
414
+ if arg.nil?
415
+ if @message_prefix
416
+ @message_stream.add_message(role: :system, content: "Current prefix: \"#{@message_prefix}\"")
417
+ else
418
+ @message_stream.add_message(role: :system, content: 'No prefix set. Usage: /prefix <text>')
419
+ end
420
+ elsif arg == 'clear'
421
+ @message_prefix = nil
422
+ @message_stream.add_message(role: :system, content: 'Prefix cleared.')
423
+ else
424
+ @message_prefix = arg
425
+ @message_stream.add_message(role: :system, content: "Prefix set: \"#{@message_prefix}\"")
426
+ end
427
+ :handled
428
+ end
429
+
430
+ def handle_suffix(input)
431
+ arg = input.split(nil, 2)[1]
432
+ if arg.nil?
433
+ if @message_suffix
434
+ @message_stream.add_message(role: :system, content: "Current suffix: \"#{@message_suffix}\"")
435
+ else
436
+ @message_stream.add_message(role: :system, content: 'No suffix set. Usage: /suffix <text>')
437
+ end
438
+ elsif arg == 'clear'
439
+ @message_suffix = nil
440
+ @message_stream.add_message(role: :system, content: 'Suffix cleared.')
441
+ else
442
+ @message_suffix = arg
443
+ @message_stream.add_message(role: :system, content: "Suffix set: \"#{@message_suffix}\"")
444
+ end
445
+ :handled
446
+ end
411
447
  end
412
448
  end
413
449
  end
@@ -5,6 +5,9 @@ module Legion
5
5
  module Screens
6
6
  class Chat < Base
7
7
  module MessageCommands
8
+ INJECT_VALID_ROLES = %w[user assistant system].freeze
9
+ TRANSFORM_OPS = %w[upcase downcase reverse strip squeeze].freeze
10
+
8
11
  private
9
12
 
10
13
  # rubocop:disable Metrics/AbcSize
@@ -655,6 +658,149 @@ module Legion
655
658
  @message_stream.add_message(role: :system, content: "Revised: #{new_content}")
656
659
  :handled
657
660
  end
661
+
662
+ def handle_inject(input)
663
+ parts = input.split(nil, 3)
664
+ role_str = parts[1]
665
+ text = parts[2]
666
+
667
+ unless role_str && INJECT_VALID_ROLES.include?(role_str) && text && !text.strip.empty?
668
+ @message_stream.add_message(
669
+ role: :system,
670
+ content: 'Usage: /inject <user|assistant|system> <text>'
671
+ )
672
+ return :handled
673
+ end
674
+
675
+ @message_stream.add_message(role: role_str.to_sym, content: text.strip)
676
+ @status_bar.update(message_count: @message_stream.messages.size)
677
+ @message_stream.add_message(role: :system, content: "Injected [#{role_str}] message.")
678
+ :handled
679
+ end
680
+
681
+ def handle_transform(input)
682
+ op = input.split(nil, 2)[1]
683
+ unless op && TRANSFORM_OPS.include?(op)
684
+ @message_stream.add_message(
685
+ role: :system,
686
+ content: "Usage: /transform <#{TRANSFORM_OPS.join('|')}>"
687
+ )
688
+ return :handled
689
+ end
690
+
691
+ msg = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
692
+ unless msg
693
+ @message_stream.add_message(role: :system, content: 'No assistant message to transform.')
694
+ return :handled
695
+ end
696
+
697
+ msg[:content] = msg[:content].to_s.send(op)
698
+ @message_stream.add_message(role: :system, content: "Transformed last assistant message: #{op}.")
699
+ :handled
700
+ end
701
+
702
+ def handle_concat
703
+ assistant_msgs = @message_stream.messages.select { |m| m[:role] == :assistant }
704
+ if assistant_msgs.empty?
705
+ @message_stream.add_message(role: :system, content: 'No assistant messages to concatenate.')
706
+ return :handled
707
+ end
708
+
709
+ combined = assistant_msgs.map { |m| m[:content].to_s }.join("\n\n")
710
+ @message_stream.add_message(role: :system, content: combined)
711
+ @message_stream.add_message(
712
+ role: :system,
713
+ content: "Concatenated #{assistant_msgs.size} assistant message(s)."
714
+ )
715
+ :handled
716
+ end
717
+
718
+ def handle_ago(input)
719
+ n = (input.split(nil, 2)[1] || '1').to_i
720
+ msgs = @message_stream.messages
721
+ if n < 1 || n > msgs.size
722
+ @message_stream.add_message(
723
+ role: :system,
724
+ content: "No message #{n} ago (conversation has #{msgs.size} message(s))."
725
+ )
726
+ return :handled
727
+ end
728
+
729
+ idx = msgs.size - n
730
+ msg = msgs[idx]
731
+ preview = truncate_text(msg[:content].to_s, 200)
732
+ @message_stream.add_message(
733
+ role: :system,
734
+ content: "[#{n} ago | ##{idx} | #{msg[:role]}] #{preview}"
735
+ )
736
+ :handled
737
+ end
738
+
739
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
740
+ def handle_split(input)
741
+ parts = input.split(nil, 3)
742
+ n_str = parts[1]
743
+ pattern_str = parts[2]
744
+
745
+ unless n_str&.match?(/\A\d+\z/)
746
+ @message_stream.add_message(role: :system, content: 'Usage: /split <N> [pattern]')
747
+ return :handled
748
+ end
749
+
750
+ idx = n_str.to_i
751
+ msgs = @message_stream.messages
752
+ unless idx < msgs.size
753
+ @message_stream.add_message(role: :system,
754
+ content: "No message at index #{idx} (#{msgs.size} message(s)).")
755
+ return :handled
756
+ end
757
+
758
+ pattern = pattern_str || "\n\n"
759
+ original = msgs[idx]
760
+ segments = original[:content].to_s.split(pattern).reject(&:empty?)
761
+
762
+ if segments.size <= 1
763
+ @message_stream.add_message(role: :system, content: 'Message could not be split (no pattern found).')
764
+ return :handled
765
+ end
766
+
767
+ new_msgs = segments.map { |seg| { role: original[:role], content: seg } }
768
+ msgs.delete_at(idx)
769
+ msgs.insert(idx, *new_msgs)
770
+ @status_bar.update(message_count: msgs.size)
771
+ @message_stream.add_message(role: :system, content: "Split into #{new_msgs.size} messages.")
772
+ :handled
773
+ end
774
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
775
+
776
+ # rubocop:disable Metrics/AbcSize
777
+ def handle_swap(input)
778
+ parts = input.split(nil, 3)
779
+ a_str = parts[1]
780
+ b_str = parts[2]
781
+
782
+ unless a_str&.match?(/\A\d+\z/) && b_str&.match?(/\A\d+\z/)
783
+ @message_stream.add_message(role: :system, content: 'Usage: /swap <A> <B>')
784
+ return :handled
785
+ end
786
+
787
+ a = a_str.to_i
788
+ b = b_str.to_i
789
+ msgs = @message_stream.messages
790
+
791
+ if a >= msgs.size || b >= msgs.size
792
+ @message_stream.add_message(
793
+ role: :system,
794
+ content: "Index out of range (conversation has #{msgs.size} message(s))."
795
+ )
796
+ return :handled
797
+ end
798
+
799
+ msgs[a], msgs[b] = msgs[b], msgs[a]
800
+ @message_stream.add_message(role: :system, content: "Swapped messages #{a} and #{b}.")
801
+ :handled
802
+ end
803
+ # rubocop:enable Metrics/AbcSize
658
804
  end
659
805
  end
660
806
  end
@@ -393,6 +393,37 @@ module Legion
393
393
  end
394
394
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
395
395
 
396
+ def handle_goto(input)
397
+ n_str = input.split(nil, 2)[1]
398
+ unless n_str&.match?(/\A\d+\z/)
399
+ @message_stream.add_message(role: :system, content: 'Usage: /goto <N>')
400
+ return :handled
401
+ end
402
+
403
+ idx = n_str.to_i
404
+ return goto_out_of_range(idx) unless goto_in_range?(idx)
405
+
406
+ scroll_to_message(idx)
407
+ @message_stream.add_message(role: :system, content: "Jumped to message #{idx}.")
408
+ :handled
409
+ end
410
+
411
+ def goto_in_range?(idx)
412
+ idx >= 0 && idx < @message_stream.messages.size
413
+ end
414
+
415
+ def goto_out_of_range(idx)
416
+ total = @message_stream.messages.size
417
+ @message_stream.add_message(role: :system, content: "Message #{idx} out of range (0..#{total - 1}).")
418
+ :handled
419
+ end
420
+
421
+ def scroll_to_message(idx)
422
+ total = @message_stream.messages.size
423
+ @message_stream.scroll_down(@message_stream.scroll_offset)
424
+ @message_stream.scroll_up([total - idx - 1, 0].max)
425
+ end
426
+
396
427
  def handle_highlight(input)
397
428
  arg = input.split(nil, 2)[1]
398
429
  @highlights ||= []
@@ -852,6 +883,139 @@ module Legion
852
883
  when 'timestamps' then handle_timestamps("/timestamps #{value}")
853
884
  end
854
885
  end
886
+
887
+ def handle_stopwatch(input)
888
+ sub = input.split(nil, 2)[1]&.strip
889
+ case sub
890
+ when 'start' then stopwatch_start
891
+ when 'stop' then stopwatch_stop
892
+ when 'lap' then stopwatch_lap
893
+ when 'reset' then stopwatch_reset
894
+ else stopwatch_status
895
+ end
896
+ :handled
897
+ end
898
+
899
+ def stopwatch_start
900
+ @stopwatch_start = Time.now
901
+ @message_stream.add_message(role: :system, content: 'Stopwatch started.')
902
+ end
903
+
904
+ def stopwatch_stop
905
+ unless @stopwatch_start
906
+ @message_stream.add_message(role: :system, content: 'Stopwatch is not running.')
907
+ return
908
+ end
909
+
910
+ @stopwatch_elapsed += Time.now - @stopwatch_start
911
+ @stopwatch_start = nil
912
+ @message_stream.add_message(role: :system,
913
+ content: "Stopwatch stopped. Elapsed: #{format_stopwatch(@stopwatch_elapsed)}")
914
+ end
915
+
916
+ def stopwatch_lap
917
+ total = @stopwatch_elapsed
918
+ total += Time.now - @stopwatch_start if @stopwatch_start
919
+ @message_stream.add_message(role: :system, content: "Lap: #{format_stopwatch(total)}")
920
+ end
921
+
922
+ def stopwatch_reset
923
+ @stopwatch_start = nil
924
+ @stopwatch_elapsed = 0
925
+ @message_stream.add_message(role: :system, content: 'Stopwatch reset.')
926
+ end
927
+
928
+ def stopwatch_status
929
+ if @stopwatch_start
930
+ total = @stopwatch_elapsed + (Time.now - @stopwatch_start)
931
+ @message_stream.add_message(role: :system, content: "Stopwatch running: #{format_stopwatch(total)}")
932
+ elsif @stopwatch_elapsed.positive?
933
+ @message_stream.add_message(role: :system,
934
+ content: "Stopwatch stopped at: #{format_stopwatch(@stopwatch_elapsed)}")
935
+ else
936
+ @message_stream.add_message(role: :system, content: 'Stopwatch: 00:00.000 (not started)')
937
+ end
938
+ end
939
+
940
+ def format_stopwatch(seconds)
941
+ total_ms = (seconds * 1000).to_i
942
+ ms = total_ms % 1000
943
+ secs = (total_ms / 1000) % 60
944
+ mins = total_ms / 60_000
945
+ format('%<mins>02d:%<secs>02d.%<ms>03d', mins: mins, secs: secs, ms: ms)
946
+ end
947
+
948
+ def handle_timer(input)
949
+ arg = input.split(nil, 2)[1]&.strip
950
+
951
+ return timer_status if arg.nil? || arg.empty?
952
+
953
+ return timer_cancel if arg == 'cancel'
954
+
955
+ seconds_str, *msg_parts = arg.split
956
+ unless seconds_str.match?(/\A\d+\z/)
957
+ @message_stream.add_message(role: :system, content: 'Usage: /timer <seconds> [message] | cancel')
958
+ return :handled
959
+ end
960
+
961
+ seconds = seconds_str.to_i
962
+ message = msg_parts.empty? ? 'Timer expired!' : msg_parts.join(' ')
963
+ start_timer(seconds, message)
964
+ end
965
+
966
+ def handle_notify(input)
967
+ text = input.split(nil, 2)[1]&.strip
968
+ unless text && !text.empty?
969
+ @message_stream.add_message(role: :system, content: 'Usage: /notify <message>')
970
+ return :handled
971
+ end
972
+
973
+ @status_bar.notify(message: text, level: :info, ttl: 5)
974
+ :handled
975
+ end
976
+
977
+ def timer_status
978
+ if @timer_thread&.alive?
979
+ remaining = @timer_end - Time.now
980
+ remaining = [remaining, 0].max.ceil
981
+ @message_stream.add_message(role: :system, content: "Timer running: #{remaining}s remaining.")
982
+ else
983
+ @message_stream.add_message(role: :system, content: 'No active timer.')
984
+ end
985
+ :handled
986
+ end
987
+
988
+ def timer_cancel
989
+ if @timer_thread&.alive?
990
+ @timer_thread.kill
991
+ @timer_thread = nil
992
+ @timer_end = nil
993
+ @message_stream.add_message(role: :system, content: 'Timer cancelled.')
994
+ else
995
+ @message_stream.add_message(role: :system, content: 'No active timer to cancel.')
996
+ end
997
+ :handled
998
+ end
999
+
1000
+ def start_timer(seconds, message)
1001
+ if @timer_thread&.alive?
1002
+ @message_stream.add_message(role: :system,
1003
+ content: 'A timer is already running. Use /timer cancel first.')
1004
+ return :handled
1005
+ end
1006
+
1007
+ @timer_end = Time.now + seconds
1008
+ @message_stream.add_message(role: :system, content: "Timer set for #{seconds}s: #{message}")
1009
+ @status_bar.notify(message: "Timer: #{seconds}s", level: :info, ttl: 3)
1010
+ @timer_thread = Thread.new do
1011
+ sleep(seconds)
1012
+ @message_stream.add_message(role: :system, content: "Timer: #{message}")
1013
+ @status_bar.notify(message: message, level: :info, ttl: 10)
1014
+ @timer_thread = nil
1015
+ @timer_end = nil
1016
+ end
1017
+ :handled
1018
+ end
855
1019
  end
856
1020
  end
857
1021
  end
@@ -48,7 +48,13 @@ module Legion
48
48
  /mark /freq
49
49
  /about /commands
50
50
  /ask /define
51
- /status /prefs].freeze
51
+ /status /prefs
52
+ /stopwatch /ago
53
+ /goto /inject
54
+ /transform /concat
55
+ /prefix /suffix
56
+ /split /swap
57
+ /timer /notify].freeze
52
58
 
53
59
  PERSONALITIES = {
54
60
  'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
@@ -93,6 +99,11 @@ module Legion
93
99
  @speak_mode = false
94
100
  @silent_mode = false
95
101
  @draft = nil
102
+ @stopwatch_start = nil
103
+ @stopwatch_elapsed = 0
104
+ @timer_thread = nil
105
+ @message_prefix = nil
106
+ @message_suffix = nil
96
107
  end
97
108
 
98
109
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -163,7 +174,7 @@ module Legion
163
174
  @message_stream.add_message(role: :system, content: '(bookmarked)')
164
175
  else
165
176
  @message_stream.add_message(role: :assistant, content: '')
166
- send_to_llm(input)
177
+ send_to_llm(apply_message_decorators(input))
167
178
  end
168
179
  @status_bar.update(message_count: @message_stream.messages.size)
169
180
  check_autosave
@@ -501,6 +512,18 @@ module Legion
501
512
  when '/define' then handle_define(input)
502
513
  when '/status' then handle_status
503
514
  when '/prefs' then handle_prefs(input)
515
+ when '/stopwatch' then handle_stopwatch(input)
516
+ when '/ago' then handle_ago(input)
517
+ when '/goto' then handle_goto(input)
518
+ when '/inject' then handle_inject(input)
519
+ when '/transform' then handle_transform(input)
520
+ when '/concat' then handle_concat
521
+ when '/prefix' then handle_prefix(input)
522
+ when '/suffix' then handle_suffix(input)
523
+ when '/split' then handle_split(input)
524
+ when '/swap' then handle_swap(input)
525
+ when '/timer' then handle_timer(input)
526
+ when '/notify' then handle_notify(input)
504
527
  else :handled
505
528
  end
506
529
  end
@@ -636,6 +659,13 @@ module Legion
636
659
  cost: @token_tracker.total_cost
637
660
  )
638
661
  end
662
+
663
+ def apply_message_decorators(message)
664
+ result = message
665
+ result = "#{@message_prefix}#{result}" if @message_prefix
666
+ result = "#{result}#{@message_suffix}" if @message_suffix
667
+ result
668
+ end
639
669
  end
640
670
  # rubocop:enable Metrics/ClassLength
641
671
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.25'
5
+ VERSION = '0.4.27'
6
6
  end
7
7
  end
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.25
4
+ version: 0.4.27
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity