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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/Rakefile +44 -4
  6. data/doc/agent-tools.md +1 -0
  7. data/doc/composer.md +2 -2
  8. data/doc/configuration.md +18 -3
  9. data/doc/editor.md +20 -10
  10. data/doc/files.md +12 -5
  11. data/doc/permissions.md +1 -0
  12. data/doc/releasing.md +79 -38
  13. data/doc/rpc.md +2 -2
  14. data/doc/sandboxing.md +5 -1
  15. data/doc/security.md +2 -2
  16. data/doc/shell.md +42 -18
  17. data/doc/tabs.md +3 -0
  18. data/doc/usage.md +3 -2
  19. data/kward.gemspec +1 -1
  20. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  21. data/lib/kward/agent.rb +1 -0
  22. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  23. data/lib/kward/cli/git.rb +5 -1
  24. data/lib/kward/cli/interactive_turn.rb +101 -19
  25. data/lib/kward/cli/rendering.rb +14 -2
  26. data/lib/kward/cli/runtime_helpers.rb +252 -46
  27. data/lib/kward/cli/settings.rb +5 -13
  28. data/lib/kward/cli/slash_commands.rb +12 -1
  29. data/lib/kward/cli/tabs.rb +71 -9
  30. data/lib/kward/cli.rb +12 -0
  31. data/lib/kward/compaction/token_estimator.rb +12 -6
  32. data/lib/kward/config_files.rb +17 -0
  33. data/lib/kward/editor_prompt.rb +46 -0
  34. data/lib/kward/editor_prompt_session.rb +28 -0
  35. data/lib/kward/ekwsh.rb +70 -10
  36. data/lib/kward/image_attachments.rb +98 -15
  37. data/lib/kward/interactive_pty_runner.rb +25 -19
  38. data/lib/kward/model/client.rb +1 -17
  39. data/lib/kward/model/model_info.rb +3 -2
  40. data/lib/kward/model/payloads.rb +0 -2
  41. data/lib/kward/persistent_shell_session.rb +750 -0
  42. data/lib/kward/project_files.rb +18 -5
  43. data/lib/kward/prompt_interface/composer_controller.rb +3 -3
  44. data/lib/kward/prompt_interface/composer_renderer.rb +28 -1
  45. data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
  46. data/lib/kward/prompt_interface/editor/controller.rb +75 -6
  47. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  48. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  49. data/lib/kward/prompt_interface/editor/modes/vibe.rb +12 -6
  50. data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
  51. data/lib/kward/prompt_interface/editor/state.rb +11 -0
  52. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  53. data/lib/kward/prompt_interface/file_overlay.rb +21 -7
  54. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  55. data/lib/kward/prompt_interface/key_handler.rb +30 -0
  56. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
  57. data/lib/kward/prompt_interface/project_browser.rb +192 -8
  58. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  59. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  60. data/lib/kward/prompt_interface/runtime_state.rb +5 -1
  61. data/lib/kward/prompt_interface/screen.rb +40 -20
  62. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  63. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  64. data/lib/kward/prompt_interface.rb +159 -32
  65. data/lib/kward/prompts/commands.rb +1 -0
  66. data/lib/kward/pty_output_sink.rb +45 -0
  67. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  68. data/lib/kward/rpc/auth_manager.rb +1 -1
  69. data/lib/kward/rpc/server.rb +2 -1
  70. data/lib/kward/session_catalog.rb +87 -0
  71. data/lib/kward/session_store.rb +94 -5
  72. data/lib/kward/shell_prompt.rb +50 -0
  73. data/lib/kward/shell_prompt_session.rb +58 -0
  74. data/lib/kward/terminal_image_support.rb +116 -0
  75. data/lib/kward/terminal_keys.rb +1 -0
  76. data/lib/kward/terminal_sequences.rb +43 -0
  77. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  78. data/lib/kward/tools/registry.rb +63 -5
  79. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  80. data/lib/kward/tools/run_shell_command.rb +24 -6
  81. data/lib/kward/version.rb +1 -1
  82. data/templates/default/layout/html/layout.erb +1 -1
  83. metadata +15 -4
  84. data/.github/workflows/ci.yml +0 -48
  85. 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` forward child output directly to your terminal so interactive programs work. This bypasses transcript control-sequence sanitization; run only commands you trust with terminal access. `capture <command>` and `/capture <command>` sanitize their captured output.
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 temporarily owns the terminal. When it exits, Kward restores the composer. Safe, line-oriented output from commands such as `ls` is mirrored into the transient transcript view so a repaint cannot hide it. Shell output is never added to the AI conversation or sent to the model.
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
- The line-oriented Git commands `git fetch`, `git ls-remote`, `git push`, `git remote`, and `git status` keep the composer visible as a frozen display while they run. Keyboard input still belongs to the child process. Other commands use the full-terminal handoff so full-screen programs retain the complete terminal.
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
- `ekwsh` handles a small set of commands itself so their state can persist:
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` | POSIX-compatible shell used with `-c`. It must be an absolute executable path. |
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 only to `capture` inside `/shell`, not interactive commands or the separate `/capture` slash command.
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. This lets `ruby`, `bundle`, and `./exe/kward` use the selected Ruby without sourcing shell startup files.
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
- `ekwsh` manages shell-like state, but it is not a persistent login shell or terminal emulator:
314
+ The local `/shell` session is persistent, but it is not a complete terminal emulator or a remote-shell protocol:
291
315
 
292
- - each external command runs separately through the configured shell,
293
- - there is no job control; a stopped child is terminated rather than leaving the terminal stranded,
294
- - shell functions do not persist and shell startup files are not sourced,
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
- - safe line-oriented output may remain in the transient TUI transcript, but full-screen terminal state is not retained and no shell output becomes part of the AI conversation.
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 that preserves state such as the current directory, environment variables, and aliases between commands. 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.
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 = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
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
- return if message.nil?
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 run_interactive_turn(agent, input, display_input: nil)
10
- prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
11
- return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
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("You>") if @prompt.respond_to?(: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.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
75
- persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
76
- auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
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