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
@@ -4,6 +4,8 @@ require "pathname"
4
4
  require "rbconfig"
5
5
  require "thread"
6
6
  require_relative "project_files"
7
+ require_relative "image_attachments"
8
+ require_relative "terminal_image_support"
7
9
  require_relative "prompt_history"
8
10
  require "tty-cursor"
9
11
  require "tty-reader"
@@ -32,6 +34,7 @@ require_relative "prompt_interface/editor/renderer"
32
34
  require_relative "prompt_interface/editor/syntax_highlighter"
33
35
  require_relative "prompt_interface/editor/auto_close_pairs"
34
36
  require_relative "prompt_interface/editor/endwise"
37
+ require_relative "prompt_interface/editor/word_completion"
35
38
  require_relative "prompt_interface/editor/auto_indent"
36
39
  require_relative "prompt_interface/composer_renderer"
37
40
  require_relative "prompt_interface/composer_controller"
@@ -68,6 +71,7 @@ module Kward
68
71
  FOOTER_REFRESH_INTERVAL = 1.0
69
72
  COMPOSER_STATUS_REFRESH_INTERVAL = 1.0
70
73
  COMPOSER_MAX_INPUT_ROWS = 6
74
+ IMAGE_VIEWER_MAX_ROWS = 8
71
75
  TRANSCRIPT_BUFFER_LIMIT = 200_000
72
76
  BANNER_MESSAGE = Banner::MESSAGE
73
77
 
@@ -83,6 +87,7 @@ module Kward
83
87
  include EditorSyntaxHighlighter
84
88
  include EditorAutoClosePairs
85
89
  include EditorEndwise
90
+ include EditorWordCompletion
86
91
  include EditorAutoIndent
87
92
  include ComposerRenderer
88
93
  include ComposerController
@@ -131,6 +136,9 @@ module Kward
131
136
  def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil, redraw_handler: nil)
132
137
  @input_io = input
133
138
  @output_io = output
139
+ # Logical state may change during a child handoff, but only the owner may render.
140
+ @terminal_owner = :kward
141
+ @terminal_handoff_mode = nil
134
142
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
135
143
  @mutex = Mutex.new
136
144
  @prompt_history = prompt_history
@@ -177,9 +185,15 @@ module Kward
177
185
  @file_open_dismissed_token = nil
178
186
  @file_editor_open_status = nil
179
187
  @file_mention_paths = nil
188
+ @project_browser_file_paths = nil
180
189
  @project_browser_state = nil
190
+ @image_viewer_state = nil
191
+ @terminal_image_protocol = nil
181
192
  @project_browser_restore_after_editor = false
182
193
  @editor_state = nil
194
+ @editor_agent_suspended = false
195
+ @editor_prompt_active = false
196
+ @editor_prompt_input = ""
183
197
  @interactive_state = nil
184
198
  @last_interactive_tick = monotonic_now
185
199
  @select_state = nil
@@ -237,8 +251,8 @@ module Kward
237
251
  @started = true
238
252
  @asking = true
239
253
  disable_editor_mouse_reporting(force: true) unless editor_active?
240
- @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
241
- @output_io.print(BRACKETED_PASTE_ENABLE)
254
+ print_output_locked(KEYBOARD_PROTOCOL_ENABLE)
255
+ print_output_locked(BRACKETED_PASTE_ENABLE)
242
256
  render_prompt_locked if render
243
257
  end
244
258
  end
@@ -247,15 +261,16 @@ module Kward
247
261
  @mutex.synchronize do
248
262
  return unless @started
249
263
 
264
+ clear_image_viewer_output_locked if image_viewer_active?
250
265
  clear_prompt_for_output_locked
251
266
  restore_scroll_region_locked
252
267
  disable_editor_mouse_reporting(force: true)
253
- @output_io.print(BRACKETED_PASTE_RESTORE)
254
- @output_io.print(KEYBOARD_PROTOCOL_RESTORE)
268
+ print_output_locked(BRACKETED_PASTE_RESTORE)
269
+ print_output_locked(KEYBOARD_PROTOCOL_RESTORE)
255
270
  restore_editor_cursor_shape_locked
