Class: Rubino::UI::CompletionMenu

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/ui/completion_menu.rb

Overview

The BottomComposer‘s /command + @file completion menu: an inline navigable list rendered in the multi-row live region above the prompt. Candidates come from the shared CompletionSource. The menu auto-opens as you type a `/` or `@` token (Reline parity); Tab also opens/accepts, ↑/↓navigate, Enter accepts, ESC dismisses immediately (and STICKS for the token) leaving the typed buffer untouched.

Pure state machine + row formatting: it reads the buffer/cursor the composer passes in and never prints or takes the render mutex — opening, navigation and the accept SPLICE are decided here, but the composer applies the splice to its buffer and owns every redraw.

Constant Summary collapse

MAX_ROWS =

Most candidate rows shown at once (the list scrolls within this window for longer candidate sets so the prompt is never pushed off-screen).

8

Instance Method Summary collapse

Constructor Details

#initialize(completion_source) ⇒ CompletionMenu

Returns a new instance of CompletionMenu.

Parameters:

  • completion_source (CompletionSource, nil)

    shared completion discovery (slash commands + @file picker). nil ⇒ the menu is inert (steering / standalone), so the composer degrades to a plain editor.



26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/rubino/ui/completion_menu.rb', line 26

def initialize(completion_source)
  @completion = completion_source
  # Open state: nil when closed, else a Hash with the candidate :items,
  # the :selected index, the :top of the visible window, the :token span
  # being completed (so accept can splice the replacement at the cursor)
  # and the :navigated accept-intent flag.
  @state = nil
  # Sticky ESC-dismiss: once the user presses ESC on an open menu, keep
  # it closed for the CURRENT token instead of re-opening on the next
  # keystroke. Cleared when the token is cleared / on submit / on accept /
  # on an explicit Tab, so a fresh token (or a deliberate Tab) reopens.
  @suppressed = false
end

Instance Method Details

#accept_spliceObject

Accept the highlighted candidate: returns the splice the composer applies — [start, token_len, replacement] where the replacement carries a trailing space (so the next token starts clean, like Reline’s append char) — and closes the menu (clearing the sticky dismiss: accepting ends this token; a new one can auto-open).



112
113
114
115
116
117
# File 'lib/rubino/ui/completion_menu.rb', line 112

def accept_splice
  choice = @state[:items][@state[:selected]].to_s
  splice = [@state[:start], @state[:token_len], "#{choice} "]
  close!
  splice
end

#auto_update(buffer, cursor) ⇒ Object

