tarazed 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a28326d4fd2fe32691844accc2f1c43100d77a356c69f9c24e86da2af8634cc
4
- data.tar.gz: f8c9ea79ea97aa1fc8a2ee4218846d0a6bf29a300bea85006b4b84617d6b477b
3
+ metadata.gz: 9aca479c6fefe50cf31a19a4d9f2ee2e9ccda43c41737accfc7d4297fd68df34
4
+ data.tar.gz: e877aa139fc517da68b0214e3b76e9f0b87d7d8277cc096370c451153a8463ca
5
5
  SHA512:
6
- metadata.gz: 3ce64bd1794a8393be042fbd0e8004d024b34e3bc477a3e8b9067ac2e345cfad572f7245332024389208672bd075e6e3509465b9e388a28e1e901038f61d12b9
7
- data.tar.gz: 3c7b4e9d94369ba16d7c870cc834d00b994bb67db87254ae2b326d305128fc4f1438959eb955d241083b30962f4cd49a0ae38b5f25c3af2a3a33e00cecbf5cce
6
+ metadata.gz: 264107d5d55ddb7f21f95c973c6211113eebe67e25b64c24c3c1b5810b31b93231b7e6297e9c5143fd39f3d3496f66e7539e8a43863e379cbdd0fefa4279fbf6
7
+ data.tar.gz: b6af96ac20098f3b52aa9642be6550aa35a4c0a37cc0ab9cf5775f86d0e717e819e3dcbb9dbb509f63fb324761ae1b4e2b4019ac9c7fb7a3717b0859b3104dbf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.2.0 - 2026-09-16
6
+
7
+ - Track OSC 133 command boundaries, input, output rows, exit status, and OSC 7 working directories in bounded history.
8
+ - Add a pump-oriented `Tarazed::Session` API and shell integration snippets for bash, zsh, and fish.
9
+
3
10
  ## 0.1.0 - 2026-09-16
4
11
 
5
12
  - Initial release.
