kward 0.81.0 → 0.83.0
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 +57 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/Rakefile +44 -4
- data/doc/agent-tools.md +1 -0
- data/doc/composer.md +2 -2
- data/doc/configuration.md +18 -3
- data/doc/editor.md +20 -10
- data/doc/files.md +12 -5
- data/doc/permissions.md +1 -0
- data/doc/releasing.md +79 -38
- data/doc/rpc.md +2 -2
- data/doc/sandboxing.md +5 -1
- data/doc/security.md +2 -2
- data/doc/shell.md +42 -18
- data/doc/tabs.md +3 -0
- data/doc/usage.md +3 -2
- data/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/agent.rb +1 -0
- data/lib/kward/auth/anthropic_oauth.rb +7 -7
- data/lib/kward/cli/git.rb +5 -1
- data/lib/kward/cli/interactive_turn.rb +101 -19
- data/lib/kward/cli/rendering.rb +14 -2
- data/lib/kward/cli/runtime_helpers.rb +252 -46
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/slash_commands.rb +12 -1
- data/lib/kward/cli/tabs.rb +71 -9
- data/lib/kward/cli.rb +12 -0
- data/lib/kward/compaction/token_estimator.rb +12 -6
- data/lib/kward/config_files.rb +17 -0
- data/lib/kward/editor_prompt.rb +46 -0
- data/lib/kward/editor_prompt_session.rb +28 -0
- data/lib/kward/ekwsh.rb +70 -10
- data/lib/kward/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +25 -19
- data/lib/kward/model/client.rb +1 -17
- data/lib/kward/model/model_info.rb +3 -2
- data/lib/kward/model/payloads.rb +0 -2
- data/lib/kward/persistent_shell_session.rb +750 -0
- data/lib/kward/project_files.rb +18 -5
- data/lib/kward/prompt_interface/composer_controller.rb +3 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +28 -1
- data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
- data/lib/kward/prompt_interface/editor/controller.rb +75 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +12 -6
- data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -0
- data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
- data/lib/kward/prompt_interface/file_overlay.rb +21 -7
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +30 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
- data/lib/kward/prompt_interface/project_browser.rb +192 -8
- data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/runtime_state.rb +5 -1
- data/lib/kward/prompt_interface/screen.rb +40 -20
- data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
- data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
- data/lib/kward/prompt_interface.rb +159 -32
- data/lib/kward/prompts/commands.rb +1 -0
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/rpc/auth_manager.rb +1 -1
- data/lib/kward/rpc/server.rb +2 -1
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +94 -5
- data/lib/kward/shell_prompt.rb +50 -0
- data/lib/kward/shell_prompt_session.rb +58 -0
- data/lib/kward/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_keys.rb +1 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/tools/prepare_shell_command.rb +28 -0
- data/lib/kward/tools/registry.rb +63 -5
- data/lib/kward/tools/replace_editor_buffer.rb +30 -0
- data/lib/kward/tools/run_shell_command.rb +24 -6
- data/lib/kward/version.rb +1 -1
- data/templates/default/layout/html/layout.erb +1 -1
- metadata +15 -4
- data/.github/workflows/ci.yml +0 -48
- data/.github/workflows/pages.yml +0 -48
data/doc/security.md
CHANGED
|
@@ -57,8 +57,8 @@ Kward's built-in file tools normally resolve paths inside the active workspace.
|
|
|
57
57
|
These protections reduce accidental edits. They do not contain the whole process:
|
|
58
58
|
|
|
59
59
|
- With `sandbox.mode: off` (the default), `run_shell_command`, `!command`, `/capture`, `/shell`, and `/pty` run with your user permissions.
|
|
60
|
-
- A non-off [command sandbox](sandboxing.md) restricts only model-requested `run_shell_command` workers and their descendants. It does not cover `!command`, `/capture`, `/shell`, or `/pty
|
|
61
|
-
- External `/shell` commands, `!command`, and `/pty`
|
|
60
|
+
- A non-off [command sandbox](sandboxing.md) restricts only model-requested `run_shell_command` workers and their descendants. It does not cover `!command`, `/capture`, `/shell`, or `/pty`, including commands that the transient `?` shell assistant runs through the user's persistent `/shell` process.
|
|
61
|
+
- External `/shell` commands, `!command`, and `/pty` receive an interactive PTY. Kward forwards a conservative set of line-oriented controls into the inline region, then grants full-terminal passthrough when a child emits screen-oriented or unknown controls. Full passthrough bypasses transcript control-sequence sanitization, so run only commands you trust with terminal access. `capture <command>` and `/capture <command>` sanitize their captured output.
|
|
62
62
|
- Plugins, command hooks, and MCP servers are local processes with the same general operating-system access.
|
|
63
63
|
- Read-before-edit applies to Kward's file tools, not to arbitrary shell commands or extension code.
|
|
64
64
|
|
data/doc/shell.md
CHANGED
|
@@ -4,6 +4,7 @@ Kward lets you run your own shell commands without leaving the interactive TUI.
|
|
|
4
4
|
|
|
5
5
|
- **For one quick command, use `!command`.** It runs from the active workspace and returns you to the normal composer when it finishes.
|
|
6
6
|
- **For a longer stretch of shell work, use `/shell`.** It keeps its directory, environment, aliases, and history between commands.
|
|
7
|
+
- **To ask Kward for help from inside `/shell`, prefix the request with `?`.** It can inspect the latest shell output, run an explicitly requested state change, or prepare a command in the shell prompt for your confirmation.
|
|
7
8
|
- **For bounded output in the transcript view, use `/capture <command>`.** Inside `/shell`, use `capture <command>` for the same kind of readable output while keeping the shell's current state.
|
|
8
9
|
|
|
9
10
|
Ordinary `!command` input and external commands inside `/shell` receive an interactive PTY. Pagers, Vim, SSH, REPLs, password prompts, and other interactive tools therefore work without a `pty` prefix.
|
|
@@ -18,11 +19,11 @@ Prefix a command with `!` in the normal composer:
|
|
|
18
19
|
!less README.md
|
|
19
20
|
```
|
|
20
21
|
|
|
21
|
-
The command runs from the active workspace root and
|
|
22
|
+
The command runs from the active workspace root and begins in an inline PTY region above a frozen composer. Line-oriented output, single-line carriage-return progress, and synchronized-output update brackets scroll the transcript area naturally while keyboard input belongs to the child process. If the child emits alternate-screen, clear-screen, absolute cursor, or unknown terminal controls, Kward conservatively hides the composer and switches permanently to full-terminal passthrough for the rest of that command. Pagers and full-screen applications therefore retain the complete terminal without relying on a command-name allowlist.
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
When an inline command exits without reading input, safe output is mirrored into the transient transcript view so a repaint cannot hide it. Carriage-return and horizontal-cursor progress redraws are reduced to their final visible lines, while an unterminated synchronized-output update is closed before Kward redraws. If the child reads input, Kward retains only output captured before the first forwarded input byte; this prevents echoed passwords, OTPs, or other input from entering tab state. Output from one-off commands is never added to the AI conversation or sent to the model.
|
|
24
25
|
|
|
25
|
-
Shell output can leave transient text in the transcript area. **After the command finishes, press Ctrl+L to redraw the durable conversation and clear that transient `!command` output.** While an interactive command is still running, keyboard input—including Ctrl+L and Kward's tab shortcuts—belongs to the child process.
|
|
26
|
+
Shell output can leave transient text in the transcript area. **After the command finishes, press Ctrl+L to redraw the durable conversation and clear that transient `!command` output.** While an interactive command is still running, the composer remains frozen and keyboard input—including Ctrl+L and Kward's tab shortcuts—belongs to the child process.
|
|
26
27
|
|
|
27
28
|
Configured `ekwsh.yml` aliases also work after `!`:
|
|
28
29
|
|
|
@@ -90,6 +91,29 @@ Leave shell mode with `exit`, `logout`, or Ctrl+D on an empty prompt.
|
|
|
90
91
|
|
|
91
92
|
`cd` changes only the embedded shell's directory. It does not change Kward's workspace root or the directory used by the model's other tools.
|
|
92
93
|
|
|
94
|
+
## Ask the shell agent
|
|
95
|
+
|
|
96
|
+
While `/shell` is active, start a submitted line with `?` to ask the transient shell assistant:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
? why did the last command fail?
|
|
100
|
+
? show me which process is listening on port 3000
|
|
101
|
+
? prepare a command to find Ruby files changed today
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The assistant receives the current shell directory, the last command, its exit status, and bounded output from that command. Output is sent to the model only because you explicitly asked with `?`; it is sanitized and bounded before being included, and shell-agent turns are not added to the normal session history.
|
|
105
|
+
|
|
106
|
+
If you explicitly ask the assistant to change shell state, it can use the active shell session:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
? cd into test
|
|
110
|
+
? set RAILS_ENV to test
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
For a suggestion or prepared command, the assistant uses `prepare_shell_command`. The command is placed in the shell composer but is not run until you press `Enter`. Running a command directly and preparing one are deliberately separate actions.
|
|
114
|
+
|
|
115
|
+
The shell assistant cannot safely run commands that require terminal input. Ask it to prepare those commands instead. The local `/shell` session keeps one interactive shell process alive, so directory changes, variables, functions, aliases, and other shell state persist between commands. The one-off `!command` and `/capture` workflows remain separate. SSH remains available through the normal interactive PTY handoff, but shell-agent prompting resumes after that SSH session exits.
|
|
116
|
+
|
|
93
117
|
## Interactive and captured commands
|
|
94
118
|
|
|
95
119
|
External commands inside `/shell` are interactive by default:
|
|
@@ -101,7 +125,7 @@ ruby
|
|
|
101
125
|
ssh example.com
|
|
102
126
|
```
|
|
103
127
|
|
|
104
|
-
Kward gives each command the terminal, forwards keyboard input, and restores the shell prompt when the command exits. It prints the submitted command but no PTY start message or exit-status summary. The line-oriented Git commands `git fetch`, `git ls-remote`, `git push`, `git remote`, and `git status` keep the shell prompt visible as a frozen display. Safe, line-oriented output from commands that did not read keyboard input is kept in the transient transcript view; full-screen and genuinely interactive output stays terminal-owned.
|
|
128
|
+
Kward gives each command the terminal, forwards keyboard input, and restores the shell prompt when the command exits. Interactive commands inherit your normal pager configuration, so commands such as `git log` can open `less` in full-screen mode. Kward suppresses Git paging only for noninteractive shell-agent and `capture` executions. It prints the submitted command but no PTY start message or exit-status summary. The line-oriented Git commands `git fetch`, `git ls-remote`, `git push`, `git remote`, and `git status` keep the shell prompt visible as a frozen display. Safe, line-oriented output from commands that did not read keyboard input is kept in the transient transcript view; full-screen and genuinely interactive output stays terminal-owned.
|
|
105
129
|
|
|
106
130
|
Use `capture` inside `/shell` when you want ordinary, readable output in Kward's transcript area instead of direct terminal control:
|
|
107
131
|
|
|
@@ -141,7 +165,7 @@ Each Kward tab owns its `/shell` state. Switching away and back restores that ta
|
|
|
141
165
|
|
|
142
166
|
Shell commands use a separate, workspace-scoped history rather than the normal chat-prompt history. Configure its size with `history_limit` in `ekwsh.yml`.
|
|
143
167
|
|
|
144
|
-
Kward's tab shortcuts work at the shell prompt and while a captured command is running. During an interactive command, the child owns every key; exit or interrupt it before switching Kward tabs.
|
|
168
|
+
Kward's tab shortcuts work at the shell prompt and while a captured command is running. During an interactive command, the child owns every key; exit or interrupt it before switching Kward tabs. Bounded output from shell-agent `?` turns is also retained in the tab's transient runtime view, so it is restored when you switch away and back without being added to session history. Ctrl+L clears this transient shell and shell-agent output.
|
|
145
169
|
|
|
146
170
|
## Completion
|
|
147
171
|
|
|
@@ -179,7 +203,7 @@ When several candidates match, repeated Tab presses cycle through them and wrap
|
|
|
179
203
|
|
|
180
204
|
## Built-ins
|
|
181
205
|
|
|
182
|
-
|
|
206
|
+
The persistent `/shell` process handles these commands in-session so their state can persist:
|
|
183
207
|
|
|
184
208
|
| Built-in | What it does |
|
|
185
209
|
| --- | --- |
|
|
@@ -232,12 +256,12 @@ aliases:
|
|
|
232
256
|
|
|
233
257
|
| Setting | Default | What it does |
|
|
234
258
|
| --- | --- | --- |
|
|
235
|
-
| `shell` | `/bin/sh` |
|
|
236
|
-
| `timeout_seconds` | `300` | Maximum runtime for one captured command. |
|
|
237
|
-
| `max_output_bytes` | `1048576` | Maximum output retained for one captured command. |
|
|
259
|
+
| `shell` | `/bin/sh` | Absolute executable path for the persistent `/shell` process and interactive shell commands. |
|
|
260
|
+
| `timeout_seconds` | `300` | Maximum runtime for one captured or shell-agent command. |
|
|
261
|
+
| `max_output_bytes` | `1048576` | Maximum output retained for one captured or shell-agent command. |
|
|
238
262
|
| `history_limit` | `1000` | Maximum shell-history entries per workspace. |
|
|
239
263
|
|
|
240
|
-
Invalid or relative `shell` paths fall back to `/bin/sh`. These timeout and output limits apply
|
|
264
|
+
Invalid or relative `shell` paths fall back to `/bin/sh`. These timeout and output limits apply to `capture` inside `/shell` and shell-agent commands, not user-owned interactive commands or the separate `/capture` slash command.
|
|
241
265
|
|
|
242
266
|
### Environment
|
|
243
267
|
|
|
@@ -253,7 +277,7 @@ TERM=xterm-256color # only when TERM is missing or dumb
|
|
|
253
277
|
|
|
254
278
|
It does not force color. Set `FORCE_COLOR`, `CLICOLOR_FORCE`, or a command-specific option such as `--color=always` when needed.
|
|
255
279
|
|
|
256
|
-
When rbenv is available, Kward adds its shims and bin directories to `PATH` and supplies `RBENV_ROOT` if it was missing
|
|
280
|
+
When rbenv is available, Kward adds its shims and bin directories to `PATH` and supplies `RBENV_ROOT` if it was missing before starting `/shell`. The configured interactive shell may also load its normal startup files.
|
|
257
281
|
|
|
258
282
|
### Aliases
|
|
259
283
|
|
|
@@ -281,17 +305,17 @@ Aliases are intentionally simple: they do not expand recursively and are not she
|
|
|
281
305
|
|
|
282
306
|
## Terminal output and safety
|
|
283
307
|
|
|
284
|
-
Interactive commands write directly to your terminal so full-screen tools can work. When a command does not read keyboard input and emits only line-oriented text plus safe color sequences, Kward mirrors that output into the transient transcript view after the command exits. Other interactive output is not sanitized and may contain terminal control sequences, so run only commands you trust with terminal access.
|
|
308
|
+
Interactive commands write directly to your terminal so full-screen tools can work. When a command does not read keyboard input and emits only line-oriented text plus safe color sequences, Kward mirrors that output into the transient transcript view after the command exits. Other interactive output is not sanitized and may contain terminal control sequences, so run only commands you trust with terminal access. Safe bounded output is included in a shell-agent request only when you explicitly use `?`; it is never sent to the model for ordinary shell commands.
|
|
285
309
|
|
|
286
310
|
Commands run with `capture` inside `/shell` preserve safe ANSI color and style sequences while removing cursor movement, clear-screen controls, title changes, alternate-screen controls, and similar sequences that could damage the TUI transcript.
|
|
287
311
|
|
|
288
312
|
## Limitations
|
|
289
313
|
|
|
290
|
-
|
|
314
|
+
The local `/shell` session is persistent, but it is not a complete terminal emulator or a remote-shell protocol:
|
|
291
315
|
|
|
292
|
-
-
|
|
293
|
-
-
|
|
294
|
-
-
|
|
295
|
-
- there is no login-shell readline integration,
|
|
316
|
+
- the one-off `!command` and `/capture` workflows do not share `/shell` state,
|
|
317
|
+
- shell state is held by the live process and is not serialized across Kward restarts,
|
|
318
|
+
- a stopped or unresponsive command can still require Ctrl+C or shell-session cleanup,
|
|
296
319
|
- full-screen terminal state is not retained after an interactive command exits,
|
|
297
|
-
-
|
|
320
|
+
- while an interactive SSH session owns the terminal, Kward cannot safely intercept `?` or provide remote cwd/completion context; shell-agent prompting resumes after SSH exits,
|
|
321
|
+
- safe line-oriented output may remain in the transient TUI transcript; only bounded safe output from an explicit `?` request enters the transient shell-agent context and none of it is added to the normal session history.
|
data/doc/tabs.md
CHANGED
|
@@ -39,6 +39,8 @@ A normal session tab can be activated in a linked Git worktree after you have re
|
|
|
39
39
|
|
|
40
40
|
```text
|
|
41
41
|
/tab worktree
|
|
42
|
+
# or
|
|
43
|
+
/worktree
|
|
42
44
|
```
|
|
43
45
|
|
|
44
46
|
When enabled, Kward keeps the same tab and transcript but rebuilds its agent against a new worktree. The tab label includes the worktree branch. The worktree is created from `HEAD`, so Kward warns when the original workspace is dirty and leaves those existing changes in the original checkout; it does not copy them automatically. `/tab worktree activate` is an explicit alias for the same action.
|
|
@@ -98,6 +100,7 @@ Tabs keep the conversations separate, so context from one tab does not automatic
|
|
|
98
100
|
| `/tab worktree merge` | Merge the current worktree branch into the branch checked out in its original workspace |
|
|
99
101
|
| `/tab worktree merge abort` | Abort a conflicted worktree merge in the original workspace |
|
|
100
102
|
| `/tab worktree remove` | Remove a clean linked worktree and keep its branch |
|
|
103
|
+
| `/worktree …` | Alias for `/tab worktree …` on the active tab |
|
|
101
104
|
| `/tab move left` | Move the current tab one slot left |
|
|
102
105
|
| `/tab move right` | Move the current tab one slot right |
|
|
103
106
|
| `/tab move <number>` | Move the current tab to a numbered position |
|
data/doc/usage.md
CHANGED
|
@@ -78,7 +78,7 @@ For several commands, enter the embedded Kward shell:
|
|
|
78
78
|
/shell
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
`/shell` opens `ekwsh`, a Kward-native command mode
|
|
81
|
+
`/shell` opens `ekwsh`, a Kward-native command mode with one persistent local shell process. It preserves state such as the current directory, environment variables, functions, and aliases between commands. Prefix a line with `?` inside `/shell` to ask a transient shell assistant about the latest output, execute an explicit state change, or prepare a command without running it. External commands receive an interactive PTY by default, so `git log`, `less`, Vim, SSH, and REPLs work without a prefix. Use `capture <command>` inside `/shell` or `/capture <command>` from the normal composer for bounded, transcript-friendly output. See [Embedded shell](shell.md) for built-ins, completion, configuration, ANSI handling, PTY passthrough, and limitations.
|
|
82
82
|
|
|
83
83
|
## Shell commands
|
|
84
84
|
|
|
@@ -123,7 +123,7 @@ Slash commands run local actions in the current session. Most do not send a prom
|
|
|
123
123
|
| `/git` | review uncommitted changes, stage files, and commit. |
|
|
124
124
|
| `/diff` | open the file changes recorded in the current session. |
|
|
125
125
|
| `/files` | browse project files in a nested tree and open them in the editor. |
|
|
126
|
-
| `/shell` | run stateful workspace commands with interactive PTY support
|
|
126
|
+
| `/shell` | run stateful workspace commands with interactive PTY support and ask the shell agent with `?`. |
|
|
127
127
|
| `/capture <command>` | run a bounded one-shot command and retain its output in the transcript. |
|
|
128
128
|
| `/pty <command>` | explicitly hand the terminal to an interactive command; retained for compatibility with existing workflows. |
|
|
129
129
|
| `/settings` | configure models, accounts, memory, interface, tools, context, personalization, and logging. |
|
|
@@ -143,6 +143,7 @@ Slash commands run local actions in the current session. Most do not send a prom
|
|
|
143
143
|
| `/tab worktree merge` | merge a clean worktree branch into the branch checked out in its original workspace. |
|
|
144
144
|
| `/tab worktree merge abort` | abort a conflicted worktree merge in the original workspace. |
|
|
145
145
|
| `/tab worktree remove` | remove a clean linked worktree while keeping its branch. |
|
|
146
|
+
| `/worktree …` | alias for `/tab worktree …` on the active tab. |
|
|
146
147
|
| `/session` | open the saved sessions picker or continue a previous session by path. |
|
|
147
148
|
| `/resume` | alias for `/session`. |
|
|
148
149
|
| `/session name <name>` | name or clear the current session. |
|
data/kward.gemspec
CHANGED
|
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
|
|
|
20
20
|
|
|
21
21
|
spec.files = Dir.chdir(__dir__) do
|
|
22
22
|
`git ls-files -z`.split("\x0").reject do |file|
|
|
23
|
-
file.start_with?(".ruby-lsp/", "test/", "plan/") || [".gitignore", "AGENTS.md"].include?(file)
|
|
23
|
+
file.start_with?(".github/", ".ruby-lsp/", "script/", "test/", "plan/") || [".gitignore", "AGENTS.md"].include?(file)
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
spec.bindir = "exe"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
require_relative "terminal_sequences"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Streams a conservative subset of PTY output into Kward's inline terminal
|
|
6
|
+
# region and permanently switches to exclusive passthrough before forwarding
|
|
7
|
+
# screen-oriented or unknown terminal controls.
|
|
8
|
+
class AdaptivePtyOutputSink
|
|
9
|
+
MAX_SEQUENCE_BYTES = 4096
|
|
10
|
+
SAFE_CONTROLS = [0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d].freeze
|
|
11
|
+
TERMINAL_STRING_INTRODUCERS = [0x5d, 0x50, 0x5e, 0x5f, 0x58].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :captured_output
|
|
14
|
+
|
|
15
|
+
def initialize(output:, on_exclusive:, max_capture_bytes: nil)
|
|
16
|
+
@output = output
|
|
17
|
+
@on_exclusive = on_exclusive
|
|
18
|
+
@max_capture_bytes = max_capture_bytes
|
|
19
|
+
@captured_output = max_capture_bytes ? +"".b : nil
|
|
20
|
+
@capture_open = true
|
|
21
|
+
@truncated = false
|
|
22
|
+
@mode = :inline
|
|
23
|
+
@sequence = +"".b
|
|
24
|
+
@synchronized_output = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write(chunk)
|
|
28
|
+
value = chunk.to_s.b
|
|
29
|
+
capture(value)
|
|
30
|
+
return @output.write(value) if exclusive?
|
|
31
|
+
|
|
32
|
+
write_inline(value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def flush
|
|
36
|
+
@output.flush if @output.respond_to?(:flush)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def finish
|
|
40
|
+
switch_to_exclusive(@sequence) unless @sequence.empty? || exclusive?
|
|
41
|
+
end_synchronized_output if inline?
|
|
42
|
+
flush
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def input_forwarded
|
|
46
|
+
@capture_open = false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def inline?
|
|
50
|
+
@mode == :inline
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def transcript_safe?
|
|
54
|
+
inline? && @sequence.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pre_input_capture_only?
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def truncated?
|
|
62
|
+
@truncated
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def exclusive?
|
|
68
|
+
@mode == :exclusive
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def write_inline(value)
|
|
72
|
+
safe_output = +"".b
|
|
73
|
+
index = 0
|
|
74
|
+
while index < value.bytesize
|
|
75
|
+
byte = value.getbyte(index)
|
|
76
|
+
if @sequence.empty?
|
|
77
|
+
if byte == 0x1b
|
|
78
|
+
@sequence << byte
|
|
79
|
+
elsif byte >= 0x20 || SAFE_CONTROLS.include?(byte)
|
|
80
|
+
safe_output << byte
|
|
81
|
+
else
|
|
82
|
+
flush_safe_output(safe_output)
|
|
83
|
+
switch_to_exclusive(value.byteslice(index..))
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
@sequence << byte
|
|
88
|
+
status = sequence_status
|
|
89
|
+
if status == :safe
|
|
90
|
+
track_safe_sequence(@sequence)
|
|
91
|
+
safe_output << @sequence
|
|
92
|
+
@sequence.clear
|
|
93
|
+
elsif status == :exclusive
|
|
94
|
+
flush_safe_output(safe_output)
|
|
95
|
+
remainder = value.byteslice((index + 1)..).to_s.b
|
|
96
|
+
switch_to_exclusive(@sequence + remainder)
|
|
97
|
+
@sequence.clear
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
index += 1
|
|
102
|
+
end
|
|
103
|
+
flush_safe_output(safe_output)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def sequence_status
|
|
107
|
+
return :exclusive if @sequence.bytesize > MAX_SEQUENCE_BYTES
|
|
108
|
+
return :pending if @sequence.bytesize == 1
|
|
109
|
+
|
|
110
|
+
second = @sequence.getbyte(1)
|
|
111
|
+
return csi_status if second == "[".ord
|
|
112
|
+
return :exclusive if TERMINAL_STRING_INTRODUCERS.include?(second)
|
|
113
|
+
return escape_intermediate_status if second.between?(0x20, 0x2f)
|
|
114
|
+
|
|
115
|
+
:exclusive
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def csi_status
|
|
119
|
+
return :pending if @sequence.bytesize == 2
|
|
120
|
+
|
|
121
|
+
byte = @sequence.getbyte(-1)
|
|
122
|
+
return safe_csi? ? :safe : :exclusive if byte.between?(0x40, 0x7e)
|
|
123
|
+
return :pending if byte.between?(0x20, 0x3f)
|
|
124
|
+
|
|
125
|
+
:exclusive
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def escape_intermediate_status
|
|
129
|
+
byte = @sequence.getbyte(-1)
|
|
130
|
+
return :pending if byte.between?(0x20, 0x2f)
|
|
131
|
+
|
|
132
|
+
:exclusive
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def safe_csi?
|
|
136
|
+
value = @sequence
|
|
137
|
+
value.match?(/\A\e\[[0-9:;]*m\z/) ||
|
|
138
|
+
value.match?(/\A\e\[[0-2]?K\z/) ||
|
|
139
|
+
value.match?(/\A\e\[[0-9;]*[CDG`]\z/) ||
|
|
140
|
+
value.match?(/\A\e\[\?25[hl]\z/) ||
|
|
141
|
+
value.match?(/\A\e\[\?(?:2004|2026)[hl]\z/) ||
|
|
142
|
+
value.match?(/\A\e\[[0-9;]* q\z/)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def track_safe_sequence(sequence)
|
|
146
|
+
@synchronized_output = true if sequence == TerminalSequences::SYNCHRONIZED_OUTPUT_ENABLE
|
|
147
|
+
@synchronized_output = false if sequence == TerminalSequences::SYNCHRONIZED_OUTPUT_DISABLE
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def end_synchronized_output
|
|
151
|
+
return unless @synchronized_output
|
|
152
|
+
|
|
153
|
+
@output.write(TerminalSequences::SYNCHRONIZED_OUTPUT_DISABLE)
|
|
154
|
+
@synchronized_output = false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def flush_safe_output(value)
|
|
158
|
+
return if value.empty?
|
|
159
|
+
|
|
160
|
+
@output.write(value)
|
|
161
|
+
value.clear
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def switch_to_exclusive(value)
|
|
165
|
+
end_synchronized_output
|
|
166
|
+
@on_exclusive.call
|
|
167
|
+
@mode = :exclusive
|
|
168
|
+
@output.write(value) unless value.empty?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def capture(value)
|
|
172
|
+
return unless @captured_output && @capture_open
|
|
173
|
+
|
|
174
|
+
remaining = @max_capture_bytes - @captured_output.bytesize
|
|
175
|
+
if value.bytesize > remaining
|
|
176
|
+
@captured_output << value.byteslice(0, remaining) if remaining.positive?
|
|
177
|
+
@truncated = true
|
|
178
|
+
else
|
|
179
|
+
@captured_output << value
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/kward/agent.rb
CHANGED
|
@@ -84,6 +84,7 @@ module Kward
|
|
|
84
84
|
end
|
|
85
85
|
loop do
|
|
86
86
|
cancellation&.raise_if_cancelled!
|
|
87
|
+
auto_compact_if_needed
|
|
87
88
|
begin
|
|
88
89
|
message = chat(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering, options: options, tool_registry: tool_registry) do |event|
|
|
89
90
|
yield event if block_given?
|
|
@@ -17,7 +17,7 @@ module Kward
|
|
|
17
17
|
DEFAULT_PORT = 53_692
|
|
18
18
|
CALLBACK_PATH = "/callback"
|
|
19
19
|
DEFAULT_CLIENT_ID = Base64.decode64("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl")
|
|
20
|
-
SCOPE = "
|
|
20
|
+
SCOPE = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
|
21
21
|
|
|
22
22
|
attr_reader :auth_path
|
|
23
23
|
|
|
@@ -60,7 +60,7 @@ module Kward
|
|
|
60
60
|
end
|
|
61
61
|
raise "Missing authorization code" if code.to_s.empty?
|
|
62
62
|
|
|
63
|
-
complete_login_flow(code: code, redirect_uri: redirect_uri, code_verifier: pkce[:verifier])
|
|
63
|
+
complete_login_flow(code: code, redirect_uri: redirect_uri, code_verifier: pkce[:verifier], state: state)
|
|
64
64
|
auth_path
|
|
65
65
|
ensure
|
|
66
66
|
server&.close unless server&.closed?
|
|
@@ -68,7 +68,6 @@ module Kward
|
|
|
68
68
|
|
|
69
69
|
def authorization_url(redirect_uri:, code_challenge:, state:)
|
|
70
70
|
query = URI.encode_www_form(
|
|
71
|
-
code: "true",
|
|
72
71
|
client_id: client_id,
|
|
73
72
|
response_type: "code",
|
|
74
73
|
redirect_uri: redirect_uri,
|
|
@@ -98,8 +97,8 @@ module Kward
|
|
|
98
97
|
wait_for_callback(server, expected_state: expected_state, timeout_seconds: timeout_seconds)
|
|
99
98
|
end
|
|
100
99
|
|
|
101
|
-
def complete_login_flow(code:, redirect_uri:, code_verifier:)
|
|
102
|
-
tokens = exchange_code_for_tokens(code: code, redirect_uri: redirect_uri, code_verifier: code_verifier)
|
|
100
|
+
def complete_login_flow(code:, redirect_uri:, code_verifier:, state:)
|
|
101
|
+
tokens = exchange_code_for_tokens(code: code, redirect_uri: redirect_uri, code_verifier: code_verifier, state: state)
|
|
103
102
|
save_auth(tokens: tokens)
|
|
104
103
|
tokens
|
|
105
104
|
end
|
|
@@ -192,13 +191,14 @@ module Kward
|
|
|
192
191
|
oauth_callback_server(port_env: "KWARD_ANTHROPIC_OAUTH_PORT", default_port: DEFAULT_PORT)
|
|
193
192
|
end
|
|
194
193
|
|
|
195
|
-
def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
|
|
194
|
+
def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:, state:)
|
|
196
195
|
response = post_json(TOKEN_URL,
|
|
197
196
|
grant_type: "authorization_code",
|
|
198
197
|
client_id: client_id,
|
|
199
198
|
code: code,
|
|
200
199
|
redirect_uri: redirect_uri,
|
|
201
|
-
code_verifier: code_verifier
|
|
200
|
+
code_verifier: code_verifier,
|
|
201
|
+
state: state)
|
|
202
202
|
parse_successful_json(response, "Anthropic OAuth token exchange")
|
|
203
203
|
end
|
|
204
204
|
|
data/lib/kward/cli/git.rb
CHANGED
|
@@ -30,12 +30,16 @@ module Kward
|
|
|
30
30
|
status = result.is_a?(Hash) && result.key?(:status_lines) ? result[:status_lines] : result
|
|
31
31
|
result
|
|
32
32
|
end
|
|
33
|
-
|
|
33
|
+
if message.nil?
|
|
34
|
+
refresh_composer_status
|
|
35
|
+
return
|
|
36
|
+
end
|
|
34
37
|
|
|
35
38
|
result = run_busy_local_command_and_requeue(activity: "committing") do
|
|
36
39
|
git_commit(git_root, message)
|
|
37
40
|
end
|
|
38
41
|
print_git_commit_result(result)
|
|
42
|
+
refresh_composer_status
|
|
39
43
|
ensure
|
|
40
44
|
@git_hook_conversation = previous_git_hook_conversation
|
|
41
45
|
end
|
|
@@ -6,9 +6,68 @@ module Kward
|
|
|
6
6
|
module InteractiveTurn
|
|
7
7
|
private
|
|
8
8
|
|
|
9
|
-
def
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
def run_editor_prompt_turn(action, agent)
|
|
10
|
+
unless agent.respond_to?(:tool_registry) && agent.tool_registry.respond_to?(:for_editor_prompt)
|
|
11
|
+
runtime_output("Editor prompts are unavailable in this tab.")
|
|
12
|
+
return []
|
|
13
|
+
end
|
|
14
|
+
unless @prompt.respond_to?(:editor_prompt_context)
|
|
15
|
+
runtime_output("Editor prompts are unavailable in this prompt.")
|
|
16
|
+
return []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
context = @prompt.editor_prompt_context
|
|
20
|
+
unless context
|
|
21
|
+
runtime_output("Open a Modern or Vibe editor buffer before prompting the agent.")
|
|
22
|
+
return []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
unless @prompt.suspend_editor_for_agent
|
|
26
|
+
runtime_output("The editor is busy.")
|
|
27
|
+
return []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
session = EditorPromptSession.new(context)
|
|
31
|
+
registry = agent.tool_registry.for_editor_prompt(session)
|
|
32
|
+
editor_agent = build_editor_prompt_agent(agent, context, registry)
|
|
33
|
+
instruction = action[:instruction] || action["instruction"]
|
|
34
|
+
run_interactive_turn(
|
|
35
|
+
editor_agent,
|
|
36
|
+
EditorPrompt.input(instruction, context),
|
|
37
|
+
tool_registry: registry,
|
|
38
|
+
editor_prompt_session: session,
|
|
39
|
+
suppress_transcript: true
|
|
40
|
+
)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
@prompt.resume_editor_for_agent(status: "Agent request failed") if @prompt.respond_to?(:resume_editor_for_agent)
|
|
43
|
+
runtime_output("Error: #{e.message}")
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_editor_prompt_agent(active_agent, context, registry)
|
|
48
|
+
active_conversation = active_agent.conversation
|
|
49
|
+
config = safely_read_config.to_h
|
|
50
|
+
provider = active_conversation.provider || current_model_provider
|
|
51
|
+
model = ConfigFiles.editor_agent_model(config) || active_conversation.model || current_model_id
|
|
52
|
+
reasoning = ConfigFiles.editor_agent_reasoning_effort(config) || active_conversation.reasoning_effort || current_reasoning_effort
|
|
53
|
+
conversation = Conversation.new(
|
|
54
|
+
system_message: EditorPrompt.system_message,
|
|
55
|
+
workspace_root: context[:workspace_root] || active_conversation.workspace_root,
|
|
56
|
+
provider: provider,
|
|
57
|
+
model: model,
|
|
58
|
+
reasoning_effort: reasoning
|
|
59
|
+
)
|
|
60
|
+
Agent.new(
|
|
61
|
+
client: @client,
|
|
62
|
+
tool_registry: registry,
|
|
63
|
+
conversation: conversation,
|
|
64
|
+
warning_sink: ConfigFiles.warning_sink
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def run_interactive_turn(agent, input, display_input: nil, tool_registry: nil, editor_prompt_session: nil, suppress_transcript: false, busy_label: "You>", on_complete: nil)
|
|
69
|
+
prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation) && !suppress_transcript
|
|
70
|
+
return run_blocking_interactive_turn(agent, input, display_input: display_input, tool_registry: tool_registry, suppress_transcript: suppress_transcript, on_complete: on_complete) unless prompt_interface?
|
|
12
71
|
|
|
13
72
|
queued_inputs = []
|
|
14
73
|
cancellation = Cancellation.new
|
|
@@ -25,11 +84,11 @@ module Kward
|
|
|
25
84
|
markdown_chunks = []
|
|
26
85
|
answer = nil
|
|
27
86
|
error = nil
|
|
28
|
-
@prompt.begin_busy_input(
|
|
29
|
-
print_user_transcript(input, display_input: display_input)
|
|
87
|
+
@prompt.begin_busy_input(busy_label) if @prompt.respond_to?(:begin_busy_input)
|
|
88
|
+
print_user_transcript(input, display_input: display_input) unless suppress_transcript
|
|
30
89
|
|
|
31
90
|
worker = Thread.new do
|
|
32
|
-
options = agent_display_options(display_input)
|
|
91
|
+
options = agent_display_options(display_input, tool_registry: tool_registry)
|
|
33
92
|
options[:cancellation] = cancellation
|
|
34
93
|
options[:steering] = steering if steering
|
|
35
94
|
answer = agent.ask(input, **options) do |event|
|
|
@@ -52,7 +111,7 @@ module Kward
|
|
|
52
111
|
cancellation.cancel!
|
|
53
112
|
worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
|
|
54
113
|
end
|
|
55
|
-
if busy_replacement_agent?
|
|
114
|
+
if busy_replacement_agent? || suppress_transcript
|
|
56
115
|
discard_interactive_events(event_queue, markdown_chunks, stream_state)
|
|
57
116
|
else
|
|
58
117
|
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
|
|
@@ -64,19 +123,39 @@ module Kward
|
|
|
64
123
|
error ||= e
|
|
65
124
|
end
|
|
66
125
|
drain_busy_input(queued_inputs, nil) unless cancelled
|
|
67
|
-
if busy_replacement_agent?
|
|
126
|
+
if busy_replacement_agent? || suppress_transcript
|
|
68
127
|
discard_interactive_events(event_queue, markdown_chunks, stream_state, force: true)
|
|
69
128
|
else
|
|
70
129
|
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
|
|
71
130
|
end
|
|
131
|
+
completed = !cancelled && !busy_replacement_agent? && !error
|
|
132
|
+
on_complete&.call(successful: completed, cancelled: cancelled, error: error, answer: answer)
|
|
72
133
|
raise error if error && !error.is_a?(Cancellation::CancelledError) && !busy_replacement_agent?
|
|
73
134
|
|
|
74
|
-
@prompt.
|
|
75
|
-
|
|
76
|
-
|
|
135
|
+
@prompt.commit_editor_prompt(editor_prompt_session) if editor_prompt_session && completed
|
|
136
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
|
|
137
|
+
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
|
|
138
|
+
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript && queued_inputs.empty? && !cancelled
|
|
77
139
|
queued_inputs
|
|
78
140
|
ensure
|
|
79
141
|
@prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
|
|
142
|
+
if editor_prompt_session && @prompt.respond_to?(:resume_editor_for_agent)
|
|
143
|
+
status = if cancelled
|
|
144
|
+
"Agent request cancelled"
|
|
145
|
+
elsif error
|
|
146
|
+
"Agent request failed"
|
|
147
|
+
elsif editor_prompt_session && !editor_prompt_session.changed? && !answer.to_s.strip.empty?
|
|
148
|
+
editor_prompt_answer_status(answer)
|
|
149
|
+
end
|
|
150
|
+
@prompt.resume_editor_for_agent(status: status)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def editor_prompt_answer_status(answer)
|
|
155
|
+
text = answer.to_s.gsub(/\s+/, " ").strip
|
|
156
|
+
text = text[0, 120].to_s
|
|
157
|
+
text += "…" if answer.to_s.gsub(/\s+/, " ").strip.length > text.length
|
|
158
|
+
"Agent: #{text}"
|
|
80
159
|
end
|
|
81
160
|
|
|
82
161
|
def drain_interactive_events(event_queue, markdown_chunks, stream_state, agent = nil, force: false)
|
|
@@ -261,21 +340,24 @@ module Kward
|
|
|
261
340
|
ModelInfo.reasoning_supported?(current_model_provider, model)
|
|
262
341
|
end
|
|
263
342
|
|
|
264
|
-
def run_blocking_interactive_turn(agent, input, display_input: nil)
|
|
343
|
+
def run_blocking_interactive_turn(agent, input, display_input: nil, tool_registry: nil, suppress_transcript: false, on_complete: nil)
|
|
265
344
|
streamed = false
|
|
266
345
|
markdown_chunks = []
|
|
267
|
-
answer = agent.ask(input, **agent_display_options(display_input)) do |event|
|
|
268
|
-
streamed = true if render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
346
|
+
answer = agent.ask(input, **agent_display_options(display_input, tool_registry: tool_registry)) do |event|
|
|
347
|
+
streamed = true if !suppress_transcript && render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
269
348
|
end
|
|
270
349
|
flush_markdown_deltas(markdown_chunks) if streamed
|
|
271
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless streamed || answer.to_s.empty?
|
|
272
|
-
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
|
|
273
|
-
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation)
|
|
350
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || streamed || answer.to_s.empty?
|
|
351
|
+
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
|
|
352
|
+
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
|
|
353
|
+
on_complete&.call(successful: true, cancelled: false, error: nil, answer: answer)
|
|
274
354
|
[]
|
|
275
355
|
end
|
|
276
356
|
|
|
277
|
-
def agent_display_options(display_input)
|
|
278
|
-
display_input.nil? ? {} : { display_input: display_input }
|
|
357
|
+
def agent_display_options(display_input, tool_registry: nil)
|
|
358
|
+
options = display_input.nil? ? {} : { display_input: display_input }
|
|
359
|
+
options[:tool_registry] = tool_registry if tool_registry
|
|
360
|
+
options
|
|
279
361
|
end
|
|
280
362
|
|
|
281
363
|
end
|