summaryrefslogtreecommitdiff
path: root/lua/clever_tee/acquisition_service.lua
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-04 18:44:32 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-04 18:44:32 +0200
commitc7b14ffb6d14969c9c41864e827f33f8e80fc24e (patch)
treec7c0ac23dc93a4a8b75449d2be4d0e05c4f05f9e /lua/clever_tee/acquisition_service.lua
parent9013636a57144e1f57c9339e7888d588430aae6a (diff)
Rename plugin to clever-tee
Diffstat (limited to 'lua/clever_tee/acquisition_service.lua')
-rw-r--r--lua/clever_tee/acquisition_service.lua927
1 files changed, 927 insertions, 0 deletions
diff --git a/lua/clever_tee/acquisition_service.lua b/lua/clever_tee/acquisition_service.lua
new file mode 100644
index 0000000..6e91159
--- /dev/null
+++ b/lua/clever_tee/acquisition_service.lua
@@ -0,0 +1,927 @@
+local domain = require("clever_tee.domain")
+local direct_preview_planner = require("clever_tee.direct_preview_planner")
+local feedback_service = require("clever_tee.feedback_service")
+local motion_plan_factory = require("clever_tee.motion_plan")
+local policy = require("clever_tee.policy")
+local sequence_state = require("clever_tee.sequence_state")
+local state_transitions = require("clever_tee.state_transitions")
+local target_plan_factory = require("clever_tee.target_plan")
+local text_topology = require("clever_tee.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 = {
+ SAME = "same",
+}
+M.PROMPT = "clever-tee: "
+M.PREVIOUS_INPUT_NOT_FOUND = "Previous input not found."
+
+local request_records = setmetatable({}, { __mode = "k" })
+local result_records = setmetatable({}, { __mode = "k" })
+local service_records = setmetatable({}, { __mode = "k" })
+local scope_records = setmetatable({}, { __mode = "k" })
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function normalize_macro_state(value)
+ if type(value) == "table" and not domain.MacroState.is(value) then
+ value = value.register
+ end
+ return domain.MacroState.new(value)
+end
+
+local request_metatable = {
+ __index = function(request, key)
+ local method = AcquisitionRequest[key]
+ if method ~= nil then
+ return method
+ end
+ return request_records[request][key]
+ end,
+ __newindex = function()
+ fail("AcquisitionRequest values are immutable", 2)
+ end,
+ __tostring = function(request)
+ return "acquisition-request:" .. request_records[request].descriptor.value
+ end,
+ __metatable = "clever_tee.acquisition_service.AcquisitionRequest",
+}
+
+function AcquisitionRequest.new(descriptor, context, position, count, macro_state)
+ if AcquisitionRequest.is(descriptor) then
+ return descriptor
+ end
+ if type(descriptor) == "table" and not domain.Descriptor.is(descriptor) then
+ local options = descriptor
+ descriptor = options.descriptor
+ context = options.context
+ position = options.position or options.origin
+ count = options.count
+ macro_state = options.macro_state
+ end
+
+ local request = setmetatable({}, request_metatable)
+ request_records[request] = {
+ descriptor = domain.Descriptor.from_string(descriptor),
+ context = domain.ModeContext.from_full_mode(context),
+ position = domain.Position.coerce(position),
+ count = domain.Count.new(count),
+ macro_state = normalize_macro_state(macro_state),
+ repeated_direction = M.RepeatedDirection.SAME,
+ }
+ return request
+end
+
+function AcquisitionRequest.is(value)
+ return type(value) == "table" and request_records[value] ~= nil
+end
+
+function AcquisitionRequest:to_table()
+ return {
+ descriptor = self.descriptor.value,
+ context = self.context.key,
+ position = self.position:to_table(),
+ count = self.count.value,
+ macro_register = self.macro_state.register,
+ repeated_direction = self.repeated_direction,
+ }
+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_tee.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 target_plan = options.target_plan
+ if target_plan ~= nil and not domain.TargetPlan.is(target_plan) then
+ fail("acquisition result target_plan must be a TargetPlan", 2)
+ end
+ local motion_plan = options.motion_plan
+ if motion_plan ~= nil and not domain.ResolvedMotionPlan.is(motion_plan) then
+ fail("acquisition result motion_plan must be a ResolvedMotionPlan", 2)
+ end
+ local result = setmetatable({}, result_metatable)
+ result_records[result] = {
+ request = request,
+ outcome = outcome,
+ target = target,
+ target_plan = target_plan,
+ motion_plan = motion_plan,
+ resolved_motion_plan = motion_plan,
+ previous_input_trigger = options.previous_input_trigger,
+ previous_target_source = options.previous_target_source,
+ cached_target = options.cached_target,
+ missing_previous_input = options.missing_previous_input == true,
+ acquisition_time_ms = options.acquisition_time_ms,
+ persistent_feedback_request = options.persistent_feedback_request,
+ resolved = target ~= nil and target_plan ~= nil and motion_plan ~= nil,
+ completed = outcome ~= nil
+ or (target ~= nil and target_plan ~= nil and motion_plan ~= 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
+
+function AcquisitionResult:resolved_values()
+ return self.target, self.target_plan, self.motion_plan
+end
+
+AcquisitionResult.unpack = AcquisitionResult.resolved_values
+
+local scope_metatable = {
+ __index = function(scope, key)
+ local method = TemporaryResourceScope[key]
+ if method ~= nil then
+ return method
+ end
+ return scope_records[scope][key]
+ end,
+ __newindex = function()
+ fail("TemporaryResourceScope values are read-only", 2)
+ end,
+ __metatable = "clever_tee.acquisition_service.TemporaryResourceScope",
+}
+
+function TemporaryResourceScope.new(request, feedback, host)
+ if not AcquisitionRequest.is(request) then
+ fail("temporary resource scope requires an AcquisitionRequest", 2)
+ end
+ local scope = setmetatable({}, scope_metatable)
+ scope_records[scope] = {
+ request = request,
+ feedback = feedback,
+ host = host,
+ active = true,
+ interactive = not request.macro_state.executing,
+ prompt_shown = false,
+ input_completed = false,
+ acquisition_completed = false,
+ cursor_marker = nil,
+ direct_marker = nil,
+ cursor_presentation_lease = nil,
+ input_packet = nil,
+ acquired_target = nil,
+ resolved_target = nil,
+ previous_input_trigger = nil,
+ previous_target_source = nil,
+ cached_target = nil,
+ missing_previous_input = false,
+ text_view = nil,
+ target_plan = nil,
+ motion_plan = nil,
+ outcome = nil,
+ }
+ return scope
+end
+
+function TemporaryResourceScope.is(value)
+ return type(value) == "table" and scope_records[value] ~= nil
+end
+
+local function set_scope_resource(scope, field, resource)
+ local record = scope_records[scope]
+ if record == nil or not record.active then
+ fail("temporary resource scope must be active", 3)
+ end
+ record[field] = resource
+ return resource
+end
+
+function TemporaryResourceScope:set_cursor_marker(marker)
+ return set_scope_resource(self, "cursor_marker", marker)
+end
+
+function TemporaryResourceScope:set_direct_marker(marker)
+ return set_scope_resource(self, "direct_marker", marker)
+end
+
+function TemporaryResourceScope:set_cursor_presentation_lease(lease)
+ return set_scope_resource(self, "cursor_presentation_lease", lease)
+end
+
+function TemporaryResourceScope:set_input_packet(packet)
+ return set_scope_resource(self, "input_packet", packet)
+end
+
+function TemporaryResourceScope:request_redraw(kind)
+ local record = scope_records[self]
+ if record == nil then
+ fail("temporary resource scope is invalid", 2)
+ end
+ if not record.interactive then
+ return false
+ end
+ record.host:redraw(kind)
+ return true
+end
+
+function TemporaryResourceScope:mark_prompt_shown()
+ return set_scope_resource(self, "prompt_shown", true)
+end
+
+function TemporaryResourceScope:mark_input_completed()
+ return set_scope_resource(self, "input_completed", true)
+end
+
+function TemporaryResourceScope:mark_acquisition_completed()
+ return set_scope_resource(self, "acquisition_completed", true)
+end
+
+function TemporaryResourceScope:set_acquired_target(target)
+ return set_scope_resource(self, "acquired_target", target)
+end
+
+function TemporaryResourceScope:set_resolved_target(target)
+ return set_scope_resource(self, "resolved_target", target)
+end
+
+function TemporaryResourceScope:set_previous_input_trigger(trigger)
+ return set_scope_resource(self, "previous_input_trigger", trigger)
+end
+
+function TemporaryResourceScope:set_cached_target(context, target)
+ set_scope_resource(self, "previous_target_source", context)
+ return set_scope_resource(self, "cached_target", target)
+end
+
+function TemporaryResourceScope:set_missing_previous_input(missing)
+ if type(missing) ~= "boolean" then
+ fail("missing previous-input state must be a Boolean", 2)
+ end
+ return set_scope_resource(self, "missing_previous_input", missing)
+end
+
+function TemporaryResourceScope:set_text_view(view)
+ if not text_topology.TextView.is(view) then
+ fail("temporary resource scope text must be a TextView", 2)
+ end
+ return set_scope_resource(self, "text_view", view)
+end
+
+function TemporaryResourceScope:set_target_plan(target_plan)
+ if not domain.TargetPlan.is(target_plan) then
+ fail("temporary resource scope target plan must be a TargetPlan", 2)
+ end
+ return set_scope_resource(self, "target_plan", target_plan)
+end
+
+function TemporaryResourceScope:set_motion_plan(motion_plan)
+ if not domain.ResolvedMotionPlan.is(motion_plan) then
+ fail("temporary resource scope motion plan must be a ResolvedMotionPlan", 2)
+ end
+ return set_scope_resource(self, "motion_plan", motion_plan)
+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
+ fail("temporary resource scope is invalid", 2)
+ end
+ if not record.active then
+ return false
+ end
+ record.active = false
+
+ local first_error
+ local function release_operation(operation)
+ local ok, failure = pcall(operation)
+ if not ok and first_error == nil then
+ first_error = failure
+ end
+ end
+
+ if record.interactive
+ and record.prompt_shown
+ and record.input_completed
+ and record.acquisition_completed
+ then
+ release_operation(function()
+ self:request_redraw("full")
+ end)
+ end
+ if record.direct_marker ~= nil then
+ release_operation(function()
+ record.feedback:remove_temporary_overlay(record.direct_marker)
+ end)
+ end
+ if record.cursor_marker ~= nil then
+ release_operation(function()
+ record.feedback:remove_temporary_overlay(record.cursor_marker)
+ end)
+ end
+ if record.cursor_presentation_lease ~= nil then
+ release_operation(function()
+ record.cursor_presentation_lease:release()
+ end)
+ end
+ if first_error ~= nil then
+ error(first_error, 0)
+ end
+ return true
+end
+
+local function require_policy(service, host)
+ service = service or policy.new(host)
+ if type(service) ~= "table" or type(service.sample_acquisition) ~= "function" then
+ fail("AcquisitionService policy must sample acquisition settings", 3)
+ end
+ return service
+end
+
+local function require_state(state)
+ state = state or sequence_state.get()
+ if not sequence_state.is(state) then
+ fail("AcquisitionService state must be the plugin-global SequenceState", 3)
+ end
+ return state
+end
+
+local function require_transitions(transitions, state)
+ transitions = transitions or state_transitions.new(state)
+ if type(transitions) ~= "table"
+ or type(transitions.BeginAcquisition) ~= "function"
+ or type(transitions.CommitAcquiredTarget) ~= "function"
+ then
+ fail("AcquisitionService transitions must commit acquisition state", 3)
+ end
+ return transitions
+end
+
+local function require_direct_planner(planner)
+ planner = planner or direct_preview_planner.new()
+ if type(planner) ~= "table" or type(planner.plan) ~= "function" then
+ fail("AcquisitionService direct planner must provide plan", 3)
+ end
+ return planner
+end
+
+local function require_target_factory(factory, policy_service)
+ factory = factory or target_plan_factory.new({ policy = policy_service })
+ if type(factory) ~= "table" or type(factory.build) ~= "function" then
+ fail("AcquisitionService target factory must provide build", 3)
+ end
+ return factory
+end
+
+local function require_motion_factory(factory, policy_service)
+ factory = factory or motion_plan_factory.new({ policy = policy_service })
+ if type(factory) ~= "table" or type(factory.build_for_context) ~= "function" then
+ fail("AcquisitionService motion factory must build contextual plans", 3)
+ end
+ return factory
+end
+
+local function require_feedback(feedback, host, policy_service, transitions)
+ feedback = feedback or feedback_service.new({
+ host = host,
+ policy = policy_service,
+ transitions = transitions,
+ })
+ if type(feedback) ~= "table"
+ or type(feedback.create_cursor_marker) ~= "function"
+ or type(feedback.request_persistent) ~= "function"
+ then
+ fail("AcquisitionService feedback must manage acquisition markers", 3)
+ end
+ return feedback
+end
+
+local function normalize_options(options, dependencies)
+ if AcquisitionService.is(options) and dependencies == nil then
+ return options
+ end
+ if type(options) ~= "table" then
+ fail("AcquisitionService options must be a table", 3)
+ end
+ if options.host ~= nil then
+ if dependencies ~= nil then
+ fail("AcquisitionService dependencies must be part of its options", 3)
+ end
+ return options
+ end
+ local result = {}
+ for key, value in pairs(dependencies or {}) do
+ result[key] = value
+ end
+ result.host = options
+ return result
+end
+
+function AcquisitionService.new(options, dependencies)
+ options = normalize_options(options, dependencies)
+ if AcquisitionService.is(options) then
+ return options
+ end
+ if type(options.host) ~= "table" then
+ fail("AcquisitionService host must be a table", 2)
+ end
+ local state = require_state(options.state)
+ local transitions = require_transitions(
+ options.transitions or options.state_transitions,
+ state
+ )
+ local policy_service = require_policy(
+ options.policy or options.policy_service,
+ options.host
+ )
+ local service = setmetatable({}, AcquisitionService)
+ service_records[service] = {
+ host = options.host,
+ policy = policy_service,
+ state = state,
+ transitions = transitions,
+ feedback = require_feedback(
+ options.feedback or options.feedback_service,
+ options.host,
+ policy_service,
+ transitions
+ ),
+ direct_planner = require_direct_planner(
+ options.direct_planner or options.direct_preview_planner
+ ),
+ target_factory = require_target_factory(
+ options.target_factory or options.target_plan_factory,
+ policy_service
+ ),
+ motion_factory = require_motion_factory(
+ options.motion_factory or options.motion_plan_factory,
+ policy_service
+ ),
+ window = options.window or options.current_window,
+ last_scope = nil,
+ started_scope_count = 0,
+ }
+ return service
+end
+
+function AcquisitionService.is(value)
+ return type(value) == "table" and service_records[value] ~= nil
+end
+
+function AcquisitionService:request(descriptor, context, position, count, macro_state)
+ return AcquisitionRequest.new(descriptor, context, position, count, macro_state)
+end
+
+local function current_window(record)
+ local window = record.window
+ if type(window) == "function" then
+ window = window()
+ end
+ if window == nil and type(record.host.read_window) == "function" then
+ window = record.host:read_window()
+ end
+ if window == nil then
+ fail("AcquisitionService requires a current window identity", 3)
+ end
+ return window
+end
+
+function AcquisitionService:start_temporary_scope(request)
+ request = AcquisitionRequest.new(request)
+ local record = service_records[self]
+ local scope = TemporaryResourceScope.new(request, record.feedback, record.host)
+ record.last_scope = scope
+ record.started_scope_count = record.started_scope_count + 1
+ return scope
+end
+
+function AcquisitionService:last_temporary_scope()
+ return service_records[self].last_scope
+end
+
+function AcquisitionService:started_scope_count()
+ return service_records[self].started_scope_count
+end
+
+local function utf8_first_code(character)
+ local first = string.byte(character, 1)
+ if first < 0x80 then
+ return first
+ end
+ local length
+ local code
+ if first >= 0xc2 and first <= 0xdf then
+ length = 2
+ code = first - 0xc0
+ elseif first >= 0xe0 and first <= 0xef then
+ length = 3
+ code = first - 0xe0
+ elseif first >= 0xf0 and first <= 0xf4 then
+ length = 4
+ code = first - 0xf0
+ else
+ fail("ordinary input must start with a valid editor character", 3)
+ end
+ for index = 2, length do
+ local byte = string.byte(character, index)
+ if byte == nil or byte < 0x80 or byte > 0xbf then
+ fail("ordinary input must contain a complete editor character", 3)
+ end
+ code = code * 0x40 + byte - 0x80
+ end
+ return code
+end
+
+local function first_editor_character(text)
+ local characters = text_topology.split_editor_characters(text)
+ if #characters == 0 then
+ fail("ordinary input must contain an editor character", 3)
+ end
+ return characters[1]
+end
+
+function M.editor_character_code(character)
+ character = first_editor_character(character)
+ local runtime = rawget(_G, "vim")
+ if type(runtime) == "table"
+ and type(runtime.fn) == "table"
+ and type(runtime.fn.char2nr) == "function"
+ then
+ return runtime.fn.char2nr(character)
+ end
+ return utf8_first_code(character)
+end
+
+function M.normalize_ordinary_input(packet)
+ packet = domain.InputPacket.from_table(packet)
+ local text
+ if packet.kind == domain.InputPacketKind.TEXT then
+ text = packet.text
+ elseif packet.kind == domain.InputPacketKind.RAW_BYTES then
+ local bytes = packet:bytes()
+ local characters = {}
+ for index = 1, #bytes do
+ characters[index] = string.char(bytes[index])
+ end
+ text = table.concat(characters)
+ else
+ fail("ordinary input packet must contain text or raw bytes", 2)
+ end
+ local character = first_editor_character(text)
+ return domain.TargetValue.character(
+ character,
+ M.editor_character_code(character)
+ )
+end
+
+local function encoded_packet_value(packet)
+ if packet.encoded ~= nil then
+ return packet.encoded
+ end
+ local bytes = packet:bytes()
+ if bytes == nil then
+ return nil
+ end
+ local characters = {}
+ for index = 1, #bytes do
+ characters[index] = string.char(bytes[index])
+ end
+ return table.concat(characters)
+end
+
+function M.normalize_input_packet(packet)
+ packet = domain.InputPacket.from_table(packet)
+ if packet.kind == domain.InputPacketKind.ERROR then
+ fail(packet.message, 2)
+ end
+ if packet.kind == domain.InputPacketKind.TEXT then
+ return M.normalize_ordinary_input(packet)
+ end
+
+ local encoded = encoded_packet_value(packet)
+ if encoded == nil then
+ return domain.TargetValue.code_fallback(0)
+ end
+ if string.byte(encoded, 1) == 0x80 then
+ return domain.TargetValue.special_key(encoded, 0x80)
+ end
+ return M.normalize_ordinary_input(domain.InputPacket.text(encoded))
+end
+
+function M.read_previous_target(state)
+ if not sequence_state.is(state) then
+ fail("previous-input reuse requires SequenceState", 2)
+ end
+ local context = state.last_input_context
+ if context == nil then
+ return nil, nil
+ end
+ return state:get_previous_target(context), context
+end
+
+function M.match_previous_input_trigger(first_code, triggers)
+ if type(first_code) ~= "number" or first_code < 0 then
+ fail("acquired first code must be nonnegative", 2)
+ end
+ if type(triggers) ~= "table" then
+ fail("previous-input triggers must be a list", 2)
+ end
+ for index, trigger in ipairs(triggers) do
+ if type(trigger) ~= "string" then
+ fail("previous-input triggers must contain strings", 2)
+ end
+ if trigger ~= "" and M.editor_character_code(trigger) == first_code then
+ return trigger, index
+ end
+ end
+ return nil
+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
+ return false
+ end
+ local bytes = packet:bytes()
+ return #bytes == 3
+ and bytes[1] == 0x80
+ and bytes[2] == 0xfd
+ and bytes[3] == 0x60
+end
+
+local function read_input_packet(host)
+ while true do
+ local packet = domain.InputPacket.from_table(host:read_input())
+ if not M.is_terminal_artifact(packet) then
+ return packet
+ end
+ end
+end
+
+local function direct_preview_settings(policy_service)
+ if type(policy_service.sample_direct_preview) == "function" then
+ return policy_service:sample_direct_preview()
+ end
+ if type(policy_service.get_boolean) == "function" then
+ return {
+ ignore_case = policy_service:get_boolean("ignore_case"),
+ smart_case = policy_service:get_boolean("smart_case"),
+ }
+ end
+ fail("AcquisitionService policy must sample direct preview settings", 3)
+end
+
+local function acquire_in_scope(record, request, scope)
+ local acquisition = record.policy:sample_acquisition()
+ local interactive = not request.macro_state.executing
+ scope:set_cursor_presentation_lease(
+ record.feedback:create_cursor_presentation_lease(
+ interactive and acquisition.hide_cursor_on_cmdline
+ )
+ )
+ if not interactive then
+ record.host:redraw("suppressed")
+ end
+ if acquisition.mark_cursor and interactive then
+ scope:set_cursor_marker(record.feedback:create_cursor_marker(
+ request.position,
+ current_window(record)
+ ))
+ scope:request_redraw("screen")
+ end
+ if acquisition.mark_direct and interactive then
+ local view = scope:set_text_view(text_topology.from_host(record.host))
+ local positions = record.direct_planner:plan(
+ view,
+ request.position,
+ request.descriptor,
+ request.count,
+ direct_preview_settings(record.policy)
+ )
+ local window = scope.cursor_marker
+ and scope.cursor_marker.window
+ or current_window(record)
+ scope:set_direct_marker(record.feedback:create_direct_markers(
+ positions,
+ window
+ ))
+ scope:request_redraw("screen")
+ end
+ if acquisition.show_prompt and interactive then
+ record.host:show_prompt(M.PROMPT)
+ scope:mark_prompt_shown()
+ end
+ record.transitions:BeginAcquisition(request.context, request.descriptor)
+ local packet = scope:set_input_packet(read_input_packet(record.host))
+ scope:mark_input_completed()
+ if M.is_escape(packet) then
+ local outcome = scope:set_outcome(domain.ActionOutcome.escape(request.position))
+ return AcquisitionResult.new(request, { outcome = outcome })
+ end
+ local target = scope:set_acquired_target(M.normalize_input_packet(packet))
+ local previous_input = record.policy:sample_previous_input()
+ local trigger = scope:set_previous_input_trigger(
+ M.match_previous_input_trigger(
+ target.first_code,
+ previous_input.repeat_last_char_inputs
+ )
+ )
+ if trigger ~= nil then
+ local cached_target, source = M.read_previous_target(record.state)
+ scope:set_cached_target(source, cached_target)
+ if cached_target ~= nil then
+ target = cached_target
+ else
+ target = nil
+ scope:set_missing_previous_input(true)
+ record.host:emit_diagnostic("error", M.PREVIOUS_INPUT_NOT_FOUND)
+ local outcome = scope:set_outcome(
+ domain.ActionOutcome.empty(request.position)
+ )
+ return AcquisitionResult.new(request, {
+ outcome = outcome,
+ previous_input_trigger = trigger,
+ previous_target_source = scope.previous_target_source,
+ missing_previous_input = true,
+ })
+ end
+ end
+ scope:set_resolved_target(target)
+ local acquisition_time_ms
+ if record.policy:sample_timeouts().repeat_timeout_ms > 0 then
+ acquisition_time_ms = record.host:read_time_ms()
+ end
+ record.transitions:CommitAcquiredTarget(
+ request.context,
+ target,
+ acquisition_time_ms
+ )
+ local view = scope.text_view
+ or scope:set_text_view(text_topology.from_host(record.host))
+ local search_scope = record.policy:sample_search().search_scope
+ local target_plan = scope:set_target_plan(record.target_factory:build(
+ target,
+ nil,
+ {
+ text_view = view,
+ origin = request.position,
+ search_scope = search_scope,
+ effective_encoding = view.effective_encoding,
+ }
+ ))
+ local selection = request.context.visual and record.host:read_selection() or nil
+ local motion_plan = scope:set_motion_plan(
+ record.motion_factory:build_for_context(
+ target_plan,
+ request.descriptor,
+ request.context,
+ selection,
+ search_scope
+ )
+ )
+ local persistent_feedback_request
+ if interactive
+ and record.policy:sample_markers().mark_char
+ and feedback_service.persistent_context_eligible(request.context)
+ then
+ local window = scope.cursor_marker
+ and scope.cursor_marker.window
+ or scope.direct_marker
+ and scope.direct_marker.window
+ or current_window(record)
+ persistent_feedback_request = record.feedback:request_persistent({
+ context = request.context,
+ anchor = request.position,
+ target_plan = target_plan,
+ motion_plan = motion_plan,
+ window = window,
+ })
+ end
+ local result = AcquisitionResult.new(request, {
+ target = target,
+ target_plan = target_plan,
+ motion_plan = motion_plan,
+ acquisition_time_ms = acquisition_time_ms,
+ persistent_feedback_request = persistent_feedback_request,
+ previous_input_trigger = trigger,
+ previous_target_source = scope.previous_target_source,
+ cached_target = scope.cached_target,
+ missing_previous_input = scope.missing_previous_input,
+ })
+ scope:mark_acquisition_completed()
+ return result
+end
+
+local function error_message(failure)
+ local message = tostring(failure)
+ if message == "" then
+ return "clever-tee: Acquisition failed"
+ end
+ return message
+end
+
+function AcquisitionService:acquire(descriptor, context, position, count, macro_state)
+ local request = self:request(descriptor, context, position, count, macro_state)
+ local scope = self:start_temporary_scope(request)
+ local record = service_records[self]
+ local ok, result = xpcall(function()
+ return acquire_in_scope(record, request, scope)
+ end, function(failure)
+ return failure
+ end)
+
+ if not ok then
+ local diagnostic = error_message(result)
+ result = AcquisitionResult.new(request, {
+ outcome = domain.ActionOutcome.error(request.position, diagnostic),
+ })
+ end
+
+ local cleanup_ok, cleanup_error = pcall(function()
+ scope:release()
+ end)
+ if not cleanup_ok and ok then
+ local diagnostic = error_message(cleanup_error)
+ result = AcquisitionResult.new(request, {
+ outcome = domain.ActionOutcome.error(request.position, diagnostic),
+ })
+ ok = false
+ end
+
+ if not ok then
+ pcall(record.host.emit_diagnostic, record.host, "error", result.outcome.diagnostic)
+ end
+ return result
+end
+
+function M.new(options, dependencies)
+ return AcquisitionService.new(options, dependencies)
+end
+
+setmetatable(M, {
+ __call = function(_, options, dependencies)
+ return AcquisitionService.new(options, dependencies)
+ end,
+})
+
+return M