Open / update / close the menu on every edit and cursor move, matching the old Reline autocompletion: typing a leading ‘/` or `@` token AUTO-opens the dropdown (no Tab needed), refining as the token grows and closing when it no longer completes. Called from every buffer-edit and cursor-move path so the list always tracks the token under the cursor.

* no token under the cursor → close the menu AND clear the sticky
  ESC-dismiss flag (a fresh token may auto-open again);
* token present but ESC-dismissed for it → stay closed;
* token with candidates → OPEN a new menu, or UPDATE an open one
  (preserving the clamped selection); no candidates → close.

The selected index is preserved (clamped) across an update so refining the token doesn’t jump the highlight back to the top mid-navigation.



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/rubino/ui/completion_menu.rb', line 77

def auto_update(buffer, cursor)
  ctx = completion_context(buffer, cursor)
  if ctx.nil?
    @state = nil
    @suppressed = false # token cleared: a fresh token can auto-open
    return
  end
  return if @suppressed # ESC stuck this token/argument closed

  items, start, len = ctx
  sel = (@state ? @state[:selected] : 0).clamp(0, items.size - 1)
  @state = { items: items, selected: sel, top: window_top(sel, items.size),
             start: start, token_len: len,
             navigated: @state ? @state[:navigated] : false }
end

#close!Object

Close the menu and clear the sticky ESC-dismiss flag (submit / accept): the next token starts fresh and is free to auto-open again.



147
148
149
150
# File 'lib/rubino/ui/completion_menu.rb', line 147

def close!
  @state = nil
  @suppressed = false
end

#dismiss!Object

Lone-ESC dismiss: close AND STICK for the current token so it doesn’t pop back on the next keystroke. Cleared when the token changes to nil, on submit/accept, or on an explicit Tab (see #auto_update / #close!).



155
156
157
158
# File 'lib/rubino/ui/completion_menu.rb', line 155

def dismiss!
  @state = nil
  @suppressed = true
end

#downObject



102
103
104
105
# File 'lib/rubino/ui/completion_menu.rb', line 102

def down
  @state[:selected] = [@state[:selected] + 1, @state[:items].size - 1].min
  navigated_to_selection
end

#exact_command?(buffer) ⇒ Boolean

True when the buffer is ALREADY an exact, complete command, so Enter should SUBMIT it rather than accept-and-space (D5/#147). Compares the TOKEN the menu would splice (not the whole buffer, which never matches a bare argument candidate — that’s what swallowed Enter on a fully typed ‘/agents sa_xxx`): submit when the typed token equals a candidate exactly AND that match is the menu’s current selection (or the only candidate) — so a partial/ambiguous token (e.g. “/re” with /reasoning + /reset) still accepts the highlight on Enter as before. An EMPTY argument token (‘/agents sa_xxx ` with the verb dropdown open) also submits — the buffer is already a complete command and accepting would splice a verb the user never typed — UNLESS the user explicitly arrow-navigated onto a candidate, which is an accept intent. Tab-accept is untouched.

Returns:

  • (Boolean)


132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/rubino/ui/completion_menu.rb', line 132

def exact_command?(buffer)
  return false unless @state

  typed = Array(buffer.chars[@state[:start], @state[:token_len]]).join
  return !@state[:navigated] if typed.empty?

  items = @state[:items]
  return false unless items.include?(typed)

  selected = items[@state[:selected]].to_s
  items.size == 1 || selected == typed
end

#hide!Object

Teardown hide (composer stop/suspend): close the rows without touching the sticky dismiss, so a resume mid-token behaves exactly as before.



162
163
164
# File 'lib/rubino/ui/completion_menu.rb', line 162

def hide!
  @state = nil
end

#itemsObject

The open menu’s candidate items (test/inspection helper), nil when closed.



45
46
47
# File 'lib/rubino/ui/completion_menu.rb', line 45

def items
  @state && @state[:items]
end

#open(buffer, cursor) ⇒ Object

Explicit open (Tab): always clears a sticky ESC-dismiss first — a deliberate Tab reopens a dismissed menu — then opens for the completion context under the cursor, if any. Returns the opened state (truthy; the composer redraws on it), or nil when nothing completes here.



53
54
55
56
57
58
59
60
61
# File 'lib/rubino/ui/completion_menu.rb', line 53

def open(buffer, cursor)
  @suppressed = false
  ctx = completion_context(buffer, cursor)
  return unless ctx

  items, start, len = ctx
  @state = { items: items, selected: 0, top: 0, start: start, token_len: len,
             navigated: false }
end

#open?Boolean

Returns:

  • (Boolean)


40
41
42
# File 'lib/rubino/ui/completion_menu.rb', line 40

def open?
  !@state.nil?
end

#rows(cols) ⇒ Object

The rendered menu rows (the slice in view, the selected one marked with a cyan ❯ and inverse highlight), or [] when no menu is open. House grammar: a dim aside bar leads each row. Candidates with a registered description (BuiltIns/custom command one-liners, the /agents subcommand hints) show it dim in an aligned column next to the name (#39).



171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/rubino/ui/completion_menu.rb', line 171

def rows(cols)
  return [] unless @state

  items = @state[:items]
  top   = @state[:top]
  sel   = @state[:selected]
  slice = items[top, MAX_ROWS] || []
  pad   = slice.map { |item| LiveRegion.display_width(item.to_s) }.max.to_i
  rows = slice.each_with_index.map do |item, i|
    candidate_row(item, pad, cols, selected: top + i == sel)
  end
  rows << pastel.dim("#{sel + 1}/#{items.size}") if items.size > MAX_ROWS
  rows
end

#upObject

↑/↓ within the open menu (routed from the composer’s history keys). Arrowing marks the menu as NAVIGATED — an explicit accept intent, so Enter on an empty argument token accepts the highlight instead of submitting the buffer (see #exact_command?).



97
98
99
100
# File 'lib/rubino/ui/completion_menu.rb', line 97

def up
  @state[:selected] = [@state[:selected] - 1, 0].max
  navigated_to_selection
end