Files
luma/Luma/REPLView.swift
Ole André Vadla Ravnås 3f70d259d3 core: Persist pattern placements
Store them with the REPL cell and the address insight, as a JSON
column each, and bind the decode view to them. Older rows and wire
cells without the field decode as none.
2026-10-01 20:49:11 +02:00

997 lines
34 KiB
Swift

import Frida
import SwiftUI
import LumaCore
extension LumaCore.REPLLanguage {
var promptGlyph: String {
switch self {
case .javascript: return "\u{203A}"
case .r2: return "\u{00BB}"
}
}
var promptColor: Color { .secondary }
var inputPlaceholder: String {
switch self {
case .javascript: return "Enter JavaScript\u{2026}"
case .r2: return "Enter an r2 command\u{2026}"
}
}
}
struct REPLView: View {
let sessionID: UUID
let engine: Engine
@Binding var selection: SidebarItemID?
@State private var inputCode: String = ""
@State private var isInputFocused: Bool = false
@State private var mode: LumaCore.REPLLanguage = .javascript
@State private var didRestoreInputState = false
@State private var draftSaveTask: Task<Void, Never>?
@State private var historyCursor: Int = 0
@State private var historyCursorInitialized = false
@State private var cells: [LumaCore.REPLCell] = []
@State private var cellsObservation: StoreObservation?
@State private var expandTick = 0
private var session: LumaCore.ProcessSession? {
engine.sessions.first { $0.id == sessionID }
}
private var node: LumaCore.ProcessNode? {
engine.node(forSessionID: sessionID)
}
private var canSubmit: Bool {
canInteract && hasLiveTarget
}
private var canInteract: Bool {
!engine.collaboration.isCollaborative || engine.collaboration.isOwner
}
private var hasLiveTarget: Bool {
if engine.isHostingNode(sessionID) { return true }
guard let session,
let host = session.host,
host.id != engine.localUserID
else { return false }
return session.phase == .attached || session.phase == .attaching
}
private var orderedCells: [LumaCore.REPLCell] {
cells.sorted { $0.timestamp < $1.timestamp }
}
private var commandHistory: [LumaCore.REPLCell] {
orderedCells.filter { !$0.isSessionBoundary }
}
private var replInactiveMessage: String {
guard let session else { return "Session not attached." }
if case .armed = session.armingState {
if engine.isGatingActive(forDeviceID: session.deviceID) {
return "Waiting for a matching launch — REPL available once captured."
}
return "Armed but inactive — resume spawn gating to capture launches."
}
if let host = session.host,
host.id != engine.localUserID,
engine.node(forSessionID: session.id) == nil,
session.phase == .attached || session.phase == .attaching
{
return "Hosted by @\(host.id) on \(session.deviceName) — REPL runs on the hosting device."
}
if session.lastAttachedAt != nil {
return "Session detached — use \(session.kind.reestablishLabel) to continue."
}
switch session.kind {
case .spawn:
return "Session not attached — arm it from the banner above."
case .attach:
return "Session not attached — use \(session.kind.reestablishLabel) from the banner above."
}
}
private var replInactiveHelp: String {
guard let session else { return "Session not attached." }
if case .armed = session.armingState {
if engine.isGatingActive(forDeviceID: session.deviceID) {
return "REPL becomes available after the next matching launch is captured."
}
return "Spawn gating is paused — resume it from the banner above."
}
if session.lastAttachedAt != nil {
return "Detached — re-establish this session before running commands."
}
switch session.kind {
case .spawn:
return "Arm this session to capture the next matching launch."
case .attach:
return "Re-attach this session to the target process."
}
}
#if canImport(UIKit)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var horizontalInset: CGFloat { horizontalSizeClass == .compact ? 6 : 12 }
#else
private var horizontalInset: CGFloat { 12 }
#endif
var body: some View {
VStack(spacing: 0) {
GeometryReader { outerGeo in
if orderedCells.isEmpty {
REPLEmptyState()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, horizontalInset)
} else {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(orderedCells) { cell in
REPLCellView(
cell: cell,
processName: session?.processName ?? "",
sessionID: sessionID,
engine: engine,
selection: $selection
)
.id(cell.id)
}
}
.frame(
maxWidth: .infinity,
minHeight: outerGeo.size.height,
alignment: .bottomLeading
)
.padding(.horizontal, horizontalInset)
Color.clear
.frame(height: 2)
.id("repl-bottom-anchor")
}
.environment(\.replExpandAction, { expandTick += 1 })
.onAppear {
reloadCells()
scrollToBottom(proxy: proxy)
}
.onChange(of: cells.count) {
scrollToBottom(proxy: proxy)
}
.onChange(of: expandTick) {
scrollToBottom(proxy: proxy)
}
}
}
}
.frame(minHeight: 0, maxHeight: .infinity)
Divider()
HStack(spacing: 8) {
Button {
toggleMode()
} label: {
Text(mode.promptGlyph)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(mode.promptColor)
}
.buttonStyle(.plain)
.help("Switch between JavaScript and r2 input — or type “:” to toggle")
if canSubmit {
REPLInputField(
text: $inputCode,
isFocused: $isInputFocused,
onCommit: { cmd in
runCurrentInput(cmd)
},
onHistoryUp: {
historyPrevious()
},
onHistoryDown: {
historyNext()
},
requestCompletions: { code, cursor in
guard let node = engine.node(forSessionID: sessionID) else { return [] as [LumaCore.REPLCompletion] }
return await node.completeInREPL(code: code, cursor: cursor, language: mode)
},
language: mode,
placeholder: mode.inputPlaceholder
)
.frame(minHeight: 22)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("repl.input")
} else {
ZStack(alignment: .leading) {
REPLInputField(
text: .constant(""),
isFocused: .constant(false),
onCommit: { _ in },
onHistoryUp: {},
onHistoryDown: {},
requestCompletions: { _, _ in [] as [LumaCore.REPLCompletion] },
language: .javascript,
placeholder: ""
)
.disabled(true)
.opacity(0.35)
.frame(minHeight: 22)
.frame(maxWidth: .infinity, alignment: .leading)
Text(replInactiveMessage)
.font(.callout)
.foregroundStyle(.secondary)
.padding(.leading, 4)
}
.help(replInactiveHelp)
}
Button {
runCurrentInput()
} label: {
Image(systemName: "return")
}
.buttonStyle(.borderless)
.help("Run")
.disabled(!canSubmit)
}
.padding(.horizontal, horizontalInset)
.padding(.vertical, 8)
.background(.bar)
}
.onAppear {
if cellsObservation == nil {
cellsObservation = engine.store.observeREPLCells(sessionID: sessionID) { [self] newCells in
Task { @MainActor in
self.cells = newCells
}
}
}
if !didRestoreInputState {
didRestoreInputState = true
inputCode = engine.replDraft(forSessionID: sessionID) ?? ""
mode = engine.replLanguage(forSessionID: sessionID)
}
DispatchQueue.main.async {
isInputFocused = node != nil
if !historyCursorInitialized {
historyCursor = commandHistory.count
historyCursorInitialized = true
}
}
}
.onChange(of: inputCode) {
scheduleDraftSave()
}
.onChange(of: mode) { _, newValue in
engine.setREPLLanguage(sessionID: sessionID, newValue)
}
.contextMenu {
Button {
clearHistory()
} label: {
Label("Clear History", systemImage: "trash")
}
}
}
private func reloadCells() {
cells = (try? engine.store.fetchREPLCells(sessionID: sessionID)) ?? []
}
private func scrollToBottom(proxy: ScrollViewProxy) {
withAnimation {
proxy.scrollTo("repl-bottom-anchor", anchor: .bottom)
}
}
private func clearHistory() {
try? engine.store.deleteREPLCells(sessionID: sessionID)
cells.removeAll()
historyCursor = 0
}
private func runCurrentInput(_ provided: String? = nil) {
let raw = provided ?? inputCode
let code = raw.trimmingCharacters(in: .whitespacesAndNewlines)
inputCode = ""
guard !code.isEmpty else {
isInputFocused = true
return
}
if let switched = modeCommand(code) {
mode = switched
isInputFocused = true
return
}
if engine.isHostedRemotelyLive(sessionID) {
let cellID = UUID()
let placeholder = LumaCore.REPLCell(
id: cellID,
sessionID: sessionID,
code: code,
language: mode,
result: .text("Running…"),
timestamp: Date()
)
try? engine.store.save(placeholder)
engine.collaboration.sendReplEvalRequest(
sessionID: sessionID,
code: code,
language: mode,
cellID: cellID
)
reloadCells()
historyCursor = commandHistory.count
} else if let node {
Task { @MainActor in
await node.evalInREPL(code, language: mode)
reloadCells()
historyCursor = commandHistory.count
}
}
isInputFocused = true
}
private func toggleMode() {
mode = mode == .javascript ? .r2 : .javascript
isInputFocused = true
}
private func modeCommand(_ code: String) -> LumaCore.REPLLanguage? {
switch code {
case ":": return mode == .javascript ? .r2 : .javascript
case ":js", ":javascript": return .javascript
case ":r2": return .r2
default: return nil
}
}
private func historyPrevious() {
let history = commandHistory
guard !history.isEmpty else {
return
}
if historyCursor != 0 {
historyCursor -= 1
}
recall(history[historyCursor])
}
private func historyNext() {
let history = commandHistory
guard !history.isEmpty else {
return
}
if historyCursor < history.count - 1 {
historyCursor += 1
recall(history[historyCursor])
} else {
historyCursor = history.count
inputCode = ""
}
}
private func recall(_ cell: LumaCore.REPLCell) {
inputCode = cell.code
mode = cell.language
}
private func scheduleDraftSave() {
draftSaveTask?.cancel()
let draft = inputCode
draftSaveTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 400_000_000)
guard !Task.isCancelled else { return }
engine.setREPLDraft(sessionID: sessionID, draft.isEmpty ? nil : draft)
}
}
}
private struct REPLEmptyState: View {
var body: some View {
GeometryReader { geo in
VStack {
Spacer(minLength: 0)
VStack(spacing: 24) {
VStack(spacing: 8) {
Image(systemName: "terminal")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("Read-Eval-Print Loop")
.font(.title2.weight(.semibold))
Text("Evaluate JavaScript in the target process,\nor run radare2 commands for disassembly and analysis.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, alignment: .center)
tips
}
.padding(.horizontal, 24)
Spacer(minLength: 0)
}
.frame(width: geo.size.width, height: geo.size.height)
}
}
private var tips: some View {
VStack(alignment: .leading, spacing: 8) {
tip("Type an expression and press Return to evaluate it.")
tip("Click the prompt — or type :js / :r2 — to switch between JavaScript and radare2.")
tip("In JavaScript, try Process.mainModule or Process.enumerateModules().")
tip("In r2 mode, the seek starts at the main module — try x for a hexdump, or afl to list functions.")
tip("Step through previous expressions with ↑ and ↓.")
}
.font(.callout)
}
private func tip(_ text: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("•")
.foregroundStyle(.secondary)
Text(text)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private struct REPLCellView: View {
let cell: LumaCore.REPLCell
let processName: String
let sessionID: UUID
let engine: Engine
@Binding var selection: SidebarItemID?
private var isResultEmpty: Bool {
switch cell.result {
case .text(let s): return s.isEmpty
case .styled(let s): return s.isEmpty
case .js(let v): return v == .undefined
case .binary(let data, _): return data.isEmpty
}
}
private var resultAlignment: VerticalAlignment {
switch cell.result {
case .text, .styled: return .top
case .js, .binary: return .firstTextBaseline
}
}
var body: some View {
if cell.isSessionBoundary {
HStack(spacing: 8) {
Rectangle()
.frame(height: 1)
.foregroundStyle(Color.accentColor.opacity(0.25))
Text("New process attached at \(cell.timestamp.formatted(date: .omitted, time: .shortened))")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize()
Rectangle()
.frame(height: 1)
.foregroundStyle(Color.accentColor.opacity(0.25))
}
.padding(.vertical, 4)
} else {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .top, spacing: 6) {
Text(cell.language.promptGlyph)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(cell.language.promptColor)
ScrollView(.horizontal, showsIndicators: false) {
Text(cell.code)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
Text(cell.timestamp.formatted(date: .omitted, time: .shortened))
.font(.caption2)
.foregroundStyle(.secondary)
.help(cell.timestamp.formatted())
}
if !isResultEmpty {
HStack(alignment: resultAlignment, spacing: 6) {
Text("←")
.foregroundStyle(.secondary)
switch cell.result {
case .text(let s):
REPLResultText(styled: LumaCore.StyledText(s))
case .styled(let s):
REPLResultText(styled: s)
case .js(let v):
JSInspectValueView(
value: v,
sessionID: sessionID,
engine: engine,
selection: $selection
)
case .binary(let data, let meta):
PatternDecodeView(
data: data,
baseAddress: meta?.baseAddress,
sessionID: sessionID,
engine: engine,
placements: Binding(
get: { cell.placements },
set: { engine.setPlacements($0, forREPLCell: cell.id) }
)
)
.fixedSize(horizontal: false, vertical: true)
}
}
.font(.system(.caption, design: .monospaced))
}
}
.padding(.bottom, 4)
.contentShape(Rectangle())
.accessibilityElement(children: .contain)
.accessibilityIdentifier("repl.cell")
.contextMenu {
Button {
addToNotebook()
} label: {
Label("Add to Notebook", systemImage: "book.pages")
}
}
}
}
private func binaryMetaString(_ meta: LumaCore.REPLCell.Result.BinaryMeta?, dataCount: Int) -> String {
var parts: [String] = []
if let ta = meta?.typedArray { parts.append(ta) }
return parts.joined(separator: " • ")
}
private func addToNotebook() {
let (details, styled, binary, jsValue): (String, LumaCore.StyledText?, Data?, JSInspectValue?) = {
switch cell.result {
case .text(let s):
return (s, nil, nil, nil)
case .styled(let s):
return (s.plainText, s, nil, nil)
case .js(let v):
return ("", nil, nil, v)
case .binary(let data, let meta):
let header = binaryMetaString(meta, dataCount: data.count)
return (header, nil, data, nil)
}
}()
var entry = LumaCore.NotebookEntry(
title: cell.code,
details: details,
styledDetails: styled,
binaryData: binary,
sessionID: sessionID,
processName: processName
)
if let jsValue {
entry.jsValue = jsValue
}
engine.addNotebookEntry(entry)
}
}
private struct REPLResultText: View {
let styled: LumaCore.StyledText
private static let chunk = 4096
@State private var revealed = REPLResultText.chunk
@Environment(\.replExpandAction) private var onExpand
var body: some View {
let total = styled.plainText.count
let shown = min(revealed, total)
VStack(alignment: .leading, spacing: 2) {
ScrollView(.horizontal, showsIndicators: true) {
Text(styled.slice(charRange: 0..<shown).attributed)
.textSelection(.enabled)
.fixedSize(horizontal: true, vertical: true)
}
if shown < total {
HStack(spacing: 12) {
Button("Show more") { revealed = min(revealed + Self.chunk, total); onExpand() }
Button("Show all") { revealed = total; onExpand() }
}
.font(.caption2)
.platformLinkButtonStyle()
}
}
}
}
private struct REPLInputField: View {
@Binding var text: String
@Binding var isFocused: Bool
#if !canImport(AppKit)
@FocusState private var focused: Bool
#endif
let onCommit: (String) -> Void
let onHistoryUp: () -> Void
let onHistoryDown: () -> Void
let requestCompletions: (String, Int) async -> [LumaCore.REPLCompletion]
let language: LumaCore.REPLLanguage
let placeholder: String
var body: some View {
#if canImport(AppKit)
REPLInputFieldAppKit(
text: $text,
isFocused: $isFocused,
onCommit: onCommit,
onHistoryUp: onHistoryUp,
onHistoryDown: onHistoryDown,
requestCompletions: requestCompletions,
language: language,
placeholder: placeholder
)
#else
TextField(placeholder, text: $text, axis: .horizontal)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.focused($focused)
.onChange(of: isFocused) { _, newValue in
focused = newValue
}
.onChange(of: focused) { _, newValue in
isFocused = newValue
}
.onSubmit { onCommit(text) }
#endif
}
}
#if canImport(AppKit)
private struct REPLInputFieldAppKit: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
let onCommit: (String) -> Void
let onHistoryUp: () -> Void
let onHistoryDown: () -> Void
let requestCompletions: (String, Int) async -> [LumaCore.REPLCompletion]
let language: LumaCore.REPLLanguage
let placeholder: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSTextField {
let field = NSTextField()
field.cell = REPLTextFieldCell()
field.isBordered = false
field.drawsBackground = false
field.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
field.focusRingType = .none
field.lineBreakMode = .byClipping
field.usesSingleLineMode = true
field.isEditable = true
field.isBezeled = false
field.delegate = context.coordinator
field.isAutomaticTextCompletionEnabled = true
field.suggestionsDelegate = context.coordinator
field.placeholderString = placeholder
field.stringValue = text
context.coordinator.textField = field
return field
}
func updateNSView(_ nsView: NSTextField, context: Context) {
context.coordinator.parent = self
if nsView.placeholderString != placeholder {
nsView.placeholderString = placeholder
}
if text != context.coordinator.lastSyncedText {
context.coordinator.lastSyncedText = text
if nsView.stringValue != text {
nsView.stringValue = text
moveInsertionToEnd(of: nsView)
}
}
if isFocused,
let window = nsView.window,
window.firstResponder != nsView.currentEditor()
{
DispatchQueue.main.async { [weak nsView] in
guard let nsView else { return }
nsView.becomeFirstResponder()
moveInsertionToEnd(of: nsView)
}
}
}
private func moveInsertionToEnd(of field: NSTextField) {
guard let editor = field.currentEditor() else { return }
let end = editor.string.utf16.count
editor.selectedRange = NSRange(location: end, length: 0)
}
class Coordinator: NSObject, NSTextFieldDelegate, NSTextSuggestionsDelegate {
typealias SuggestionItemType = String
var parent: REPLInputFieldAppKit
weak var textField: NSTextField?
var completionBaseText: String?
var completionBaseCursor: Int?
var lastSyncedText: String
init(_ parent: REPLInputFieldAppKit) {
self.parent = parent
self.lastSyncedText = parent.text
}
func controlTextDidChange(_ obj: Notification) {
guard let field = textField else { return }
lastSyncedText = field.stringValue
parent.text = field.stringValue
}
func controlTextDidEndEditing(_ obj: Notification) {
parent.isFocused = false
}
func control(
_ control: NSControl,
textView: NSTextView,
doCommandBy commandSelector: Selector
) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:))
|| commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:))
{
let current = textView.string
parent.onCommit(current)
lastSyncedText = ""
parent.text = ""
textField?.stringValue = ""
completionBaseText = nil
completionBaseCursor = nil
if let field = textField, let window = field.window {
window.endEditing(for: nil)
window.makeFirstResponder(field)
if let editor = field.currentEditor() {
editor.string = ""
editor.selectedRange = NSRange(location: 0, length: 0)
}
}
parent.isFocused = true
return true
}
if commandSelector == #selector(NSResponder.moveUp(_:)) {
parent.onHistoryUp()
return true
}
if commandSelector == #selector(NSResponder.moveDown(_:)) {
parent.onHistoryDown()
return true
}
return false
}
func textField(
_ textField: NSTextField,
provideUpdatedSuggestions responseHandler: @escaping (ItemResponse) -> Void
) {
guard let editor = textField.currentEditor() else {
var empty = ItemResponse(itemSections: [])
empty.phase = .final
responseHandler(empty)
return
}
let codeSnapshot = editor.string
let cursorSnapshot = editor.selectedRange.location
self.completionBaseText = codeSnapshot
self.completionBaseCursor = cursorSnapshot
if codeSnapshot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
var empty = ItemResponse(itemSections: [])
empty.phase = .final
responseHandler(empty)
return
}
Task { @MainActor in
let suggestions = await self.parent.requestCompletions(codeSnapshot, cursorSnapshot)
guard editor.string == codeSnapshot, !suggestions.isEmpty else {
var empty = ItemResponse(itemSections: [])
empty.phase = .final
responseHandler(empty)
return
}
let items: [Item] = suggestions.map { suggestion in
var item = NSSuggestionItem(representedValue: suggestion.insertText, title: suggestion.displayText)
item.secondaryTitle = suggestion.detailText
return item
}
let section = NSSuggestionItemSection(items: items)
var response = ItemResponse(itemSections: [section])
response.phase = .final
responseHandler(response)
}
}
func textField(_ textField: NSTextField, textCompletionFor item: Item) -> String? {
completedText(for: item)
}
private func completedText(for item: Item) -> String? {
guard let baseText = completionBaseText,
let baseCursor = completionBaseCursor
else {
return item.representedValue
}
let nsText = baseText as NSString
let length = nsText.length
let cursor = min(max(baseCursor, 0), length)
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._$"))
var start = cursor
while start > 0 {
let ch = nsText.character(at: start - 1)
if let scalar = UnicodeScalar(ch), allowed.contains(scalar) {
start -= 1
} else {
break
}
}
var end = cursor
while end < length {
let ch = nsText.character(at: end)
if let scalar = UnicodeScalar(ch), allowed.contains(scalar) {
end += 1
} else {
break
}
}
let tokenRange = NSRange(location: start, length: end - start)
let token = nsText.substring(with: tokenRange)
let before = nsText.substring(to: start)
let after = nsText.substring(from: end)
let symbol: String = item.representedValue
if parent.language == .r2 {
return before + symbol + after
}
let newToken: String
if let dotIndex = token.lastIndex(of: ".") {
let baseExpr = String(token[..<dotIndex])
if baseExpr.isEmpty {
newToken = symbol
} else {
newToken = baseExpr + "." + symbol
}
} else {
newToken = symbol
}
return before + newToken + after
}
func textField(_ textField: NSTextField, didSelect item: Item) {
let completed = completedText(for: item) ?? item.representedValue
guard let editor = textField.currentEditor() else {
lastSyncedText = completed
parent.text = completed
parent.isFocused = true
return
}
editor.string = completed
let end = (completed as NSString).length
editor.selectedRange = NSRange(location: end, length: 0)
lastSyncedText = completed
parent.text = completed
}
}
}
private final class REPLTextFieldCell: NSTextFieldCell {
private lazy var replEditorInstance: NSTextView = {
let editor = NSTextView()
editor.isRichText = false
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.isAutomaticDataDetectionEnabled = false
editor.isAutomaticLinkDetectionEnabled = false
editor.isAutomaticSpellingCorrectionEnabled = false
editor.isContinuousSpellCheckingEnabled = false
return editor
}()
override func fieldEditor(for controlView: NSView) -> NSTextView? {
replEditorInstance
}
func replEditor() -> NSTextView {
replEditorInstance
}
}
#endif
private struct REPLExpandActionKey: EnvironmentKey {
static let defaultValue: () -> Void = {}
}
extension EnvironmentValues {
var replExpandAction: () -> Void {
get { self[REPLExpandActionKey.self] }
set { self[REPLExpandActionKey.self] = newValue }
}
}