256
271
  set_cursor_visible_locked(true, force: true)
257
- @output_io.puts
258
- @output_io.flush
272
+ puts_output_locked
273
+ flush_output_locked
259
274
  @started = false
260
275
  restore_console_mode_locked
261
276
  end
@@ -281,14 +296,14 @@ module Kward
281
296
  @stream_state.finish_block
282
297
  render_prompt_after_output_locked
283
298
  end
284
- @output_io.flush
299
+ flush_output_locked
285
300
  end
286
301
  end
287
302
 
288
303
  def restore_transcript
289
304
  start(render: false) unless @started
290
305
  @mutex.synchronize do
291
- @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
306
+ print_output_locked(SYNCHRONIZED_OUTPUT_ENABLE)
292
307
  clear_prompt_for_output_locked unless @rendered_rows.zero? && @last_composer_rows.empty?
293
308
  @transcript_buffer.clear
294
309
  @transcript_viewport_rows = 0
@@ -303,8 +318,8 @@ module Kward
303
318
  @restoring_transcript = false
304
319
  width, height = screen_size
305
320
  redraw_screen_locked(width: width, height: height)
306
- @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE)
307
- @output_io.flush
321
+ print_output_locked(SYNCHRONIZED_OUTPUT_DISABLE)
322
+ flush_output_locked
308
323
  end
309
324
  end
310
325
 
@@ -343,6 +358,64 @@ module Kward
343
358
  @mutex.synchronize { editor_active? }
344
359
  end
345
360
 
361
+ # Returns the active Vibe or Modern editor buffer for an agent prompt turn.
362
+ def editor_prompt_context
363
+ @mutex.synchronize do
364
+ next nil unless editor_active? && (@editor_state.vibe? || @editor_state.modern?)
365
+
366
+ {
367
+ path: @editor_state.path,
368
+ display_path: @editor_state.display_path,
369
+ workspace_root: @workspace_root,
370
+ language: @editor_state.language,
371
+ content: @editor_state.buffer.dup,
372
+ cursor: @editor_state.cursor_line_and_column,
373
+ selection: @editor_state.selected_text
374
+ }
375
+ end
376
+ end
377
+
378
+ # Suspends the live editor while an agent prompt is running.
379
+ def suspend_editor_for_agent
380
+ @mutex.synchronize do
381
+ return false unless editor_active? && !@editor_agent_suspended
382
+
383
+ @editor_agent_suspended = true
384
+ @editor_state.status = "Agent is working · Ctrl+C cancels"
385
+ render_prompt_locked if @started
386
+ true
387
+ end
388
+ end
389
+
390
+ # Commits an agent draft as one undoable editor operation.
391
+ def commit_editor_prompt(session)
392
+ @mutex.synchronize do
393
+ return false unless editor_active? && @editor_agent_suspended
394
+ return false unless @editor_state.buffer == session.initial_content
395
+
396
+ changed = @editor_state.replace_buffer(session.content)
397
+ @editor_state.status = changed ? "Agent updated buffer · :w save" : "Agent made no buffer changes"
398
+ render_prompt_locked if @started
399
+ changed
400
+ end
401
+ end
402
+
403
+ # Resumes the editor after an agent prompt turn.
404
+ def resume_editor_for_agent(status: nil)
405
+ @mutex.synchronize do
406
+ return false unless editor_active?
407
+
408
+ @editor_agent_suspended = false
409
+ @editor_state.status = status.to_s unless status.to_s.empty?
410
+ render_prompt_locked if @started
411
+ true
412
+ end
413
+ end
414
+
415
+ def inline_image_protocol
416
+ @mutex.synchronize { terminal_image_protocol }
417
+ end
418
+
346
419
  def update_workspace_root(root, prompt_history: nil)
347
420
  @mutex.synchronize do
348
421
  expanded_root = File.expand_path(root.to_s.empty? ? Dir.pwd : root)
@@ -467,6 +540,21 @@ module Kward
467
540
  perform_pending_transcript_redraw
468
541
  end
469
542
 
