summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-04 12:03:36 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-04 12:03:36 +0200
commit44b0624d22ea62983cae7737a0b1d1bd0280c5e5 (patch)
tree3df7b1cb63067f8ce4fb5249226dbe478ab03096
parentdbbbe30b637fccfacb15cd7317d4af87b658cfb5 (diff)
Return Escape acquisition outcomes
-rw-r--r--lua/clever_f/acquisition_service.lua90
-rw-r--r--tests/run.lua36
2 files changed, 122 insertions, 4 deletions
diff --git a/lua/clever_f/acquisition_service.lua b/lua/clever_f/acquisition_service.lua
index b1e74bb..d341414 100644
--- a/lua/clever_f/acquisition_service.lua
+++ b/lua/clever_f/acquisition_service.lua
@@ -8,10 +8,12 @@ local text_topology = require("clever_f.text_topology")
local M = {}
local AcquisitionRequest = {}
+local AcquisitionResult = {}
local AcquisitionService = {}
local TemporaryResourceScope = {}
AcquisitionService.__index = AcquisitionService
M.AcquisitionRequest = AcquisitionRequest
+M.AcquisitionResult = AcquisitionResult
M.AcquisitionService = AcquisitionService
M.TemporaryResourceScope = TemporaryResourceScope
M.RepeatedDirection = {
@@ -20,6 +22,7 @@ M.RepeatedDirection = {
M.PROMPT = "clever-f: "
local request_records = setmetatable({}, { __mode = "k" })
+local result_records = setmetatable({}, { __mode = "k" })
local service_records = setmetatable({}, { __mode = "k" })
local scope_records = setmetatable({}, { __mode = "k" })
@@ -91,6 +94,64 @@ function AcquisitionRequest:to_table()
}
end
+local result_metatable = {
+ __index = function(result, key)
+ local method = AcquisitionResult[key]
+ if method ~= nil then
+ return method
+ end
+ return result_records[result][key]
+ end,
+ __newindex = function()
+ fail("AcquisitionResult values are immutable", 2)
+ end,
+ __tostring = function(result)
+ local outcome = result_records[result].outcome
+ return outcome and tostring(outcome) or "acquisition:resolved"
+ end,
+ __metatable = "clever_f.acquisition_service.AcquisitionResult",
+}
+
+function AcquisitionResult.new(request, options)
+ if AcquisitionResult.is(request) and options == nil then
+ return request
+ end
+ if not AcquisitionRequest.is(request) then
+ fail("acquisition result requires an AcquisitionRequest", 2)
+ end
+ options = options or {}
+ if type(options) ~= "table" then
+ fail("acquisition result options must be a table", 2)
+ end
+ local outcome = options.outcome
+ if outcome ~= nil and not domain.ActionOutcome.is(outcome) then
+ fail("acquisition result outcome must be an ActionOutcome", 2)
+ end
+ local target = options.target
+ if target ~= nil and not domain.TargetValue.is(target) then
+ fail("acquisition result target must be a TargetValue", 2)
+ end
+ local result = setmetatable({}, result_metatable)
+ result_records[result] = {
+ request = request,
+ outcome = outcome,
+ target = target,
+ target_plan = options.target_plan,
+ motion_plan = options.motion_plan,
+ resolved_motion_plan = options.motion_plan,
+ completed = outcome ~= nil or target ~= nil,
+ }
+ return result
+end
+
+function AcquisitionResult.is(value)
+ return type(value) == "table" and result_records[value] ~= nil
+end
+
+function AcquisitionResult:has_outcome()
+ return self.outcome ~= nil
+end
+
local scope_metatable = {
__index = function(scope, key)
local method = TemporaryResourceScope[key]
@@ -119,6 +180,7 @@ function TemporaryResourceScope.new(request, feedback)
cursor_presentation_lease = nil,
input_packet = nil,
acquired_target = nil,
+ outcome = nil,
}
return scope
end
@@ -156,6 +218,13 @@ function TemporaryResourceScope:set_acquired_target(target)
return set_scope_resource(self, "acquired_target", target)
end
+function TemporaryResourceScope:set_outcome(outcome)
+ if not domain.ActionOutcome.is(outcome) then
+ fail("temporary resource scope outcome must be an ActionOutcome", 2)
+ end
+ return set_scope_resource(self, "outcome", outcome)
+end
+
function TemporaryResourceScope:release()
local record = scope_records[self]
if record == nil then
@@ -372,6 +441,20 @@ function M.normalize_ordinary_input(packet)
return domain.TargetValue.character(character, first_code)
end
+function M.is_escape(packet)
+ packet = domain.InputPacket.from_table(packet)
+ if packet.kind == domain.InputPacketKind.SPECIAL_KEY
+ and (packet.name == "Escape" or packet.name == "Esc")
+ then
+ return true
+ end
+ if packet.kind == domain.InputPacketKind.TEXT then
+ return packet.text == string.char(27)
+ end
+ local bytes = packet:bytes()
+ return bytes ~= nil and #bytes == 1 and bytes[1] == 27
+end
+
function M.is_terminal_artifact(packet)
packet = domain.InputPacket.from_table(packet)
if packet.kind ~= domain.InputPacketKind.RAW_BYTES then
@@ -449,12 +532,17 @@ function AcquisitionService:acquire(descriptor, context, position, count, macro_
end
record.transitions:BeginAcquisition(request.context, request.descriptor)
local packet = scope:set_input_packet(read_input_packet(record.host))
+ if M.is_escape(packet) then
+ local outcome = scope:set_outcome(domain.ActionOutcome.escape(request.position))
+ scope:release()
+ return AcquisitionResult.new(request, { outcome = outcome })
+ end
if packet.kind == domain.InputPacketKind.TEXT
or packet.kind == domain.InputPacketKind.RAW_BYTES
then
scope:set_acquired_target(M.normalize_ordinary_input(packet))
end
- return request
+ return AcquisitionResult.new(request, { target = scope.acquired_target })
end
function M.new(options, dependencies)
diff --git a/tests/run.lua b/tests/run.lua
index c279844..9ce65d2 100644
--- a/tests/run.lua
+++ b/tests/run.lua
@@ -4901,7 +4901,7 @@ test("Acquisition starts one temporary resource scope", function()
local service = acquisition_service.new(MemoryHost.new({
input_packets = { { kind = "text", text = "a" } },
}))
- local request = service:acquire(
+ local result = service:acquire(
"f",
"n",
domain.Position.new(1, 1),
@@ -4910,9 +4910,10 @@ test("Acquisition starts one temporary resource scope", function()
)
local scope = service:last_temporary_scope()
- truthy(acquisition_service.AcquisitionRequest.is(request))
+ truthy(acquisition_service.AcquisitionResult.is(result))
+ truthy(acquisition_service.AcquisitionRequest.is(result.request))
truthy(acquisition_service.TemporaryResourceScope.is(scope))
- same(request, scope.request)
+ same(result.request, scope.request)
truthy(scope.active)
same(1, service:started_scope_count())
end)
@@ -5129,6 +5130,35 @@ test("Acquisition reads one raw packet after sequence start", function()
same(1, reads)
end)
+test("Escape acquisition preserves the cursor and returns Escape", function()
+ fresh_sequence_state()
+ local origin = domain.Position.new(2, 3)
+ local host = MemoryHost.new({
+ buffer_lines = { "abc", "abcd" },
+ cursor = origin,
+ configuration = {
+ mark_cursor = false,
+ hide_cursor_on_cmdline = false,
+ },
+ input_packets = {
+ { kind = "special_key", name = "Escape", bytes = { 27 } },
+ },
+ })
+ local service = acquisition_service.new(host)
+
+ local result = service:acquire("f", "n", origin, nil, nil)
+ truthy(acquisition_service.AcquisitionResult.is(result))
+ truthy(result:has_outcome())
+ same(domain.ActionKind.ESCAPE, result.outcome.kind)
+ same(origin, result.outcome.position)
+ same(origin, host:read_cursor())
+ same(nil, result.target)
+ falsy(service:last_temporary_scope().active)
+ truthy(acquisition_service.is_escape(
+ domain.InputPacket.raw_bytes({ 27 })
+ ))
+end)
+
test("Acquisition discards the terminal artifact packet", function()
fresh_sequence_state()
local artifact = domain.InputPacket.raw_bytes({ 0x80, 0xfd, 0x60 })