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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +20 -2
- data/assets/shell-integration/tarazed.bash +44 -0
- data/assets/shell-integration/tarazed.fish +27 -0
- data/assets/shell-integration/tarazed.zsh +36 -0
- data/docs/adr/001-terminal-core-boundary.md +2 -2
- data/docs/adr/002-shell-integration.md +27 -0
- data/docs/adr/README.md +3 -0
- data/lib/tarazed/grid.rb +17 -5
- data/lib/tarazed/pty.rb +3 -3
- data/lib/tarazed/scrollback.rb +8 -2
- data/lib/tarazed/session.rb +21 -0
- data/lib/tarazed/shell_integration.rb +18 -0
- data/lib/tarazed/version.rb +1 -1
- data/lib/tarazed/vt.rb +95 -6
- data/lib/tarazed.rb +45 -0
- data/sig/tarazed.rbs +42 -2
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9aca479c6fefe50cf31a19a4d9f2ee2e9ccda43c41737accfc7d4297fd68df34
|
|
4
|
+
data.tar.gz: e877aa139fc517da68b0214e3b76e9f0b87d7d8277cc096370c451153a8463ca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
41
|
-
|
|
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
|
|
21
|
-
|
|
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
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)
|
|
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
|
|
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
|
data/lib/tarazed/scrollback.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/tarazed/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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:
|
|
89
|
+
rubygems_version: 3.6.9
|
|
84
90
|
specification_version: 4
|
|
85
91
|
summary: A pure Ruby terminal emulator core
|
|
86
92
|
test_files: []
|