543
+ # Prefills the next composer input without submitting it.
544
+ def prefill_input(value)
545
+ @mutex.synchronize do
546
+ @composer.prefill_input = value.to_s
547
+ end
548
+ end
549
+
550
+ # Updates the visible prompt label while the composer is active.
551
+ def update_prompt_label(label)
552
+ @mutex.synchronize do
553
+ @prompt_label = label.to_s
554
+ render_prompt_locked if @started && @asking
555
+ end
556
+ end
557
+
470
558
  def yes?(message, default: false)
471
559
  answer = ask("#{message} #{default ? "[Y/n]" : "[y/N]"}")
472
560
  return default if answer.nil?
@@ -575,16 +663,21 @@ module Kward
575
663
  @interactive_state = nil
576
664
  restore_composer_snapshot_locked(snapshot)
577
665
  redraw_screen_locked if @started
578
- @output_io.flush
666
+ flush_output_locked
579
667
  end
580
668
  end
581
669
 
582
- def with_terminal_handoff(preserve_composer: false)
670
+ def with_inline_terminal_handoff(&block)
671
+ with_terminal_handoff(inline: true, &block)
672
+ end
673
+
674
+ def with_terminal_handoff(inline: false)
583
675
  start
584
676
  input = nil
585
677
  output = nil
678
+ transition = nil
586
679
  @mutex.synchronize do
587
- if preserve_composer
680
+ if inline
588
681
  handle_resize_locked
589
682
  reserve_composer_region_locked
590
683
  move_to_transcript_cursor_locked
@@ -593,27 +686,36 @@ module Kward
593
686
  restore_scroll_region_locked
594
687
  end
595
688
  disable_editor_mouse_reporting(force: true)
596
- @output_io.print(BRACKETED_PASTE_RESTORE)
597
- @output_io.print(KEYBOARD_PROTOCOL_RESTORE)
689
+ print_output_locked(BRACKETED_PASTE_RESTORE)
690
+ print_output_locked(KEYBOARD_PROTOCOL_RESTORE)
598
691
  restore_editor_cursor_shape_locked
599
692
  set_cursor_visible_locked(true, force: true)
600
- @output_io.flush
693
+ flush_output_locked
601
694
  restore_console_mode_locked
602
695
  input = @input_io
603
696
  output = @output_io
697
+ transition = method(:switch_terminal_handoff_to_exclusive)
698
+ @terminal_handoff_mode = inline ? :inline : :exclusive
699
+ @terminal_owner = :child
604
700
  end
605
701
 
606
- yield(input, output)
702
+ yield(input, output, transition)
607
703
  ensure
608
704
  @mutex.synchronize do
609
- restore_scroll_region_locked if preserve_composer
610
- make_room_for_composer_after_handoff_locked if !preserve_composer && @started && @asking
705
+ @terminal_owner = :kward
706
+ @terminal_handoff_mode = nil
707
+ restore_scroll_region_locked
708
+ print_output_locked(TerminalSequences::SGR_RESET)
611
709
  enter_raw_mode_locked
612
- @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
613
- @output_io.print(BRACKETED_PASTE_ENABLE)
710
+ print_output_locked(KEYBOARD_PROTOCOL_ENABLE)
711
+ print_output_locked(BRACKETED_PASTE_ENABLE)
712
+ disable_editor_mouse_reporting(force: true)
713
+ restore_editor_cursor_shape_locked(force: true)
714
+ @cursor_visible = nil
614
715
  @last_composer_rows = []
615
- render_prompt_locked if @started && @asking
616
- @output_io.flush
716
+ width, height = screen_size
717
+ with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
718
+ flush_output_locked
617
719
  end
618
720
  end
619
721
 
@@ -851,7 +953,7 @@ module Kward
851
953
  @stream_state.finish_block
852
954
  restore_composer_cursor_locked
853
955
  end
854
- @output_io.flush
956
+ flush_output_locked
855
957
  end
856
958
  end
857
959
 
@@ -873,7 +975,7 @@ module Kward
873
975
  end
874
976
  end
875
977
 
876
- def record_transient_terminal_output(text)
978
+ def record_transient_terminal_output(text, render: true)
877
979
  value = text.to_s
878
980
  return if value.empty?
879
981
 
