ask-terminal 0.2.1
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 +7 -0
- data/CHANGELOG.md +62 -0
- data/LICENSE +21 -0
- data/README.md +107 -0
- data/exe/ask +6 -0
- data/exe/ask-terminal +7 -0
- data/lib/ask/terminal/cli.rb +74 -0
- data/lib/ask/terminal/client.rb +7 -0
- data/lib/ask/terminal/host.rb +7 -0
- data/lib/ask/terminal/renderer.rb +107 -0
- data/lib/ask/terminal/repl.rb +227 -0
- data/lib/ask/terminal/version.rb +7 -0
- data/lib/ask/terminal.rb +10 -0
- data/lib/ask-terminal.rb +3 -0
- metadata +129 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a2494b8eb2ebc4400cbb63467cf36a11a2dd98d5707a3d7130449477967ee338
|
|
4
|
+
data.tar.gz: d5d6b77593f1e70840b68a828b9bfaa400cd8272fb682be9ea25e3699f490bf1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 123439471a5bc89122297402bedc6115bef1f47cb04124e220e9d348ef04ffd414eb32d2abc8ce686704d64a49b8432a5d76f8f7ebcd52a1c59ca5e54542c3f3
|
|
7
|
+
data.tar.gz: 19ef2976881a3a74c9e573b727e135dc7f72947739c0fbee530ad027c88e902864734cbe497e54a7de5f27fb924799510cce6ae84f1b6af0836374011e31ae1b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to ask-terminal are documented here, following
|
|
4
|
+
the keep-a-changelog format.
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-08-12
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **The protocol client lives in `ask-session-protocol`** (the retired
|
|
13
|
+
ask-session-client merged into it) — `Client` and `Host` are there;
|
|
14
|
+
ask-terminal keeps thin aliases
|
|
15
|
+
(`Ask::Terminal::Client` = `Ask::SessionProtocol::Client`), so existing
|
|
16
|
+
references keep working.
|
|
17
|
+
## [0.1.2] - 2026-08-11
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Spawned hosts are multi-client attachable by default** — `ask` starts
|
|
22
|
+
the host with a unix socket (unique per spawn under
|
|
23
|
+
`~/.ask-app-server/sockets/`, or `ASK_APP_SERVER_SOCKET` when set) and
|
|
24
|
+
prints the path on startup, so the web console, bots, or a second
|
|
25
|
+
terminal can attach to the same live sessions. `Host.spawn(socket:)`
|
|
26
|
+
controls it: `true` (default), a custom path, or `false` to disable.
|
|
27
|
+
|
|
28
|
+
## [0.1.1] - 2026-08-11
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **The command is now `ask`** — the terminal client's primary executable
|
|
33
|
+
is the short brand name; `ask-terminal` remains installed as a long
|
|
34
|
+
alias. Both behave identically. CLI logic moved into
|
|
35
|
+
`Ask::Terminal::CLI`.
|
|
36
|
+
|
|
37
|
+
## [0.1.0] - 2026-08-11
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **`Client`** — a thin NDJSON protocol client over the host's stdio or a
|
|
42
|
+
unix socket: request/response correlation by id, notification queue,
|
|
43
|
+
protocol error handling, and connection-close semantics (sentinel
|
|
44
|
+
notification + `ConnectionClosed` on pending requests).
|
|
45
|
+
- **`Host`** — spawns `ask-app-server` as a subprocess (`Gem.bin_path`,
|
|
46
|
+
PATH fallback) or attaches to a running host over its unix socket.
|
|
47
|
+
- **`Renderer`** — canonical session events rendered as ANSI terminal
|
|
48
|
+
output: streaming model/thinking deltas, tool lifecycle with duration
|
|
49
|
+
and truncated output, approval and plan interactions, todos progress,
|
|
50
|
+
turn lifecycle.
|
|
51
|
+
- **`Repl`** — the interactive loop: prompt → `session/send` → stream
|
|
52
|
+
events → resolve approvals (y/N) and plan proposals (y/N) inline via the
|
|
53
|
+
canonical `interaction/*` and `plan/*` methods → prompt again. Commands:
|
|
54
|
+
`/approve`, `/reject`, `/plan-approve`, `/plan-reject`, `/abort`,
|
|
55
|
+
`/status`, `/sessions`, `/exit`; Ctrl-C aborts the running turn.
|
|
56
|
+
- **`ask-terminal` executable** — `--host stdio|socket`, `--socket PATH`,
|
|
57
|
+
`--workspace`, `--model`, `--approval`.
|
|
58
|
+
- **Test suite** — 37 tests: protocol client against a scripted peer,
|
|
59
|
+
renderer coverage, repl interaction flows (approval/plan/commands), and
|
|
60
|
+
end-to-end integration over a real in-process SocketServer host
|
|
61
|
+
(handshake, session lifecycle, approval streaming, two clients sharing
|
|
62
|
+
one session).
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# ask-terminal
|
|
2
|
+
|
|
3
|
+
**The terminal client of the ask ecosystem.** An interactive terminal
|
|
4
|
+
coding agent that speaks the canonical
|
|
5
|
+
[ask-session-protocol](https://github.com/ask-rb/ask-session-protocol) and
|
|
6
|
+
nothing else — no runtime, no sessions, no tools. The host
|
|
7
|
+
(ask-app-server) owns all of it; this client just renders events and
|
|
8
|
+
sends requests.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
ask> fix the failing test in spec/models
|
|
12
|
+
› bash grep -rn "failing" spec/
|
|
13
|
+
└ bash (120ms)
|
|
14
|
+
...
|
|
15
|
+
── turn completed ──
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Why this exists
|
|
19
|
+
|
|
20
|
+
The ask ecosystem's architecture is *one session host, one canonical
|
|
21
|
+
protocol, many thin clients*. ask-terminal is the first client and the
|
|
22
|
+
proof: it can attach to the same live sessions as the web console, a bot,
|
|
23
|
+
or an IDE — each client receives every event exactly once via its own
|
|
24
|
+
delivery cursor, and any client can resolve an approval or a plan proposal
|
|
25
|
+
by id.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Two ways to reach a host (the command is `ask`; `ask-terminal` is the long
|
|
30
|
+
alias — both are installed by the gem):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Spawn a host over stdio — it also exposes a unix socket for
|
|
34
|
+
# multi-client attach, and prints the path on startup:
|
|
35
|
+
ask
|
|
36
|
+
# host socket: ~/.ask-app-server/sockets/4f3a9c2b1d0e8f7a.sock
|
|
37
|
+
|
|
38
|
+
# Attach to an already-running host over its unix socket (multi-client)
|
|
39
|
+
ask --socket ~/.ask-app-server/sockets/4f3a9c2b1d0e8f7a.sock
|
|
40
|
+
|
|
41
|
+
# Options
|
|
42
|
+
ask --workspace ~/code/myapp --model deepseek-v4-flash --approval on_request
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Every spawned host is attachable: the web console, a bot, or a second
|
|
46
|
+
terminal can connect to the same live sessions (`ASK_APP_SERVER_SOCKET`
|
|
47
|
+
pins the path; `Host.spawn(socket: false)` disables it).
|
|
48
|
+
|
|
49
|
+
| Flag | Default | Description |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `--host MODE` | `stdio` | `stdio` (spawn the host) or `socket` (attach) |
|
|
52
|
+
| `--socket PATH` | — | Attach to a running host (implies `--host socket`) |
|
|
53
|
+
| `-w, --workspace DIR` | current dir | Workspace for `session/create` |
|
|
54
|
+
| `--model MODEL` | host default | Model identifier |
|
|
55
|
+
| `--approval MODE` | `on_request` | `on_request` \| `off` \| `auto` |
|
|
56
|
+
|
|
57
|
+
## Interactive commands
|
|
58
|
+
|
|
59
|
+
| Command | Description |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `/approve [id]` | Approve a pending approval (all when no id) |
|
|
62
|
+
| `/reject [id]` | Reject a pending approval (all when no id) |
|
|
63
|
+
| `/plan-approve` · `/plan-reject` | Resolve the pending plan proposal |
|
|
64
|
+
| `/abort` | Abort the running turn |
|
|
65
|
+
| `/status` | Show workspace and pending interactions |
|
|
66
|
+
| `/sessions` | List host sessions |
|
|
67
|
+
| `/exit` | Quit |
|
|
68
|
+
|
|
69
|
+
Approvals and plan proposals also resolve inline: when the host pauses a
|
|
70
|
+
turn, the terminal asks `Approve bash (act_1)? [y/N]` and resolves through
|
|
71
|
+
the canonical `interaction/approve` / `plan/approve` methods — the exact
|
|
72
|
+
requests any other client would make.
|
|
73
|
+
|
|
74
|
+
## Architecture
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
┌─────────────────────────────────────────────────────────┐
|
|
78
|
+
│ ask-app-server (THE HOST — separate process) │
|
|
79
|
+
│ sessions · events · approvals · plan · tools │
|
|
80
|
+
└──────────────▲───────────────────────────┬─────────────┘
|
|
81
|
+
│ canonical protocol │
|
|
82
|
+
┌───────────┴──────────┐ ┌───────────▼─────────────┐
|
|
83
|
+
│ ask-terminal │ │ web console · bots · │
|
|
84
|
+
│ (this client) │ │ IDE — any client │
|
|
85
|
+
└──────────────────────┘ └─────────────────────────┘
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
* `Client` — NDJSON protocol over the host's stdio or a unix socket;
|
|
89
|
+
request/response correlation, notification queue, connection-close
|
|
90
|
+
handling.
|
|
91
|
+
* `Host` — spawns `ask-app-server` (`Gem.bin_path`) or connects to its
|
|
92
|
+
socket.
|
|
93
|
+
* `Renderer` — canonical events → ANSI output (streaming deltas, tool
|
|
94
|
+
lifecycle, approvals, plans, todos).
|
|
95
|
+
* `Repl` — the interactive loop: prompt → `session/send` → stream events
|
|
96
|
+
→ resolve interactions inline → prompt again.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bundle install
|
|
102
|
+
bundle exec rake test
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
data/exe/ask
ADDED
data/exe/ask-terminal
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Terminal
|
|
7
|
+
# Command-line entry for the terminal client. Installed as `ask`
|
|
8
|
+
# (primary) and `ask-terminal` (long alias).
|
|
9
|
+
#
|
|
10
|
+
# ask spawn the host over stdio and attach
|
|
11
|
+
# ask --socket PATH attach to a running host
|
|
12
|
+
# ask -w DIR --model M options for session/create
|
|
13
|
+
class CLI
|
|
14
|
+
# The short command name (used in banners and help).
|
|
15
|
+
COMMAND_NAME = "ask"
|
|
16
|
+
|
|
17
|
+
def self.run!(args = ARGV)
|
|
18
|
+
new(args).run
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(args)
|
|
22
|
+
@args = args.dup
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
options = {
|
|
27
|
+
host: "stdio",
|
|
28
|
+
socket: nil,
|
|
29
|
+
workspace: Dir.pwd,
|
|
30
|
+
model: nil,
|
|
31
|
+
approval: "on_request"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
parser = OptionParser.new do |o|
|
|
35
|
+
o.banner = "Usage: #{COMMAND_NAME} [options]"
|
|
36
|
+
o.on("--host MODE", "stdio (spawn the host) or socket (attach) [stdio]") { |v| options[:host] = v }
|
|
37
|
+
o.on("--socket PATH", "Attach to a running host at PATH (implies --host socket)") { |v| options[:socket] = v }
|
|
38
|
+
o.on("-w", "--workspace DIR", "Workspace directory [current dir]") { |v| options[:workspace] = v }
|
|
39
|
+
o.on("--model MODEL", "Model identifier") { |v| options[:model] = v }
|
|
40
|
+
o.on("--approval MODE", "Approval mode: on_request | off | auto [on_request]") { |v| options[:approval] = v }
|
|
41
|
+
o.on("-h", "--help", "Show help") { puts o; return 0 }
|
|
42
|
+
end
|
|
43
|
+
parser.parse!(@args)
|
|
44
|
+
|
|
45
|
+
client =
|
|
46
|
+
if options[:socket] || options[:host] == "socket"
|
|
47
|
+
socket_path = options[:socket] || ENV["ASK_APP_SERVER_SOCKET"]
|
|
48
|
+
abort "a socket path is required (--socket PATH or ASK_APP_SERVER_SOCKET)" unless socket_path
|
|
49
|
+
Host.connect(socket_path)
|
|
50
|
+
else
|
|
51
|
+
spawned = Host.spawn
|
|
52
|
+
if spawned.socket_path
|
|
53
|
+
puts "host socket: #{spawned.socket_path}"
|
|
54
|
+
puts "attach with: ask --socket #{spawned.socket_path}"
|
|
55
|
+
end
|
|
56
|
+
spawned.client
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
repl = Repl.new(
|
|
60
|
+
client: client,
|
|
61
|
+
workspace: options[:workspace],
|
|
62
|
+
model: options[:model],
|
|
63
|
+
approval: options[:approval]
|
|
64
|
+
)
|
|
65
|
+
begin
|
|
66
|
+
repl.run
|
|
67
|
+
ensure
|
|
68
|
+
client.close
|
|
69
|
+
end
|
|
70
|
+
0
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Terminal
|
|
7
|
+
# Renders canonical session events as ANSI terminal output. Accepts
|
|
8
|
+
# either a wire hash ({ "type" =>, "seq" =>, "payload" => }) or an
|
|
9
|
+
# Ask::SessionProtocol::Events::Event.
|
|
10
|
+
module Renderer
|
|
11
|
+
# ANSI helpers.
|
|
12
|
+
DIM = "\e[2m"
|
|
13
|
+
CYAN = "\e[36m"
|
|
14
|
+
YELLOW = "\e[33m"
|
|
15
|
+
GREEN = "\e[32m"
|
|
16
|
+
RED = "\e[31m"
|
|
17
|
+
MAGENTA = "\e[35m"
|
|
18
|
+
RESET = "\e[0m"
|
|
19
|
+
|
|
20
|
+
# How many lines of tool output to show inline.
|
|
21
|
+
MAX_OUTPUT_LINES = 20
|
|
22
|
+
MAX_ARG_CHARS = 120
|
|
23
|
+
|
|
24
|
+
# Render one event to `io`. Streaming deltas (model.streaming,
|
|
25
|
+
# tool.delta) print without a trailing newline; everything else
|
|
26
|
+
# prints a complete line.
|
|
27
|
+
#
|
|
28
|
+
# @param event [Hash, Ask::SessionProtocol::Events::Event]
|
|
29
|
+
# @param io [IO] output sink
|
|
30
|
+
def self.render(event, io: $stdout)
|
|
31
|
+
hash = event.respond_to?(:to_h) ? event.to_h : event
|
|
32
|
+
payload = hash["payload"] || {}
|
|
33
|
+
|
|
34
|
+
case hash["type"]
|
|
35
|
+
when "model.streaming"
|
|
36
|
+
io.print payload["delta"].to_s
|
|
37
|
+
when "model.thinking"
|
|
38
|
+
io.print "#{DIM}#{payload["delta"]}#{RESET}"
|
|
39
|
+
when "tool.use"
|
|
40
|
+
args = truncate(JSON.generate(payload["args"]))
|
|
41
|
+
io.puts "\n#{CYAN}› #{payload["name"]}#{args.empty? ? "" : " #{args}"}#{RESET}"
|
|
42
|
+
when "tool.delta"
|
|
43
|
+
io.print payload["partial"].to_s
|
|
44
|
+
when "tool.result"
|
|
45
|
+
render_tool_result(payload, io)
|
|
46
|
+
when "approval.required"
|
|
47
|
+
io.puts "#{YELLOW}✋ #{payload["toolName"]} requires approval (#{payload["id"]})#{RESET}"
|
|
48
|
+
io.puts "#{DIM}#{payload["message"]}#{RESET}" if payload["message"]
|
|
49
|
+
when "approval.updated"
|
|
50
|
+
mark = payload["status"] == "approved" ? "#{GREEN}✓#{RESET}" : "#{RED}✗#{RESET}"
|
|
51
|
+
io.puts "#{mark} #{payload["status"]} (#{payload["id"]})"
|
|
52
|
+
when "plan.proposed"
|
|
53
|
+
io.puts "\n#{MAGENTA}📋 Plan proposal (#{payload["id"]}):#{RESET}"
|
|
54
|
+
io.puts payload["plan"].to_s
|
|
55
|
+
io.puts "#{DIM}plan/approve or plan/reject to continue#{RESET}"
|
|
56
|
+
when "plan.approved"
|
|
57
|
+
io.puts "#{GREEN}✓ plan approved#{RESET}"
|
|
58
|
+
when "plan.rejected"
|
|
59
|
+
io.puts "#{RED}✗ plan rejected#{RESET}"
|
|
60
|
+
when "todos.updated"
|
|
61
|
+
render_todos(payload["todos"], io)
|
|
62
|
+
when "turn.started"
|
|
63
|
+
io.puts "#{DIM}── turn started ──#{RESET}"
|
|
64
|
+
when "turn.completed"
|
|
65
|
+
io.puts "\n#{DIM}── turn completed ──#{RESET}"
|
|
66
|
+
when "turn.failed"
|
|
67
|
+
io.puts "\n#{RED}✗ turn failed: #{payload["error"]}#{RESET}"
|
|
68
|
+
when "turn.aborted"
|
|
69
|
+
io.puts "\n#{DIM}── turn aborted ──#{RESET}"
|
|
70
|
+
when "error"
|
|
71
|
+
io.puts "#{RED}⚠ #{payload["error"]}#{RESET}"
|
|
72
|
+
when "session.created"
|
|
73
|
+
io.puts "#{DIM}session #{payload["sessionId"]} created#{RESET}"
|
|
74
|
+
when "session.ended"
|
|
75
|
+
io.puts "#{DIM}session #{payload["sessionId"]} ended (#{payload["reason"]})#{RESET}"
|
|
76
|
+
end
|
|
77
|
+
io.flush
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.render_tool_result(payload, io)
|
|
81
|
+
output = payload["output"].to_s
|
|
82
|
+
if payload["isError"]
|
|
83
|
+
io.puts "#{RED}✗ #{payload["name"]} failed:#{RESET}"
|
|
84
|
+
else
|
|
85
|
+
io.puts "#{DIM}└ #{payload["name"]} (#{payload["durationMs"]}ms)#{RESET}" if payload["durationMs"]
|
|
86
|
+
end
|
|
87
|
+
return if output.empty?
|
|
88
|
+
|
|
89
|
+
lines = output.lines
|
|
90
|
+
lines.first(MAX_OUTPUT_LINES).each { |l| io.puts "#{DIM} #{l.chomp}#{RESET}" }
|
|
91
|
+
io.puts "#{DIM} … #{lines.size - MAX_OUTPUT_LINES} more lines#{RESET}" if lines.size > MAX_OUTPUT_LINES
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.render_todos(todos, io)
|
|
95
|
+
return unless todos.is_a?(Array) && !todos.empty?
|
|
96
|
+
|
|
97
|
+
done = todos.count { |t| t["status"] == "completed" }
|
|
98
|
+
io.puts "#{DIM}todos: #{done}/#{todos.size} done#{RESET}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.truncate(string)
|
|
102
|
+
string.to_s[0, MAX_ARG_CHARS]
|
|
103
|
+
end
|
|
104
|
+
private_class_method :truncate, :render_tool_result, :render_todos
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Terminal
|
|
5
|
+
# The interactive terminal coding agent: a thin client of the session
|
|
6
|
+
# protocol. Owns one session with the host and drives it entirely
|
|
7
|
+
# through canonical requests and events:
|
|
8
|
+
#
|
|
9
|
+
# prompt → session/send → stream events → turn ends → prompt again
|
|
10
|
+
#
|
|
11
|
+
# Human-in-the-loop events are handled inline: approvals ask y/n,
|
|
12
|
+
# plan proposals ask approve/reject, and both resolve through the
|
|
13
|
+
# canonical interaction/plan methods — the same requests any other
|
|
14
|
+
# client (web console, bot) would make.
|
|
15
|
+
class Repl
|
|
16
|
+
# @param client [Client] protocol client (spawned host or socket)
|
|
17
|
+
# @param input [IO] user input (readline when a tty, else gets)
|
|
18
|
+
# @param output [IO] rendered output
|
|
19
|
+
# @param workspace [String] workspace path for session/create
|
|
20
|
+
# @param model [String, nil] model override
|
|
21
|
+
# @param approval [String] approval mode: on_request | off | auto
|
|
22
|
+
def initialize(client:, input: $stdin, output: $stdout, workspace: Dir.pwd,
|
|
23
|
+
model: nil, approval: "on_request")
|
|
24
|
+
@client = client
|
|
25
|
+
@input = input
|
|
26
|
+
@output = output
|
|
27
|
+
@workspace = workspace
|
|
28
|
+
@model = model
|
|
29
|
+
@approval = approval
|
|
30
|
+
@session_id = nil
|
|
31
|
+
@turn_active = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Connect to the host, create a session, and run the interactive loop.
|
|
35
|
+
# Returns when the user exits (/exit or EOF).
|
|
36
|
+
def run
|
|
37
|
+
handshake = @client.initialize!
|
|
38
|
+
@server_info = handshake["server"] || {}
|
|
39
|
+
|
|
40
|
+
created = @client.create_session(
|
|
41
|
+
workspace_path: @workspace, mode: @approval, model: @model
|
|
42
|
+
)
|
|
43
|
+
@session_id = created.dig("session", "sessionId")
|
|
44
|
+
@client.subscribe(@session_id, after_seq: 0)
|
|
45
|
+
|
|
46
|
+
banner
|
|
47
|
+
main_loop
|
|
48
|
+
rescue Client::ConnectionClosed
|
|
49
|
+
@output.puts "\n#{Renderer::RED}host closed the connection#{Renderer::RESET}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def banner
|
|
55
|
+
@output.puts "#{Renderer::DIM}#{Ask::Terminal::CLI::COMMAND_NAME} #{Ask::Terminal::VERSION} · " \
|
|
56
|
+
"protocol #{Ask::SessionProtocol::PROTOCOL_VERSION} · " \
|
|
57
|
+
"host #{@server_info["name"]} #{@server_info["version"]}#{Renderer::RESET}"
|
|
58
|
+
@output.puts "#{Renderer::DIM}workspace: #{@workspace} · session: #{@session_id}#{Renderer::RESET}"
|
|
59
|
+
@output.puts "#{Renderer::DIM}type /help for commands#{Renderer::RESET}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def main_loop
|
|
63
|
+
loop do
|
|
64
|
+
line = prompt
|
|
65
|
+
break if line.nil?
|
|
66
|
+
|
|
67
|
+
line = line.strip
|
|
68
|
+
next if line.empty?
|
|
69
|
+
|
|
70
|
+
if line.start_with?("/")
|
|
71
|
+
break if handle_command(line)
|
|
72
|
+
else
|
|
73
|
+
send_prompt(line)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def prompt
|
|
79
|
+
if @input.respond_to?(:tty?) && @input.tty?
|
|
80
|
+
require "readline"
|
|
81
|
+
Readline.readline("ask> ")
|
|
82
|
+
else
|
|
83
|
+
@output.print "ask> "
|
|
84
|
+
@output.flush
|
|
85
|
+
@input.gets
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def send_prompt(content)
|
|
90
|
+
result = @client.send(@session_id, content)
|
|
91
|
+
case result["status"]
|
|
92
|
+
when "stale"
|
|
93
|
+
@output.puts "#{Renderer::YELLOW}message not accepted: turn moved on (stale)#{Renderer::RESET}"
|
|
94
|
+
when "queued"
|
|
95
|
+
@output.puts "#{Renderer::DIM}queued for the next turn boundary#{Renderer::RESET}"
|
|
96
|
+
end
|
|
97
|
+
wait_for_turn_end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Drain notifications until the current turn ends. Approval and plan
|
|
101
|
+
# interactions are resolved inline; Ctrl-C aborts the turn.
|
|
102
|
+
def wait_for_turn_end
|
|
103
|
+
@turn_active = true
|
|
104
|
+
loop do
|
|
105
|
+
notification = @client.wait_notification(timeout: 0.2)
|
|
106
|
+
if notification.nil?
|
|
107
|
+
# No traffic this tick; keep waiting unless the turn already
|
|
108
|
+
# ended via an earlier event.
|
|
109
|
+
return unless @turn_active
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
return if notification == Client::CONNECTION_CLOSED
|
|
113
|
+
|
|
114
|
+
event = notification["params"] && notification["params"]["event"]
|
|
115
|
+
next unless event
|
|
116
|
+
|
|
117
|
+
Renderer.render(event, io: @output)
|
|
118
|
+
payload = event["payload"] || {}
|
|
119
|
+
case event["type"]
|
|
120
|
+
when "turn.completed", "turn.failed", "turn.aborted"
|
|
121
|
+
@turn_active = false
|
|
122
|
+
return
|
|
123
|
+
when "approval.required"
|
|
124
|
+
resolve_approval(payload)
|
|
125
|
+
when "plan.proposed"
|
|
126
|
+
resolve_plan(payload)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
rescue Interrupt
|
|
130
|
+
@output.puts "\n#{Renderer::DIM}aborting turn…#{Renderer::RESET}"
|
|
131
|
+
@client.abort(@session_id) rescue nil
|
|
132
|
+
retry
|
|
133
|
+
ensure
|
|
134
|
+
@turn_active = false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def resolve_approval(payload)
|
|
138
|
+
@output.print "Approve #{payload["toolName"]} (#{payload["id"]})? [y/N] "
|
|
139
|
+
@output.flush
|
|
140
|
+
answer = @input.gets
|
|
141
|
+
if answer&.strip&.match?(/\Ay(es)?\z/i)
|
|
142
|
+
@client.approve(@session_id, payload["id"])
|
|
143
|
+
else
|
|
144
|
+
@client.reject(@session_id, payload["id"])
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def resolve_plan(payload)
|
|
149
|
+
@output.print "Approve plan? [y/N] "
|
|
150
|
+
@output.flush
|
|
151
|
+
answer = @input.gets
|
|
152
|
+
if answer&.strip&.match?(/\Ay(es)?\z/i)
|
|
153
|
+
@client.plan_approve(@session_id)
|
|
154
|
+
else
|
|
155
|
+
@client.plan_reject(@session_id)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ── Commands ────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
# @return [Boolean] true when the repl should exit
|
|
162
|
+
def handle_command(line)
|
|
163
|
+
command, *args = line.split(/\s+/)
|
|
164
|
+
case command
|
|
165
|
+
when "/exit", "/quit"
|
|
166
|
+
true
|
|
167
|
+
when "/help"
|
|
168
|
+
print_help
|
|
169
|
+
when "/approve"
|
|
170
|
+
args.empty? ? @client.approve_all(@session_id) : @client.approve(@session_id, args[0])
|
|
171
|
+
@output.puts "#{Renderer::GREEN}✓ approved#{Renderer::RESET}"
|
|
172
|
+
when "/reject"
|
|
173
|
+
args.empty? ? @client.reject_all(@session_id) : @client.reject(@session_id, args[0])
|
|
174
|
+
@output.puts "#{Renderer::RED}✗ rejected#{Renderer::RESET}"
|
|
175
|
+
when "/plan-approve"
|
|
176
|
+
@client.plan_approve(@session_id)
|
|
177
|
+
when "/plan-reject"
|
|
178
|
+
@client.plan_reject(@session_id)
|
|
179
|
+
when "/abort"
|
|
180
|
+
@client.abort(@session_id)
|
|
181
|
+
@output.puts "#{Renderer::DIM}abort sent#{Renderer::RESET}"
|
|
182
|
+
when "/status"
|
|
183
|
+
print_status
|
|
184
|
+
when "/sessions"
|
|
185
|
+
list = @client.request("session/list", {})
|
|
186
|
+
(list["sessions"] || []).each do |s|
|
|
187
|
+
@output.puts "#{s["sessionId"]} #{s["model"]} #{s["running"] ? "running" : "idle"}"
|
|
188
|
+
end
|
|
189
|
+
else
|
|
190
|
+
@output.puts "#{Renderer::YELLOW}unknown command: #{command} (try /help)#{Renderer::RESET}"
|
|
191
|
+
end
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def print_help
|
|
196
|
+
@output.puts <<~HELP
|
|
197
|
+
#{Renderer::DIM}commands:#{Renderer::RESET}
|
|
198
|
+
/approve [id] approve a pending approval (all when no id)
|
|
199
|
+
/reject [id] reject a pending approval (all when no id)
|
|
200
|
+
/plan-approve approve the pending plan proposal
|
|
201
|
+
/plan-reject reject the pending plan proposal
|
|
202
|
+
/abort abort the running turn
|
|
203
|
+
/status show workspace and pending interactions
|
|
204
|
+
/sessions list host sessions
|
|
205
|
+
/exit quit
|
|
206
|
+
HELP
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def print_status
|
|
210
|
+
state = @client.read_workspace_state(@session_id)
|
|
211
|
+
workspace = state["workspace"] || {}
|
|
212
|
+
@output.puts "#{Renderer::DIM}workspace: #{workspace["path"] || "—"} · " \
|
|
213
|
+
"mode: #{workspace["mode"]}#{Renderer::RESET}"
|
|
214
|
+
|
|
215
|
+
interactions = @client.list_interactions(@session_id)
|
|
216
|
+
pending = interactions["interactions"] || []
|
|
217
|
+
if pending.empty?
|
|
218
|
+
@output.puts "#{Renderer::DIM}no pending approvals#{Renderer::RESET}"
|
|
219
|
+
else
|
|
220
|
+
pending.each do |i|
|
|
221
|
+
@output.puts "#{Renderer::YELLOW}✋ #{i["payload"]["toolName"]} (#{i["id"]})#{Renderer::RESET}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
data/lib/ask/terminal.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ask-session-protocol"
|
|
4
|
+
|
|
5
|
+
require_relative "terminal/version"
|
|
6
|
+
require_relative "terminal/client"
|
|
7
|
+
require_relative "terminal/host"
|
|
8
|
+
require_relative "terminal/renderer"
|
|
9
|
+
require_relative "terminal/repl"
|
|
10
|
+
require_relative "terminal/cli"
|
data/lib/ask-terminal.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-terminal
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ask-session-protocol
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.2.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.2.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.25'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.25'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mocha
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: simplecov
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.22'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.22'
|
|
82
|
+
description: 'The first thin client of the ask ecosystem: an interactive terminal
|
|
83
|
+
coding agent that speaks the canonical ask-session-protocol and nothing else. Spawns
|
|
84
|
+
the host (ask-app-server) over stdio or attaches to a running host over its unix
|
|
85
|
+
socket — the same protocol any other client (web console, bots) speaks, so multiple
|
|
86
|
+
clients can share the same live sessions.'
|
|
87
|
+
email:
|
|
88
|
+
- kaka@myrrlabs.com
|
|
89
|
+
executables:
|
|
90
|
+
- ask
|
|
91
|
+
- ask-terminal
|
|
92
|
+
extensions: []
|
|
93
|
+
extra_rdoc_files: []
|
|
94
|
+
files:
|
|
95
|
+
- CHANGELOG.md
|
|
96
|
+
- LICENSE
|
|
97
|
+
- README.md
|
|
98
|
+
- exe/ask
|
|
99
|
+
- exe/ask-terminal
|
|
100
|
+
- lib/ask-terminal.rb
|
|
101
|
+
- lib/ask/terminal.rb
|
|
102
|
+
- lib/ask/terminal/cli.rb
|
|
103
|
+
- lib/ask/terminal/client.rb
|
|
104
|
+
- lib/ask/terminal/host.rb
|
|
105
|
+
- lib/ask/terminal/renderer.rb
|
|
106
|
+
- lib/ask/terminal/repl.rb
|
|
107
|
+
- lib/ask/terminal/version.rb
|
|
108
|
+
homepage: https://github.com/ask-rb/ask-terminal
|
|
109
|
+
licenses:
|
|
110
|
+
- MIT
|
|
111
|
+
metadata: {}
|
|
112
|
+
rdoc_options: []
|
|
113
|
+
require_paths:
|
|
114
|
+
- lib
|
|
115
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
116
|
+
requirements:
|
|
117
|
+
- - ">="
|
|
118
|
+
- !ruby/object:Gem::Version
|
|
119
|
+
version: '3.2'
|
|
120
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - ">="
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '0'
|
|
125
|
+
requirements: []
|
|
126
|
+
rubygems_version: 4.0.18
|
|
127
|
+
specification_version: 4
|
|
128
|
+
summary: Terminal client for the ask session protocol
|
|
129
|
+
test_files: []
|