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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +132 -56
- data/lib/legion/tty/screens/chat/custom_commands.rb +36 -0
- data/lib/legion/tty/screens/chat/message_commands.rb +146 -0
- data/lib/legion/tty/screens/chat/ui_commands.rb +164 -0
- data/lib/legion/tty/screens/chat.rb +32 -2
- data/lib/legion/tty/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f1f14c29bb844005733b887586662c8c08947f757567ec9438029b423b0c991e
|
|
4
|
+
data.tar.gz: 3e0244816b27e6174541fceba9aea3df6d87a7698f40766cf7341cf12b8ab0b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
| `/
|
|
80
|
-
| `/
|
|
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
|
-
| `/
|
|
83
|
-
| `/
|
|
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
|
-
| `/
|
|
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
|
-
| `/
|
|
89
|
-
| `/
|
|
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
|
-
| `/
|
|
95
|
-
| `/
|
|
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
|
-
| `/
|
|
98
|
-
| `/
|
|
99
|
-
| `/
|
|
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
|
-
| `/
|
|
102
|
-
| `/
|
|
103
|
-
| `/
|
|
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
|
-
| `/
|
|
112
|
-
| `/
|
|
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
|
-
| `/
|
|
115
|
-
| `/
|
|
116
|
-
| `/
|
|
117
|
-
| `/
|
|
118
|
-
| `/
|
|
119
|
-
| `/
|
|
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
|
-
| `/
|
|
124
|
-
| `/
|
|
125
|
-
| `/template [name]` | List or use prompt templates |
|
|
126
|
-
| `/
|
|
127
|
-
| `/
|
|
128
|
-
| `/
|
|
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
|
-
| `/
|
|
131
|
-
| `/
|
|
132
|
-
| `/
|
|
133
|
-
| `/
|
|
134
|
-
| `/
|
|
135
|
-
| `/
|
|
136
|
-
| `/
|
|
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 +
|
|
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
|
|
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 #
|
|
217
|
-
bundle exec rubocop #
|
|
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
|
|
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
|
data/lib/legion/tty/version.rb
CHANGED