@@ -883,8 +985,10 @@ module Kward
883
985
  @stream_state.finish_block
884
986
  width, height = screen_size
885
987
  remember_transcript_viewport_locked(height)
988
+ next unless render
989
+
886
990
  with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
887
- @output_io.flush
991
+ flush_output_locked
888
992
  end
889
993
  end
890
994
 
@@ -895,7 +999,7 @@ module Kward
895
999
  write_transcript_text_locked(delta.to_s)
896
1000
  restore_composer_cursor_locked unless @restoring_transcript
897
1001
  end
898
- @output_io.flush unless @restoring_transcript
1002
+ flush_output_locked unless @restoring_transcript
899
1003
  end
900
1004
  end
901
1005
 
@@ -909,7 +1013,7 @@ module Kward
909
1013
  @mutex.synchronize do
910
1014
  width, height = screen_size
911
1015
  with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
912
- @output_io.flush
1016
+ flush_output_locked
913
1017
  end
914
1018
  end
915
1019
 
@@ -931,12 +1035,27 @@ module Kward
931
1035
  @stream_state.reset
932
1036
  width, height = screen_size
933
1037
  with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
934
- @output_io.flush
1038
+ flush_output_locked
935
1039
  end
936
1040
  end
937
1041
 
938
1042
  private
939
1043
 
1044
+ def switch_terminal_handoff_to_exclusive
1045
+ @mutex.synchronize do
1046
+ return unless @terminal_owner == :child && @terminal_handoff_mode == :inline
1047
+
1048
+ @terminal_owner = :kward
1049
+ with_synchronized_output_locked do
1050
+ clear_prompt_for_output_locked
1051
+ restore_scroll_region_locked
1052
+ end
1053
+ flush_output_locked
1054
+ @terminal_handoff_mode = :exclusive
1055
+ @terminal_owner = :child
1056
+ end
1057
+ end
1058
+
940
1059
  def write_transcript_message(message, preserve_composer:)
941
1060
  @mutex.synchronize do
942
1061
  text = message.to_s
@@ -958,12 +1077,11 @@ module Kward
958
1077
  @stream_state.finish_block
959
1078
  preserve_composer ? restore_composer_cursor_locked : render_prompt_after_output_locked
960
1079
  end
961
- @output_io.flush
1080
+ flush_output_locked
962
1081
  end
963
1082
  end
964
1083
 
965
1084
  def request_transcript_redraw
966
- redraw_screen_locked
967
1085
  @transcript_redraw_requested = true
968
1086
  end
969
1087
 
@@ -973,7 +1091,13 @@ module Kward
973
1091
  @transcript_redraw_requested = false
974
1092
  pending
975
1093
  end
976
- @redraw_handler&.call if requested
1094
+ return unless requested
1095
+
1096
+ if @redraw_handler
1097
+ @redraw_handler.call
1098
+ else
1099
+ redraw
1100
+ end
977
1101
  end
978
1102
 
979
1103
  def prompt_workspace_root
@@ -982,9 +1106,12 @@ module Kward
982
1106
 
983
1107
  def reset_workspace_file_state_locked
984
1108
  @file_mention_paths = nil
1109
+ @project_browser_file_paths = nil
985
1110
  @file_mention_path_entries_paths = nil
986
1111
  @file_mention_path_entries = nil
987
1112
  @project_browser_state = nil
1113
+ @image_viewer_state = nil
1114
+ @terminal_image_protocol = nil
988
1115
  @project_browser_tree_paths = nil
989
1116
  @project_browser_tree = nil
990
1117
  @file_overlay_dismissed_token = nil
