Skip to content

Commit 8a1f971

Browse files
committed
feat: add inline completion (ghost text) provider and Tab-accept
Add the inline completion plumbing that drives the CodeEditTextView ghost text primitive. A delegate provides completion items, the controller renders the selected item as ghost text anchored at the caret, and keyboard shortcuts accept, dismiss, and cycle through items. - InlineCompletionItem: Identifiable item holding insertText and the range it replaces on acceptance (an empty range is a pure insertion). - InlineCompletionDelegate: @mainactor protocol to request items and observe the show, accept, and dismiss lifecycle, with default empty impls. - InlineCompletionTriggerModel: requests after typing and clears stale ghost text, mirroring SuggestionTriggerCharacterModel. - TextViewController+InlineCompletion: debounced single in-flight request, render, accept, dismiss, and next/previous cycling. - Wire triggers and clears into didReplaceContentsIn and updateCursorPosition, and add Tab-accept, Escape-dismiss, and Option+]/[ cycling key handling. - SourceEditor and TextViewController gain a weak inlineCompletionDelegate, forwarded exactly like completionDelegate.
1 parent 1fa4d3c commit 8a1f971

10 files changed

Lines changed: 566 additions & 6 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// InlineCompletionDelegate.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by anxkhn on 6/29/26.
6+
//
7+
8+
@MainActor
9+
public protocol InlineCompletionDelegate: AnyObject {
10+
/// Requests inline completion items for the given cursor position.
11+
///
12+
/// Called after the user types, debounced by the controller. Return an empty array to display nothing.
13+
func inlineCompletionsRequested(
14+
textView: TextViewController,
15+
cursorPosition: CursorPosition
16+
) async -> [InlineCompletionItem]
17+
18+
/// Called when an item begins displaying as ghost text.
19+
func inlineCompletionDidShow(item: InlineCompletionItem)
20+
21+
/// Called when an item is accepted and inserted into the document.
22+
func inlineCompletionDidAccept(item: InlineCompletionItem)
23+
24+
/// Called when a displayed item is dismissed without being accepted.
25+
func inlineCompletionDidDismiss(item: InlineCompletionItem)
26+
}
27+
28+
public extension InlineCompletionDelegate {
29+
func inlineCompletionDidShow(item: InlineCompletionItem) { }
30+
func inlineCompletionDidAccept(item: InlineCompletionItem) { }
31+
func inlineCompletionDidDismiss(item: InlineCompletionItem) { }
32+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// InlineCompletionItem.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by anxkhn on 6/29/26.
6+
//
7+
8+
import Foundation
9+
10+
/// Represents a single inline completion (ghost text) suggestion.
11+
///
12+
/// An item is rendered as ghost text anchored at the caret. When accepted, the ``insertText`` replaces the characters
13+
/// in ``range``. An empty ``range`` represents a pure insertion at the cursor.
14+
public struct InlineCompletionItem: Sendable, Identifiable {
15+
/// A stable identifier for the item.
16+
public let id: UUID
17+
18+
/// The text to insert when the item is accepted. This is also the text rendered as ghost text.
19+
public let insertText: String
20+
21+
/// The range the ``insertText`` replaces when accepted. An empty range is a pure insertion at the cursor.
22+
public let range: NSRange
23+
24+
/// Create an inline completion item.
25+
/// - Parameters:
26+
/// - id: A stable identifier for the item. Defaults to a new `UUID`.
27+
/// - insertText: The text to insert when accepted, and the text rendered as ghost text.
28+
/// - range: The range replaced on acceptance. An empty range is a pure insertion at the cursor.
29+
public init(id: UUID = UUID(), insertText: String, range: NSRange) {
30+
self.id = id
31+
self.insertText = insertText
32+
self.range = range
33+
}
34+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//
2+
// InlineCompletionTriggerModel.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by anxkhn on 6/29/26.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
import TextStory
11+
12+
/// Triggers an inline completion (ghost text) request when the user edits text.
13+
/// Designed to be called in the ``TextViewDelegate``'s didReplaceCharacters method.
14+
///
15+
/// Mirrors ``SuggestionTriggerCharacterModel``: it lives as effectively a text view delegate so that both the text
16+
/// contents and the caret are up-to-date when it runs. Any mutation clears the current ghost text before a new
17+
/// request is debounced. Ghost text and the existing suggestion popup are mutually exclusive, so requests are skipped
18+
/// while ``SuggestionController`` is visible.
19+
@MainActor
20+
final class InlineCompletionTriggerModel {
21+
weak var controller: TextViewController?
22+
23+
func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) {
24+
guard let controller, controller.inlineCompletionDelegate != nil else {
25+
return
26+
}
27+
28+
// Any edit invalidates the currently displayed ghost text.
29+
controller.dismissInlineSuggestion()
30+
31+
// Ghost text and the existing suggestion popup are mutually exclusive.
32+
guard !SuggestionController.shared.isVisible else {
33+
return
34+
}
35+
36+
let mutation = TextMutation(
37+
string: string,
38+
range: range,
39+
limit: textView.textStorage.length
40+
)
41+
42+
// Only request after an insertion (typing), not after a deletion.
43+
guard mutation.delta > 0 else {
44+
return
45+
}
46+
47+
controller.requestInlineSuggestion()
48+
}
49+
}

Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ extension TextViewController {
8585
if let position = cursorPositions.first {
8686
suggestionTriggerModel.selectionUpdated(position)
8787
}
88+
89+
// Dismiss ghost text when the cursor moves off the suggestion's anchor.
90+
if activeInlineSuggestion != nil,
91+
let anchor = inlineSuggestionAnchor,
92+
cursorPositions.first?.range != NSRange(location: anchor, length: 0) {
93+
dismissInlineSuggestion()
94+
}
8895
}
8996

9097
/// Fills out all properties on the given cursor position if it's missing either the range or line/column
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
//
2+
// TextViewController+InlineCompletion.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by anxkhn on 6/29/26.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
11+
public extension TextViewController {
12+
/// The debounce interval applied before an inline completion request is sent to the delegate.
13+
private static var inlineCompletionDebounce: Duration { .milliseconds(200) }
14+
15+
/// Requests inline completion items from the ``inlineCompletionDelegate``.
16+
///
17+
/// Debounced with a single in-flight cancellable task. Each call cancels any pending request. Requests are skipped
18+
/// while the suggestion popup is visible, so ghost text and the popup never appear at the same time.
19+
func requestInlineSuggestion() {
20+
inlineCompletionTask?.cancel()
21+
inlineCompletionTask = nil
22+
23+
guard let delegate = inlineCompletionDelegate,
24+
!SuggestionController.shared.isVisible,
25+
let selectedRange = textView.selectionManager.textSelections.first?.range,
26+
let cursorPosition = resolveCursorPosition(CursorPosition(range: selectedRange)) else {
27+
return
28+
}
29+
30+
inlineCompletionTask = Task { [weak self] in
31+
do {
32+
try await Task.sleep(for: Self.inlineCompletionDebounce)
33+
try Task.checkCancellation()
34+
35+
guard let self else { return }
36+
37+
let items = await delegate.inlineCompletionsRequested(
38+
textView: self,
39+
cursorPosition: cursorPosition
40+
)
41+
42+
try Task.checkCancellation()
43+
guard !items.isEmpty, !SuggestionController.shared.isVisible else {
44+
return
45+
}
46+
47+
self.setInlineSuggestions(items)
48+
} catch {
49+
return
50+
}
51+
}
52+
}
53+
54+
/// Renders the given inline completion items as ghost text, displaying the item at `selectedIndex`.
55+
///
56+
/// Stores the items and renders the selected item's ``InlineCompletionItem/insertText`` as ghost text anchored at
57+
/// the caret. Notifies the delegate via ``InlineCompletionDelegate/inlineCompletionDidShow(item:)``.
58+
/// - Parameters:
59+
/// - items: The items to make available. Passing an empty array is a no-op.
60+
/// - selectedIndex: The index of the item to display. Clamped to a valid index.
61+
func setInlineSuggestions(_ items: [InlineCompletionItem], selectedIndex: Int = 0) {
62+
guard !items.isEmpty, !SuggestionController.shared.isVisible else {
63+
return
64+
}
65+
66+
inlineSuggestionItems = items
67+
inlineSuggestionSelectedIndex = min(max(0, selectedIndex), items.count - 1)
68+
let item = items[inlineSuggestionSelectedIndex]
69+
activeInlineSuggestion = item
70+
71+
let offset = textView.selectionManager.textSelections.first?.range.location ?? item.range.location
72+
inlineSuggestionAnchor = offset
73+
textView.setInlineSuggestion(item.insertText, at: offset)
74+
75+
inlineCompletionDelegate?.inlineCompletionDidShow(item: item)
76+
}
77+
78+
/// Accepts the active inline suggestion, inserting its text into the document.
79+
///
80+
/// Replaces the characters in the item's range with its ``InlineCompletionItem/insertText``, clears the ghost
81+
/// text, and notifies the delegate.
82+
/// - Returns: `true` if a suggestion was accepted, otherwise `false`.
83+
@discardableResult
84+
func acceptInlineSuggestion() -> Bool {
85+
guard let item = activeInlineSuggestion else {
86+
return false
87+
}
88+
89+
// Clear state before mutating so the edit doesn't re-enter this suggestion's lifecycle.
90+
clearInlineSuggestionState()
91+
textView.clearInlineSuggestion()
92+
textView.replaceCharacters(in: item.range, with: item.insertText)
93+
94+
inlineCompletionDelegate?.inlineCompletionDidAccept(item: item)
95+
return true
96+
}
97+
98+
/// Cycles to the next inline suggestion and re-renders it.
99+
func selectNextInlineSuggestion() {
100+
guard !inlineSuggestionItems.isEmpty else { return }
101+
let next = (inlineSuggestionSelectedIndex + 1) % inlineSuggestionItems.count
102+
setInlineSuggestions(inlineSuggestionItems, selectedIndex: next)
103+
}
104+
105+
/// Cycles to the previous inline suggestion and re-renders it.
106+
func selectPreviousInlineSuggestion() {
107+
guard !inlineSuggestionItems.isEmpty else { return }
108+
let count = inlineSuggestionItems.count
109+
let previous = (inlineSuggestionSelectedIndex - 1 + count) % count
110+
setInlineSuggestions(inlineSuggestionItems, selectedIndex: previous)
111+
}
112+
113+
/// Dismisses the active inline suggestion without inserting it.
114+
///
115+
/// Clears the ghost text and notifies the delegate.
116+
/// - Returns: `true` if a suggestion was dismissed, otherwise `false`.
117+
@discardableResult
118+
func dismissInlineSuggestion() -> Bool {
119+
guard let item = activeInlineSuggestion else {
120+
return false
121+
}
122+
123+
clearInlineSuggestionState()
124+
textView.clearInlineSuggestion()
125+
126+
inlineCompletionDelegate?.inlineCompletionDidDismiss(item: item)
127+
return true
128+
}
129+
130+
/// Resets all stored inline suggestion state and cancels any in-flight request.
131+
private func clearInlineSuggestionState() {
132+
inlineCompletionTask?.cancel()
133+
inlineCompletionTask = nil
134+
activeInlineSuggestion = nil
135+
inlineSuggestionItems = []
136+
inlineSuggestionSelectedIndex = 0
137+
inlineSuggestionAnchor = nil
138+
}
139+
}

Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ extension TextViewController {
247247
let commandKey = NSEvent.ModifierFlags.command
248248
let controlKey = NSEvent.ModifierFlags.control
249249

250+
// Inline completion (ghost text) keys take precedence while a suggestion is active.
251+
if handleInlineCompletionKeyDown(event: event, modifierFlags: modifierFlags) {
252+
return nil
253+
}
254+
250255
switch (modifierFlags, event.charactersIgnoringModifiers) {
251256
case (commandKey, "/"):
252257
handleCommandSlash()
@@ -262,12 +267,7 @@ extension TextViewController {
262267
self.findViewController?.showFindPanel()
263268
return nil
264269
case (.init(rawValue: 0), "\u{1b}"): // Escape key
265-
if findViewController?.viewModel.isShowingFindPanel == true {
266-
self.findViewController?.hideFindPanel()
267-
return nil
268-
}
269-
// Attempt to show completions otherwise
270-
return handleShowCompletions(event)
270+
return handleEscapeKey(event)
271271
case (controlKey, " "):
272272
return handleShowCompletions(event)
273273
case ([NSEvent.ModifierFlags.command, NSEvent.ModifierFlags.control], "j"):
@@ -281,6 +281,40 @@ extension TextViewController {
281281
}
282282
}
283283

284+
/// Handles the Escape key when no inline completion is active: hides the find panel if it is showing, otherwise
285+
/// toggles the completion suggestions.
286+
private func handleEscapeKey(_ event: NSEvent) -> NSEvent? {
287+
if findViewController?.viewModel.isShowingFindPanel == true {
288+
self.findViewController?.hideFindPanel()
289+
return nil
290+
}
291+
// Attempt to show completions otherwise
292+
return handleShowCompletions(event)
293+
}
294+
295+
/// Handles key events that control an active inline completion (ghost text).
296+
///
297+
/// While a suggestion is active, Escape dismisses it and Option+`]` / Option+`[` cycle through the available items.
298+
/// When no suggestion is active this is a no-op so the keys keep their normal behavior.
299+
/// - Returns: `true` if the event was consumed by the inline completion.
300+
private func handleInlineCompletionKeyDown(event: NSEvent, modifierFlags: NSEvent.ModifierFlags) -> Bool {
301+
guard activeInlineSuggestion != nil else { return false }
302+
303+
switch (modifierFlags, event.charactersIgnoringModifiers) {
304+
case (.option, "]"):
305+
selectNextInlineSuggestion()
306+
return true
307+
case (.option, "["):
308+
selectPreviousInlineSuggestion()
309+
return true
310+
case (.init(rawValue: 0), "\u{1b}"): // Escape key
311+
dismissInlineSuggestion()
312+
return true
313+
default:
314+
return false
315+
}
316+
}
317+
284318
/// Handles the tab key event.
285319
/// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines
286320
/// are highlighted and handles indenting accordingly.
@@ -289,6 +323,12 @@ extension TextViewController {
289323
func handleTab(event: NSEvent, modifierFlags: UInt) -> NSEvent? {
290324
let shiftKey = NSEvent.ModifierFlags.shift.rawValue
291325

326+
// Accept ghost text with an unmodified Tab, but only when the suggestion popup isn't also showing.
327+
if modifierFlags == 0, activeInlineSuggestion != nil, !SuggestionController.shared.isVisible {
328+
acceptInlineSuggestion()
329+
return nil
330+
}
331+
292332
if modifierFlags == shiftKey {
293333
handleIndent(inwards: true)
294334
} else {

Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ extension TextViewController: TextViewDelegate {
2929
}
3030

3131
suggestionTriggerModel.textView(textView, didReplaceContentsIn: range, with: string)
32+
inlineCompletionTriggerModel.textView(textView, didReplaceContentsIn: range, with: string)
3233
}
3334

3435
public func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool {

0 commit comments

Comments
 (0)