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.



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

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).



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

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.



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

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.



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

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!).



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

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

#downObject



100
101
102
103
# File 'lib/rubino/ui/completion_menu.rb', line 100

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 token with the dropdown open (e.g. ‘/agents ` showing a subagent id, or `/agents sa_xxx ` showing steer/probe/–stop) is NOT an exact command: Enter ACCEPTS the highlighted candidate, matching the standard picker convention (fzf / VS Code / Claude Code) where a default-highlighted candidate is accepted on Enter without first arrowing onto it (#3). This supersedes the older #147 rule that submitted on an empty argument unless the user had arrow-navigated —under it the user could not pick the highlighted id/verb with a bare Enter (“lo prende come se fosse /agents”). To run the bare command instead, dismiss the dropdown with Esc first, then Enter (a closed menu never accept-splices). A FULLY-TYPED token that exactly equals the sole/selected candidate (`/agents sa_xxx`, `/new`) still submits — that path is non-empty, so it is unaffected. Tab-accept is untouched.

Returns:

  • (Boolean)


139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/rubino/ui/completion_menu.rb', line 139

def exact_command?(buffer)
  return false unless @state

  typed = Array(buffer.chars[@state[:start], @state[:token_len]]).join
  return false 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.



169
170
171
# File 'lib/rubino/ui/completion_menu.rb', line 169

def hide!
  @state = nil
end

#itemsObject

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



43
44
45
# File 'lib/rubino/ui/completion_menu.rb', line 43

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.



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

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)


38
39
40
# File 'lib/rubino/ui/completion_menu.rb', line 38

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).



178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/rubino/ui/completion_menu.rb', line 178

def rows(cols)
  return [] unless @state

  items = @state[:items]
  pad   = items.map { |item| LiveRegion.display_width(item.to_s) }.max.to_i
  descriptors = items.map do |item|
    { label: item.to_s, desc: description(item, pad, cols) }
  end
  MenuView.render(descriptors, cols,
                  window: { selected: @state[:selected], top: @state[:top], max_rows: MAX_ROWS },
                  hints: "Enter accepts · Esc dismisses")
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?).



95
96
97
98
# File 'lib/rubino/ui/completion_menu.rb', line 95

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