@@ -32,6 +32,7 @@ module Kward
32
32
  { name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[text|markdown|ruby]" },
33
33
  { name: "pty", description: "Run a command in an interactive PTY passthrough session.", argument_hint: "<command>" },
34
34
  { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name|worktree]" },
35
+ { name: "worktree", description: "Manage the active tab's linked Git worktree.", argument_hint: "[activate|detach|status|merge|merge abort|remove]" },
35
36
  { name: "status", description: "Show the current status message.", argument_hint: "" },
36
37
  { name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
37
38
  { name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
@@ -0,0 +1,45 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Forwards PTY output immediately and optionally keeps a bounded byte copy.
4
+ #
5
+ # The runner only depends on the sink's `write` method and optional `flush`
6
+ # method. Capture is deliberately byte-oriented so PTY chunk boundaries have
7
+ # no effect on the retained output.
8
+ class PassthroughPtyOutputSink
9
+ attr_reader :captured_output
10
+
11
+ def initialize(output:, max_capture_bytes: nil)
12
+ @output = output
13
+ @max_capture_bytes = max_capture_bytes
14
+ @captured_output = max_capture_bytes ? +"".b : nil
15
+ @truncated = false
16
+ end
17
+
18
+ def write(chunk)
19
+ @output.write(chunk)
20
+ capture(chunk)
21
+ end
22
+
23
+ def flush
24
+ @output.flush if @output.respond_to?(:flush)
25
+ end
26
+
27
+ def truncated?
28
+ @truncated
29
+ end
30
+
31
+ private
32
+
33
+ def capture(chunk)
34
+ return unless @captured_output
35
+
36
+ remaining = @max_capture_bytes - @captured_output.bytesize
37
+ if chunk.bytesize > remaining
38
+ @captured_output << chunk.byteslice(0, remaining) if remaining.positive?
39
+ @truncated = true
40
+ else
41
+ @captured_output << chunk
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,93 @@
1
+ require_relative "ansi"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Reduces safe, line-oriented PTY redraws to transcript-friendly text.
6
+ class PtyTranscriptNormalizer
7
+ HORIZONTAL_REDRAW_PATTERN = /\r|\e\[[0-9;]*[CDGK`]/.freeze
8
+
9
+ def self.normalize(text)
10
+ text.split("\n", -1).map do |line|
11
+ if line.match?(HORIZONTAL_REDRAW_PATTERN)
12
+ Line.new(line).render
13
+ else
14
+ ANSI.sanitize_transcript(line)
15
+ end
16
+ end.join("\n")
17
+ end
18
+
19
+ # Applies the horizontal cursor controls accepted by AdaptivePtyOutputSink
20
+ # without modeling a complete terminal screen.
21
+ class Line
22
+ def initialize(text)
23
+ @text = text
24
+ @cells = []
25
+ @cursor = 0
26
+ end
27
+
28
+ def render
29
+ ANSI.scan_escape_tokens(@text).each do |token|
30
+ token[:escape] ? apply_escape(token[:text]) : write_text(token[:text])
31
+ end
32
+ @cells.join.rstrip
33
+ end
34
+
35
+ private
36
+
37
+ def apply_escape(sequence)
38
+ final = sequence[-1]
39
+ return set_column(sequence) if final == "G" || final == "`"
40
+ return move_cursor(sequence, 1) if final == "C"
41
+ return move_cursor(sequence, -1) if final == "D"
42
+ return erase_line(sequence) if final == "K"
43
+ end
44
+
45
+ def set_column(sequence)
46
+ @cursor = [parameter(sequence, default: 1), 1].max - 1
47
+ end
48
+
49
+ def move_cursor(sequence, direction)
50
+ distance = [parameter(sequence, default: 1), 1].max
51
+ @cursor = [@cursor + (distance * direction), 0].max
52
+ end
53
+
54
+ def erase_line(sequence)
55
+ case parameter(sequence, default: 0)
56
+ when 1
57
+ fill_to_cursor
58
+ 0.upto(@cursor) { |index| @cells[index] = " " }
59
+ when 2
60
+ @cells.clear
61
+ else
62
+ @cells.slice!(@cursor..) if @cursor < @cells.length
63
+ end
64
+ end
65
+
66
+ def parameter(sequence, default:)
67
+ value = sequence[2...-1].to_s.split(";", 2).first.to_s
68
+ value.empty? ? default : value.to_i
69
+ end
70
+
71
+ def write_text(text)
72
+ text.each_char do |character|
73
+ case character
74
+ when "\r"
75
+ @cursor = 0
76
+ when "\b"
77
+ @cursor = [@cursor - 1, 0].max
78
+ when "\t"
79
+ @cursor = ((@cursor / 8) + 1) * 8
80
+ else
81
+ fill_to_cursor
82
+ @cells[@cursor] = character
83
+ @cursor += 1
84
+ end
85
+ end
86
+ end
87
+
88
+ def fill_to_cursor
89
+ @cells.concat([" "] * (@cursor - @cells.length)) if @cursor > @cells.length
90
+ end
91
+ end
92
+ end
93
+ end
@@ -235,7 +235,7 @@ module Kward
235
235
  def complete_login(login, code)
236
236
  raise "Missing authorization code" if code.to_s.empty?
237
237
 
238
- login.oauth.complete_login_flow(code: code, redirect_uri: login.redirect_uri, code_verifier: login.pkce[:verifier])
238
+ login.oauth.complete_login_flow(code: code, redirect_uri: login.redirect_uri, code_verifier: login.pkce[:verifier], state: login.state)
239
239
  login.status = "completed"
240
240
  @server.notify("auth/loginFinished", login_payload(login))
241
241
  rescue StandardError => e
@@ -610,7 +610,8 @@ module Kward
610
610
  },
611
611
  composer: {
612
612
  sessionDiff: { supported: false, reason: "interactiveComposerOnly" },
613
- copy: { supported: false, reason: "clientClipboardOwnedByUi" }
613
+ copy: { supported: false, reason: "clientClipboardOwnedByUi" },
614
+ editorPrompt: { supported: false, reason: "interactiveVibeEditorOnly" }
614
615
  },
615
616
  security: {
616
617
  workspaceMutationGuard: "none",
@@ -0,0 +1,87 @@
1
+ require "json"
2
+ require_relative "private_file"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Rebuildable lightweight summaries for persisted session event logs.
7
+ #
8
+ # Session JSONL files remain authoritative. Catalog entries are accepted only
9
+ # while their source fingerprint matches and may be deleted at any time.
10
+ class SessionCatalog
11
+ VERSION = 1
12
+ INDEX_DIRECTORY = ".index"
13
+ FILENAME = "sessions.json"
14
+
15
+ def initialize(session_dir:)
16
+ @path = File.join(session_dir, INDEX_DIRECTORY, FILENAME)
17
+ @entries = load_entries
18
+ @dirty = false
19
+ end
20
+
21
+ def fetch(path)
22
+ entry = @entries[entry_key(path)]
23
+ return nil unless entry.is_a?(Hash)
24
+ return nil unless entry["source"] == fingerprint(path)
25
+
26
+ entry["summary"] if entry["summary"].is_a?(Hash)
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ def write(path, summary, fingerprint: self.fingerprint(path))
32
+ @entries[entry_key(path)] = {
33
+ "source" => fingerprint,
34
+ "summary" => summary
35
+ }
36
+ @dirty = true
37
+ end
38
+
39
+ def remove(path)
40
+ @dirty = true if @entries.delete(entry_key(path))
41
+ end
42
+
43
+ def retain(paths)
44
+ keys = paths.to_h { |path| [entry_key(path), true] }
45
+ previous_size = @entries.size
46
+ @entries.delete_if { |key, _entry| !keys[key] }
47
+ @dirty = true if @entries.size != previous_size
48
+ end
49
+
50
+ def flush
51
+ return unless @dirty
52
+
53
+ PrivateFile.write_json(@path, {
54
+ "version" => VERSION,
55
+ "entries" => @entries
56
+ })
57
+ @dirty = false
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ def fingerprint(path)
63
+ stat = File.stat(path)
64
+ {
65
+ "size" => stat.size,
66
+ "inode" => stat.ino,
67
+ "mtimeSeconds" => stat.mtime.to_i,
68
+ "mtimeNanoseconds" => stat.mtime.nsec
69
+ }
70
+ end
71
+
72
+ private
73
+
74
+ def load_entries
75
+ data = JSON.parse(File.read(@path))
76
+ return {} unless data["version"] == VERSION && data["entries"].is_a?(Hash)
77
+
78
+ data["entries"]
79
+ rescue StandardError
80
+ {}
81
+ end
82
+
83
+ def entry_key(path)
84
+ File.basename(path)
85
+ end
86
+ end
87
+ end