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 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ask-terminal"
5
+
6
+ exit Ask::Terminal::CLI.run!
data/exe/ask-terminal ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Long alias for `ask`. Both executables behave identically.
5
+ require "ask-terminal"
6
+
7
+ exit Ask::Terminal::CLI.run!
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The protocol client lives in ask-session-protocol; this constant is
4
+ # kept as an alias so existing references keep working.
5
+ require "ask-session-protocol"
6
+
7
+ Ask::Terminal::Client = Ask::SessionProtocol::Client
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Host spawning/attach lives in ask-session-protocol; this constant is
4
+ # kept as an alias so existing references keep working.
5
+ require "ask-session-protocol"
6
+
7
+ Ask::Terminal::Host = Ask::SessionProtocol::Host
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Terminal
5
+ VERSION = "0.2.1"
6
+ end
7
+ end
@@ -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"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ask/terminal"
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: []