data/README.md CHANGED
@@ -37,8 +37,26 @@ terminal.close
37
37
  ```
38
38
 
39
39
  ConPTY requires a supported 64-bit Windows release and does not fall back to a
40
- pipe-only console. Shell integration and the higher-level `Screen`, `Parser`,
41
- and `Session` APIs remain future work.
40
+ pipe-only console.
41
+
42
+ `Tarazed::Session` adds a pump-oriented API and bounded OSC 133 command
43
+ history. Command rows and output ranges use stable absolute history rows, and
44
+ OSC 7 updates the session working directory:
45
+
46
+ ```ruby
47
+ session = Tarazed::Session.new(command: ["/bin/bash"], columns: 80, rows: 24)
48
+ session.pump(timeout: 0.05)
49
+ session.commands.each do |command|
50
+ puts "#{command.exit_status}: #{command.input} (#{command.cwd})"
51
+ end
52
+ session.close
53
+ ```
54
+
55
+ Shell integration snippets for bash, zsh, and fish are packaged with the gem.
56
+ An embedding application can inject one into a new interactive shell with
57
+ `Tarazed::ShellIntegration.read(:bash)` (or `:zsh` / `:fish`). The snippets
58
+ emit prompt, input, execution, and completion markers without changing the
59
+ visible prompt.
42
60
 
43
61
  ## Development
44
62
 
@@ -0,0 +1,44 @@
1
+ # Tarazed shell integration for bash 3.2 and newer.
2
+ if [[ -z ${TARAZED_SHELL_INTEGRATION-} ]]; then
3
+ TARAZED_SHELL_INTEGRATION=1
4
+
5
+ __tarazed_urlencode() {
6
+ local LC_ALL=C value=$1 output= character code hex index=0
7
+ while (( index < ${#value} )); do
8
+ character=${value:index:1}
9
+ case $character in
10
+ [a-zA-Z0-9.~_/-]) output=$output$character ;;
11
+ *)
12
+ printf -v code '%d' "'$character"
13
+ printf -v hex '%%%02X' "$((code & 255))"
14
+ output=$output$hex
15
+ ;;
16
+ esac
17
+ (( index += 1 ))
18
+ done
19
+ printf '%s' "$output"
20
+ }
21
+
22
+ __tarazed_prompt_command() {
23
+ local command_status=$1
24
+ printf '\e]133;D;%s\a\e]7;file://%s%s\e\\\e]133;A\e\\' \
25
+ "$command_status" "${HOSTNAME-}" "$(__tarazed_urlencode "$PWD")"
26
+ __tarazed_command_active=0
27
+ }
28
+
29
+ PROMPT_COMMAND="__tarazed_prompt_status=\$?${PROMPT_COMMAND:+;$PROMPT_COMMAND};__tarazed_prompt_command \"\$__tarazed_prompt_status\""
30
+ PS1="${PS1-}\[\e]133;B\a\]"
31
+
32
+ if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
33
+ PS0="${PS0-}\[\e]133;C\a\]"
34
+ else
35
+ __tarazed_command_active=1
36
+ __tarazed_preexec() {
37
+ if (( ! __tarazed_command_active )); then
38
+ __tarazed_command_active=1
39
+ printf '\e]133;C\e\\'
40
+ fi
41
+ }
42
+ trap '__tarazed_preexec' DEBUG
43
+ fi
44
+ fi
@@ -0,0 +1,27 @@
1
+ # Tarazed shell integration for fish.
2
+ if not set -q TARAZED_SHELL_INTEGRATION
3
+ set -g TARAZED_SHELL_INTEGRATION 1
4
+
5
+ function __tarazed_cwd
6
+ set -l encoded (string escape --style=url -- $PWD)
7
+ set encoded (string replace -r '^%2[Ff]' '' -- $encoded)
8
+ printf '\e]7;file:///%s\e\\' $encoded
9
+ end
10
+
11
+ function __tarazed_preexec --on-event fish_preexec
12
+ printf '\e]133;C\e\\'
13
+ end
14
+
15
+ function __tarazed_postexec --on-event fish_postexec
16
+ set -l command_status $status
17
+ printf '\e]133;D;%s\a' $command_status
18
+ end
19
+
20
+ functions -c fish_prompt __tarazed_original_fish_prompt
21
+ function fish_prompt
22
+ __tarazed_cwd
23
+ printf '\e]133;A\e\\'
24
+ __tarazed_original_fish_prompt
25
+ printf '\e]133;B\e\\'
26
+ end
27
+ end
@@ -0,0 +1,36 @@
1
+ # Tarazed shell integration for zsh.
2
+ if [[ -z ${TARAZED_SHELL_INTEGRATION-} ]]; then
3
+ typeset -g TARAZED_SHELL_INTEGRATION=1
4
+
5
+ __tarazed_urlencode() {
6
+ local LC_ALL=C value=$1 output= character code hex
7
+ integer index=1
8
+ while (( index <= ${#value} )); do
9
+ character=${value[index]}
10
+ case $character in
11
+ [a-zA-Z0-9.~_/-]) output+=$character ;;
12
+ *)
13
+ printf -v code '%d' "'$character"
14
+ printf -v hex '%%%02X' "$((code & 255))"
15
+ output+=$hex
16
+ ;;
17
+ esac
18
+ (( index += 1 ))
19
+ done
20
+ print -rn -- "$output"
21
+ }
22
+
23
+ __tarazed_precmd() {
24
+ local command_status=$?
25
+ printf '\e]133;D;%s\a\e]7;file://%s%s\e\\\e]133;A\e\\' \
26
+ "$command_status" "${HOST-}" "$(__tarazed_urlencode "$PWD")"
27
+ }
28
+
29
+ __tarazed_preexec() {
30
+ printf '\e]133;C\e\\'
31
+ }
32
+
33
+ precmd_functions=(__tarazed_precmd ${precmd_functions:#__tarazed_precmd})
34
+ preexec_functions=(__tarazed_preexec ${preexec_functions:#__tarazed_preexec})
35
+ PS1="${PS1-}"$'%{\e]133;B\a%}'
36
+ fi
@@ -17,8 +17,8 @@ and Linux or a Fiddle-based ConPTY backend on 64-bit Windows. It depends
17
17
  directly on `unicode-display_width`; scrollback uses a bounded Array because
18
18
  the default 10,000-row limit does not justify a persistent tree dependency.
19
19
 
20
- Higher-level screen, parser, session, and shell integration APIs remain outside
21
- the extracted API.
20
+ Higher-level screen and parser APIs remain outside the extracted API. Session
21
+ and shell integration are deferred to ADR 002.
22
22
 
23
23
  ## Consequences
24
24
 
@@ -0,0 +1,27 @@
1
+ # ADR 002: Derive shell history from terminal protocols
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ Embedders need command boundaries, exit status, and working directories without
9
+ parsing shell prompts or depending on a particular shell. Terminal output is an
10
+ untrusted, chunked byte stream, and retained history must stay bounded.
11
+
12
+ ## Decision
13
+
14
+ The existing VT parser consumes OSC 133 and OSC 7 alongside other bounded OSC
15
+ sequences. Completed commands are immutable values retained in a count-bounded
16
+ history. Rows are the grid's cumulative history coordinates, so later
17
+ scrollback eviction does not change recorded ranges. Malformed markers and
18
+ working-directory values do not enter the model.
19
+
20
+ `Session` remains a thin PTY facade. Shell-specific behavior stays in packaged
21
+ bash, zsh, and fish snippets instead of the emulator core.
22
+
23
+ ## Consequences
24
+
25
+ Applications can navigate command output consistently on POSIX and ConPTY.
26
+ Command input is reconstructed from retained terminal cells and is therefore
27
+ limited by scrollback and a 64 KiB per-command cap.
data/docs/adr/README.md CHANGED
@@ -1,3 +1,6 @@
1
1
  # Architecture decision records
2
2
 
3
3
  These records document Tarazed's durable boundaries.
4
+
5
+ - [001: Keep terminal state independent of renderers](001-terminal-core-boundary.md)
6
+ - [002: Derive shell history from terminal protocols](002-shell-integration.md)
data/lib/tarazed/grid.rb CHANGED
@@ -33,6 +33,8 @@ module Tarazed
33
33
  def alternate? = !@alternate.nil?
34
34
  def lines = cells.map { |row| row.map(&:text).join.rstrip }
35
35
  def text = lines.join("\n")
36
+ def history_row = scrollback.total + cursor_y
37
+ def wrap_pending? = @wrap_pending
36
38
 
37
39
  def put(char)
38
40
  width = self.class.width(char)
@@ -45,6 +47,7 @@ module Tarazed
45
47
  width = [self.class.width(joined), columns].min
46
48
  if previous_x + width > columns && autowrap
47
49
  cells[cursor_y][previous_x] = blank
50
+ mark_wrapped(cells[cursor_y])
48
51
  carriage_return
49
52
  linefeed
50
53
  return put(joined)
@@ -66,6 +69,7 @@ module Tarazed
66
69
  width = 1 if width > columns
67
70
  if @wrap_pending || (width == 2 && cursor_x == columns - 1)
68
71
  if autowrap
72
+ mark_wrapped(cells[cursor_y])
69
73
  @cursor_x = 0
70
74
  linefeed
71
75
  elsif width == 2
@@ -104,6 +108,8 @@ module Tarazed
104
108
  end
105
109
  row.concat(Array.new(columns - row.length) { blank })
106
110
  end
111
+ @cells.each_with_index { |row, index| mark_wrapped(row) if index < @cells.length - 1 }
112
+ scrollback.advance(complete_rows - retained_complete_rows)
107
113
  @cursor_x = remainder.zero? ? columns - 1 : remainder
108
114
  @cursor_y = rows - 1
109
115
  @wrap_pending = remainder.zero?
@@ -283,10 +289,11 @@ module Tarazed
283
289
  validate_dimensions(columns, rows)
284
290
  previous_columns = @columns
285
291
  @columns, @rows = columns, rows
292
+ primary = alternate? ? @alternate : cells
286
293
  [cells, @alternate].compact.each do |screen|
287
294
  while screen.length > rows
288
295
  removed = screen.shift
289
- scrollback.push(removed) unless alternate?
296
+ scrollback.push(removed) if screen.equal?(primary)
290
297
  @cursor_y -= 1 if screen.equal?(cells)
291
298
  end
292
299
  screen << blank_row while screen.length < rows
@@ -307,13 +314,17 @@ module Tarazed
307
314
  def selection(start, finish, history: false)
308
315
  source = history ? scrollback.to_a + cells : cells
309
316
  start, finish = finish, start if ([start[1], start[0]] <=> [finish[1], finish[0]]) == 1
310
- (start[1]..finish[1]).map do |row|
311
- next "" unless source[row]
317
+ parts = (start[1]..finish[1]).map do |row|
318
+ next ["", false] unless source[row]
312
319
 
313
320
  first = row == start[1] ? start[0] : 0
314
321
  last = row == finish[1] ? finish[0] : columns
315
- source[row][first...last].to_a.map(&:text).join.rstrip
316
- end.join("\n")
322
+ [source[row][first...last].to_a.map(&:text).join.rstrip, source[row].instance_variable_get(:@wrapped)]
323
+ end
324
+ parts.each_with_index.each_with_object(+"") do |((part, _), index), text|
325
+ text << "\n" if index.positive? && !parts[index - 1][1]
326
+ text << part
327
+ end
317
328
  end
318
329
 
319
330
  def links(row)
@@ -359,6 +370,7 @@ module Tarazed
359
370
 
360
371
  def blank = Cell.new(text: " ", width: 1, foreground: foreground, background: background, attributes: {}.freeze)
361
372
  def blank_row = Array.new(columns) { blank }
373
+ def mark_wrapped(row) = row.instance_variable_set(:@wrapped, true)
362
374
 
363
375
  def clear_wide(row, column)
364
376
  return unless column.between?(0, columns - 1)
data/lib/tarazed/pty.rb CHANGED
@@ -8,7 +8,7 @@ module Tarazed
8
8
  attr_reader :reader, :writer, :pid, :grid, :vt, :status, :initial_cwd, :command_name
9
9
 
10
10
  def initialize(command: ENV.fetch("SHELL", "/bin/sh"), cwd: Dir.pwd, columns: 80, rows: 24, env: {},
11
- scrollback: 10_000, queue_limit_bytes: 8_388_608)
11
+ scrollback: 10_000, queue_limit_bytes: 8_388_608, command_history_limit: 1_000, on_command: nil)
12
12
  unless queue_limit_bytes.is_a?(Integer) && queue_limit_bytes.positive?
13
13
  raise ArgumentError, "terminal queue limit must be a positive integer"
14
14
  end
@@ -21,7 +21,7 @@ module Tarazed
21
21
  command = nil if command == "/bin/sh"
22
22
  @native = Windows::ConPTY.new(command: command, cwd: cwd, columns: columns, rows: rows, env: env)
23
23
  @pid = @native.pid
24
- @vt = VT.new(grid) { |bytes| write(bytes) }
24
+ @vt = VT.new(grid, command_limit: command_history_limit, on_command: on_command) { |bytes| write(bytes) }
25
25
  return
26
26
  end
27
27
 
@@ -34,7 +34,7 @@ module Tarazed
34
34
  @reader.binmode
35
35
  @writer.binmode
36
36
  @writer.sync = true
37
- @vt = VT.new(grid) { |bytes| write(bytes) }
37
+ @vt = VT.new(grid, command_limit: command_history_limit, on_command: on_command) { |bytes| write(bytes) }
38
38
  resize(columns: columns, rows: rows)
39
39
  @queue, @queue_bytes, @queue_limit_bytes = [], 0, queue_limit_bytes
40
40
  @queue_lock, @queue_ready = Mutex.new, ConditionVariable.new
@@ -4,12 +4,13 @@ module Tarazed
4
4
  class Scrollback
5
5
  include Enumerable
6
6
 
7
- attr_reader :limit
7
+ attr_reader :limit, :total
8
8
 
9
9
  def initialize(limit)
10
10
  raise ArgumentError, "scrollback limit must be nonnegative" unless limit.is_a?(Integer) && limit >= 0
11
11
 
12
12
  @limit = limit
13
+ @total = 0
13
14
  clear
14
15
  end
15
16
 
@@ -20,10 +21,15 @@ module Tarazed
20
21
  def each(&block) = block ? @rows.each(&block) : enum_for(__method__)
21
22
 
22
23
  def push(cells)
24
+ advance(1)
23
25
  return if limit.zero?
24
26
 
25
27
  @rows.shift if @rows.length == limit
26
- @rows << cells.map { |cell| cell.dup.tap { |copy| copy.text = copy.text.dup.freeze }.freeze }.freeze
28
+ row = cells.map { |cell| cell.dup.tap { |copy| copy.text = copy.text.dup.freeze }.freeze }
29
+ row.instance_variable_set(:@wrapped, true) if cells.instance_variable_get(:@wrapped)
30
+ @rows << row.freeze
27
31
  end
32
+
33
+ def advance(count) = @total += count
28
34
  end
29
35
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarazed
4
+ class Session < PTY
5
+ def initialize(command:, env: {}, cwd: Dir.pwd, columns: 80, rows: 24, scrollback_limit: 10_000,
6
+ command_history_limit: 1_000, queue_limit_bytes: 8_388_608, on_command: nil)
7
+ super(command: command, env: env, cwd: cwd, columns: columns, rows: rows, scrollback: scrollback_limit,
8
+ command_history_limit: command_history_limit, queue_limit_bytes: queue_limit_bytes, on_command: on_command)
9
+ end
10
+
11
+ def screen = grid
12
+ def commands = vt.commands
13
+ def cwd = vt.cwd || initial_cwd
14
+ def input(bytes) = write(bytes)
15
+
16
+ def pump(timeout: 0)
17
+ bytes = read(timeout: timeout)
18
+ !bytes.nil? && !bytes.empty?
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarazed
4
+ module ShellIntegration
5
+ NAMES = {bash: "tarazed.bash", zsh: "tarazed.zsh", fish: "tarazed.fish"}.freeze
6
+ private_constant :NAMES
7
+
8
+ module_function
9
+
10
+ def path(shell)
11
+ name = File.basename(shell.to_s).to_sym
12
+ file = NAMES.fetch(name) { raise ArgumentError, "unsupported shell: #{shell}" }
13
+ File.expand_path("../../assets/shell-integration/#{file}", __dir__)
14
+ end
15
+
16
+ def read(shell) = File.binread(path(shell))
17
+ end
18
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tarazed
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/tarazed/vt.rb CHANGED
@@ -11,9 +11,17 @@ module Tarazed
11
11
 
12
12
  attr_reader :grid, :title, :cwd, :modes, :replies, :bell_count
13
13
 
14
- def initialize(grid = Grid.new, &reply)
14
+ def initialize(grid = Grid.new, command_limit: 1_000, on_command: nil, &reply)
15
+ unless command_limit.is_a?(Integer) && command_limit >= 0
16
+ raise ArgumentError, "command history limit must be a nonnegative integer"
17
+ end
18
+
15
19
  @grid = grid
16
20
  @reply = reply
21
+ @on_command = on_command
22
+ @command_limit = command_limit
23
+ @commands = []
24
+ @next_command_id = 1
17
25
  @replies = []
18
26
  @input = +"".b
19
27
  @state = :ground
@@ -24,6 +32,8 @@ module Tarazed
24
32
  @bell_count = 0
25
33
  end
26
34
 
35
+ def commands = @commands.dup.freeze
36
+
27
37
  # The parser retains incomplete UTF-8 and control sequences between reads.
28
38
  def feed(bytes)
29
39
  @input << bytes.b
@@ -413,22 +423,101 @@ module Tarazed
413
423
  end
414
424
 
415
425
  def osc(sequence)
416
- command, payload = sequence.force_encoding(Encoding::UTF_8).scrub.split(";", 2)
426
+ text = sequence.dup.force_encoding(Encoding::UTF_8)
427
+ valid_utf8 = text.valid_encoding?
428
+ command, payload = text.scrub.split(";", 2)
417
429
  return unless payload
418
430
 
419
431
  case command
420
432
  when "0", "2" then @title = payload
421
- when "7"
422
- uri = URI.parse(payload)
423
- @cwd = URI.decode_uri_component(uri.path) if uri.scheme == "file" && uri.path&.start_with?("/")
433
+ when "7" then update_cwd(payload) if valid_utf8
424
434
  when "8"
425
435
  _, url = payload.split(";", 2)
426
436
  grid.hyperlink = url.nil? || url.empty? ? nil : url.freeze
437
+ when "133" then shell_marker(payload)
438
+ end
439
+ end
440
+
441
+ def update_cwd(payload)
442
+ match = /\Afile:\/\/([A-Za-z0-9._~%\-:\[\]]*)(\/.*)\z/.match(payload)
443
+ return unless match
444
+
445
+ path = URI.decode_uri_component(match[2])
446
+ return unless path.valid_encoding? && !path.match?(/[\x00-\x1f\x7f]/)
447
+
448
+ if Gem.win_platform?
449
+ drive_path = /\A\/[A-Za-z]:\//.match?(path)
450
+ path = path.delete_prefix("/") if drive_path
451
+ path = "//#{match[1]}#{path}" if !drive_path && !match[1].empty?
427
452
  end
428
- rescue URI::InvalidURIError, ArgumentError
453
+ @cwd = path.freeze
454
+ rescue ArgumentError
429
455
  nil
430
456
  end
431
457
 
458
+ def shell_marker(payload)
459
+ case payload
460
+ when "A"
461
+ @pending_command = {id: @next_command_id, prompt_row: grid.history_row}
462
+ @next_command_id += 1
463
+ when "B"
464
+ if @pending_command && !@pending_command[:started_at]
465
+ @pending_command[:input_start] ||= grid.wrap_pending? ? [0, grid.history_row + 1] :
466
+ [grid.cursor_x, grid.history_row]
467
+ end
468
+ when "C"
469
+ start_command if @pending_command && !@pending_command[:started_at]
470
+ else
471
+ finish_command(payload.delete_prefix("D;")) if @pending_command && payload.start_with?("D;")
472
+ end
473
+ end
474
+
475
+ def start_command
476
+ @pending_command[:input] = command_input(@pending_command[:input_start])
477
+ @pending_command[:output_start] = grid.history_row
478
+ @pending_command[:started_at] = Time.now.freeze
479
+ @pending_command[:cwd] = cwd&.dup&.freeze
480
+ end
481
+
482
+ def finish_command(value)
483
+ return unless @pending_command[:started_at]
484
+ return unless /\A\d{1,10}\z/.match?(value)
485
+
486
+ status = value.to_i
487
+ return if status > 0xffffffff
488
+
489
+ pending = @pending_command
490
+ finished_at = Time.now.freeze
491
+ output_range = pending[:output_start] && (pending[:output_start]..grid.history_row).freeze
492
+ command = Command.new(id: pending[:id], prompt_row: pending[:prompt_row], input: (pending[:input] || "").freeze,
493
+ output_range: output_range, exit_status: status, started_at: pending[:started_at], finished_at: finished_at,
494
+ cwd: pending[:cwd] || cwd)
495
+ @pending_command = nil
496
+ @commands.shift if @commands.length == @command_limit && @command_limit.positive?
497
+ @commands << command if @command_limit.positive?
498
+ @on_command&.call(command)
499
+ end
500
+
501
+ def command_input(start)
502
+ return "" unless start
503
+ return "" if start[1] > grid.history_row
504
+
505
+ base = grid.scrollback.total - grid.scrollback.length
506
+ first = [start[1] - base, 0].max
507
+ last = grid.history_row - base
508
+ return "" if last.negative?
509
+
510
+ finish = grid.wrap_pending? ? grid.columns : grid.cursor_x
511
+ first_column = start[1] < base ? 0 : start[0]
512
+ input = grid.selection([first_column, first], [finish, last], history: true).sub(/\n+\z/, "")
513
+ if input.bytesize > 65_536
514
+ # ponytail: terminal cells are the source of truth; add explicit command metadata if 64 KiB commands matter.
515
+ input = input.byteslice(0, 65_536)
516
+ input = input.byteslice(0, input.bytesize - 1) until input.valid_encoding?
517
+ end
518
+ input
519
+ end
520
+
432
521
  def dcs(sequence)
433
522
  return unless sequence.start_with?("$q")
434
523
 
data/lib/tarazed.rb CHANGED
@@ -9,4 +9,49 @@ require_relative "tarazed/pty"
9
9
 
10
10
  module Tarazed
11
11
  class Error < StandardError; end
12
+
13
+ module Value
14
+ module_function
15
+
16
+ def define(*members)
17
+ return Data.define(*members) if defined?(Data)
18
+
19
+ Struct.new(*members) do
20
+ members.each { |member| undef_method("#{member}=") }
21
+
22
+ def initialize(*values, **keywords)
23
+ if keywords.empty?
24
+ raise ArgumentError, "wrong number of arguments" unless values.length == self.class.members.length
25
+
26
+ super(*values)
27
+ else
28
+ raise ArgumentError, "cannot mix positional and keyword arguments" unless values.empty?
29
+
30
+ missing = self.class.members - keywords.keys
31
+ unknown = keywords.keys - self.class.members
32
+ raise ArgumentError, "missing keyword: #{missing.first.inspect}" unless missing.empty?
33
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
34
+
35
+ super(*self.class.members.map { |member| keywords.fetch(member) })
36
+ end
37
+ freeze
38
+ end
39
+
40
+ def with(**changes)
41
+ return self if changes.empty?
42
+
43
+ unknown = changes.keys - self.class.members
44
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
45
+
46
+ self.class.new(**to_h.merge(changes))
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ Command = Value.define(:id, :prompt_row, :input, :output_range, :exit_status, :started_at, :finished_at, :cwd)
53
+ private_constant :Value
12
54
  end
55
+
56
+ require_relative "tarazed/shell_integration"
57
+ require_relative "tarazed/session"
data/sig/tarazed.rbs CHANGED
@@ -8,6 +8,24 @@ module Tarazed
8
8
  type attributes = Hash[Symbol, bool | Integer | [Integer, Integer, Integer]]
9
9
  type mouse_action = :press | :move | :release
10
10
 
11
+ class Command
12
+ attr_reader id: Integer
13
+ attr_reader prompt_row: Integer
14
+ attr_reader input: String
15
+ attr_reader output_range: Range[Integer]?
16
+ attr_reader exit_status: Integer
17
+ attr_reader started_at: Time?
18
+ attr_reader finished_at: Time
19
+ attr_reader cwd: String?
20
+
21
+ def self.new: (Integer id, Integer prompt_row, String input, Range[Integer]? output_range, Integer exit_status,
22
+ Time? started_at, Time finished_at, String? cwd) -> instance
23
+ | (id: Integer, prompt_row: Integer, input: String, output_range: Range[Integer]?,
24
+ exit_status: Integer, started_at: Time?, finished_at: Time, cwd: String?) -> instance
25
+ def with: (?id: Integer, ?prompt_row: Integer, ?input: String, ?output_range: Range[Integer]?,
26
+ ?exit_status: Integer, ?started_at: Time?, ?finished_at: Time, ?cwd: String?) -> Command
27
+ end
28
+
11
29
  class Cell < Struct[untyped]
12
30
  attr_accessor text: String
13
31
  attr_accessor width: Integer
@@ -24,6 +42,7 @@ module Tarazed
24
42
  include Enumerable[Array[Cell]]
25
43
 
26
44
  attr_reader limit: Integer
45
+ attr_reader total: Integer
27
46
 
28
47
  def initialize: (Integer limit) -> void
29
48
  def clear: () -> Array[Array[Cell]]
@@ -33,6 +52,7 @@ module Tarazed
33
52
  def each: () -> Enumerator[Array[Cell], Array[Array[Cell]]]
34
53
  | () { (Array[Cell]) -> void } -> Array[Array[Cell]]
35
54
  def push: (Array[Cell] cells) -> Array[Array[Cell]]?
55
+ def advance: (Integer count) -> Integer
36
56
  end
37
57
 
38
58
  class Grid
@@ -60,6 +80,8 @@ module Tarazed
60
80
  def alternate?: () -> bool
61
81
  def lines: () -> Array[String]
62
82
  def text: () -> String
83
+ def history_row: () -> Integer
84
+ def wrap_pending?: () -> bool
63
85
  def put: (String char) -> void
64
86
  def put_ascii: (String text) -> void
65
87
  def self.width: (String char) -> Integer
@@ -101,7 +123,8 @@ module Tarazed
101
123
  attr_reader replies: Array[String]
102
124
  attr_reader bell_count: Integer
103
125
 
104
- def initialize: (?Grid grid) ?{ (String) -> void } -> void
126
+ def initialize: (?Grid grid, ?command_limit: Integer, ?on_command: ^(Command) -> untyped) ?{ (String) -> void } -> void
127
+ def commands: () -> Array[Command]
105
128
  def feed: (String bytes) -> self
106
129
  def paste: (String text) -> String
107
130
  def mouse: (button: Symbol | Integer | nil, column: Integer, row: Integer, ?action: mouse_action,
@@ -120,7 +143,8 @@ module Tarazed
120
143
  attr_reader command_name: String
121
144
 
122
145
  def initialize: (?command: String | Array[String], ?cwd: String, ?columns: Integer, ?rows: Integer,
123
- ?env: Hash[String, String?], ?scrollback: Integer, ?queue_limit_bytes: Integer) -> void
146
+ ?env: Hash[String, String?], ?scrollback: Integer, ?queue_limit_bytes: Integer,
147
+ ?command_history_limit: Integer, ?on_command: ^(Command) -> untyped) -> void
124
148
  def read: (?timeout: Numeric, ?max_bytes: Integer, ?max_seconds: Numeric?) -> String?
125
149
  def pending?: () -> bool
126
150
  def write: (String bytes) -> Integer
@@ -136,4 +160,20 @@ module Tarazed
136
160
  def close: () -> self
137
161
  end
138
162
 
163
+ class Session < PTY
164
+ def initialize: (command: String | Array[String], ?env: Hash[String, String?], ?cwd: String,
165
+ ?columns: Integer, ?rows: Integer, ?scrollback_limit: Integer, ?command_history_limit: Integer,
166
+ ?queue_limit_bytes: Integer, ?on_command: ^(Command) -> untyped) -> void
167
+ def screen: () -> Grid
168
+ def commands: () -> Array[Command]
169
+ def cwd: () -> String
170
+ def input: (String bytes) -> Integer
171
+ def pump: (?timeout: Numeric) -> bool
172
+ end
173
+
174
+ module ShellIntegration
175
+ def self.path: (String | Symbol shell) -> String
176
+ def self.read: (String | Symbol shell) -> String
177
+ end
178
+
139
179
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tarazed
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -46,14 +46,20 @@ files:
46
46
  - CHANGELOG.md
47
47
  - LICENSE.txt
48
48
  - README.md
49
+ - assets/shell-integration/tarazed.bash
50
+ - assets/shell-integration/tarazed.fish
51
+ - assets/shell-integration/tarazed.zsh
49
52
  - docs/adr/000-template.md
50
53
  - docs/adr/001-terminal-core-boundary.md
54
+ - docs/adr/002-shell-integration.md
51
55
  - docs/adr/README.md
52
56
  - lib/tarazed.rb
53
57
  - lib/tarazed/cell.rb
54
58
  - lib/tarazed/grid.rb
55
59
  - lib/tarazed/pty.rb
56
60
  - lib/tarazed/scrollback.rb
61
+ - lib/tarazed/session.rb
62
+ - lib/tarazed/shell_integration.rb
57
63
  - lib/tarazed/version.rb
58
64
  - lib/tarazed/vt.rb
59
65
  - lib/tarazed/windows/conpty.rb
@@ -80,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
86
  - !ruby/object:Gem::Version
81
87
  version: '0'
82
88
  requirements: []
83
- rubygems_version: 4.0.16
89
+ rubygems_version: 3.6.9
84
90
  specification_version: 4
85
91
  summary: A pure Ruby terminal emulator core
86
92
  test_files: []