- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连 - M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire - M1.5: tmux -CC 原生 tab(MVP) - 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/ - 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口) - 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名 - 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1117 lines
32 KiB
Swift
1117 lines
32 KiB
Swift
import Foundation
|
|
import GhosttyTerminal
|
|
|
|
actor Engine {
|
|
private enum EscapeState {
|
|
case none
|
|
case escape
|
|
case csi(Data)
|
|
}
|
|
|
|
private let shell: ShellDefinition
|
|
private let sessionBridge: SessionBridge
|
|
private var startedAt = Date()
|
|
private var currentInput = ""
|
|
private var cursorPosition = 0
|
|
private var isTerminated = false
|
|
private var pendingText = Data()
|
|
private var escapeState = EscapeState.none
|
|
private var ignoreNextLineFeed = false
|
|
private var hasStarted = false
|
|
private var commandHistory: [String] = []
|
|
private var historyIndex = -1
|
|
private var savedInput = ""
|
|
private var pendingResizeRedrawTask: Task<Void, Never>?
|
|
private var renderedInputRevision: UInt64 = 0
|
|
private var renderedInputState = TerminalRenderedInputState(
|
|
totalLineCount: 1,
|
|
cursorLineOffset: 0,
|
|
cursorColumn: 1
|
|
)
|
|
private var terminalSize = InMemoryTerminalViewport(
|
|
columns: 80,
|
|
rows: 20,
|
|
widthPixels: 0,
|
|
heightPixels: 0
|
|
)
|
|
|
|
init(shell: ShellDefinition, sessionBridge: SessionBridge) {
|
|
self.shell = shell
|
|
self.sessionBridge = sessionBridge
|
|
}
|
|
|
|
func start() {
|
|
guard !hasStarted else {
|
|
return
|
|
}
|
|
|
|
hasStarted = true
|
|
isTerminated = false
|
|
startedAt = Date()
|
|
send("\u{1B}[2J\u{1B}[H")
|
|
send(shell.welcomeMessage)
|
|
sendPrompt()
|
|
}
|
|
|
|
func updateSize(_ size: InMemoryTerminalViewport) {
|
|
let previous = terminalSize
|
|
terminalSize = size
|
|
|
|
shellDebugLog(
|
|
.metrics,
|
|
"shell resize cols=\(previous.columns)x\(previous.rows) -> \(size.columns)x\(size.rows) pixels=\(size.widthPixels)x\(size.heightPixels)"
|
|
)
|
|
|
|
guard hasStarted, !isTerminated else { return }
|
|
guard previous != size else { return }
|
|
|
|
shellDebugLog(
|
|
.actions,
|
|
"shell redraw after resize input=\(shellDebugDescribe(currentInput)) cursorPosition=\(cursorPosition)"
|
|
)
|
|
redrawInputLine()
|
|
|
|
pendingResizeRedrawTask?.cancel()
|
|
let expectedRevision = renderedInputRevision
|
|
pendingResizeRedrawTask = Task { [self] in
|
|
try? await Task.sleep(nanoseconds: 75_000_000)
|
|
guard !Task.isCancelled else { return }
|
|
redrawInputLineIfViewportStable(
|
|
size,
|
|
expectedRevision: expectedRevision
|
|
)
|
|
}
|
|
}
|
|
|
|
func handleOutbound(_ data: Data) {
|
|
guard !isTerminated else {
|
|
return
|
|
}
|
|
|
|
for byte in data {
|
|
handle(byte)
|
|
}
|
|
flushPendingText()
|
|
}
|
|
|
|
// MARK: - Byte Handling
|
|
|
|
private func handle(_ byte: UInt8) {
|
|
switch escapeState {
|
|
case .escape:
|
|
flushPendingText()
|
|
if byte == 0x5B {
|
|
escapeState = .csi(Data())
|
|
} else if byte == 0x4F {
|
|
escapeState = .csi(Data())
|
|
} else {
|
|
escapeState = .none
|
|
// Meta/Option often arrives as ESC followed by an ASCII byte
|
|
// (for example ESC-b / ESC-f word motion on macOS).
|
|
handleMeta(byte)
|
|
}
|
|
return
|
|
|
|
case var .csi(buffer):
|
|
if (0x40 ... 0x7E).contains(byte) {
|
|
escapeState = .none
|
|
handleCSI(buffer, finalByte: byte)
|
|
} else if buffer.count >= 64 {
|
|
// Cap the CSI parameter buffer to guard against a peer that
|
|
// sends ESC[ followed by an unbounded stream of intermediate
|
|
// bytes, which would otherwise grow memory until the process
|
|
// is killed.
|
|
escapeState = .none
|
|
} else {
|
|
buffer.append(byte)
|
|
escapeState = .csi(buffer)
|
|
}
|
|
return
|
|
|
|
case .none:
|
|
break
|
|
}
|
|
|
|
switch byte {
|
|
case 0x1B:
|
|
flushPendingText()
|
|
escapeState = .escape
|
|
|
|
case 0x01:
|
|
flushPendingText()
|
|
moveCursorToStart()
|
|
|
|
case 0x02:
|
|
flushPendingText()
|
|
moveCursorLeft()
|
|
|
|
case 0x03:
|
|
flushPendingText()
|
|
currentInput.removeAll(keepingCapacity: true)
|
|
cursorPosition = 0
|
|
resetHistoryState()
|
|
send("^C\r\n")
|
|
sendPrompt()
|
|
|
|
case 0x05:
|
|
flushPendingText()
|
|
moveCursorToEnd()
|
|
|
|
case 0x06:
|
|
flushPendingText()
|
|
moveCursorRight()
|
|
|
|
case 0x0C:
|
|
flushPendingText()
|
|
currentInput.removeAll(keepingCapacity: true)
|
|
cursorPosition = 0
|
|
resetHistoryState()
|
|
send("\u{1B}[2J\u{1B}[H")
|
|
sendPrompt()
|
|
|
|
case 0x0B:
|
|
flushPendingText()
|
|
killToEndOfLine()
|
|
|
|
case 0x15:
|
|
flushPendingText()
|
|
killLine()
|
|
|
|
case 0x17:
|
|
flushPendingText()
|
|
deleteBackwardShellWord()
|
|
|
|
case 0x08, 0x7F:
|
|
flushPendingText()
|
|
deleteBackward()
|
|
|
|
case 0x0D:
|
|
flushPendingText()
|
|
ignoreNextLineFeed = true
|
|
submitCurrentInput()
|
|
|
|
case 0x0A:
|
|
flushPendingText()
|
|
if ignoreNextLineFeed {
|
|
ignoreNextLineFeed = false
|
|
return
|
|
}
|
|
|
|
submitCurrentInput()
|
|
|
|
case 0x09:
|
|
flushPendingText()
|
|
// The demo shell does not implement completion, but it also cannot
|
|
// keep a literal HT byte in the line buffer because redraw and
|
|
// cursor math operate on visible cell widths. Expanding tab to the
|
|
// next visual stop keeps the host-managed shell stable while still
|
|
// giving the key an observable effect for input testing.
|
|
insertText(
|
|
terminalExpandedTabText(
|
|
promptDisplayWidth: shell.promptDisplayWidth,
|
|
input: currentInput,
|
|
cursorPosition: cursorPosition,
|
|
terminalColumns: Int(terminalSize.columns)
|
|
)
|
|
)
|
|
|
|
default:
|
|
guard byte >= 0x20 else {
|
|
return
|
|
}
|
|
|
|
pendingText.append(byte)
|
|
}
|
|
}
|
|
|
|
private func handleCSI(_ params: Data, finalByte: UInt8) {
|
|
// CSI params carry modifier suffixes such as `1;3D` for Alt-Left, so
|
|
// dispatch on the decoded editing action instead of `finalByte` alone.
|
|
switch terminalCSIEditingAction(params: params, finalByte: finalByte) {
|
|
case .historyUp:
|
|
navigateHistory(direction: .up)
|
|
|
|
case .historyDown:
|
|
navigateHistory(direction: .down)
|
|
|
|
case .moveCursorRight:
|
|
moveCursorRight()
|
|
|
|
case .moveCursorLeft:
|
|
moveCursorLeft()
|
|
|
|
case .moveCursorBackwardWord:
|
|
moveCursorBackwardWord()
|
|
|
|
case .moveCursorForwardWord:
|
|
moveCursorForwardWord()
|
|
|
|
case .moveCursorToStart:
|
|
moveCursorToStart()
|
|
|
|
case .moveCursorToEnd:
|
|
moveCursorToEnd()
|
|
|
|
case .deleteForward:
|
|
deleteForward()
|
|
|
|
case .deleteForwardWord:
|
|
deleteForwardWord()
|
|
|
|
case nil:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Cursor Movement
|
|
|
|
private func moveCursorLeft() {
|
|
guard cursorPosition > 0 else { return }
|
|
cursorPosition -= 1
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func moveCursorRight() {
|
|
guard cursorPosition < currentInput.count else { return }
|
|
cursorPosition += 1
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func moveCursorToStart() {
|
|
guard cursorPosition > 0 else {
|
|
return
|
|
}
|
|
cursorPosition = 0
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func moveCursorToEnd() {
|
|
guard cursorPosition < currentInput.count else {
|
|
return
|
|
}
|
|
cursorPosition = currentInput.count
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func moveCursorBackwardWord() {
|
|
let nextCursorPosition = terminalPreviousWordBoundary(
|
|
in: currentInput,
|
|
from: cursorPosition
|
|
)
|
|
guard nextCursorPosition != cursorPosition else { return }
|
|
cursorPosition = nextCursorPosition
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func moveCursorForwardWord() {
|
|
let nextCursorPosition = terminalNextWordBoundary(
|
|
in: currentInput,
|
|
from: cursorPosition
|
|
)
|
|
guard nextCursorPosition != cursorPosition else { return }
|
|
cursorPosition = nextCursorPosition
|
|
redrawInputLine()
|
|
}
|
|
|
|
// MARK: - Editing
|
|
|
|
private func insertText(_ text: String) {
|
|
let previousInput = currentInput
|
|
let previousCursorPosition = cursorPosition
|
|
let idx = currentInput.index(currentInput.startIndex, offsetBy: cursorPosition)
|
|
currentInput.insert(contentsOf: text, at: idx)
|
|
cursorPosition += text.count
|
|
|
|
if applyIncrementalAppendIfPossible(
|
|
insertedText: text,
|
|
previousInput: previousInput,
|
|
previousCursorPosition: previousCursorPosition
|
|
) {
|
|
return
|
|
}
|
|
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func deleteBackward() {
|
|
guard cursorPosition > 0 else {
|
|
return
|
|
}
|
|
|
|
let idx = currentInput.index(currentInput.startIndex, offsetBy: cursorPosition - 1)
|
|
currentInput.remove(at: idx)
|
|
cursorPosition -= 1
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func deleteBackwardWord() {
|
|
let result = terminalDeleteBackwardWord(
|
|
input: currentInput,
|
|
cursorPosition: cursorPosition
|
|
)
|
|
guard result.cursorPosition != cursorPosition else { return }
|
|
currentInput = result.input
|
|
cursorPosition = result.cursorPosition
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func deleteBackwardShellWord() {
|
|
let result = terminalDeleteBackwardShellWord(
|
|
input: currentInput,
|
|
cursorPosition: cursorPosition
|
|
)
|
|
guard result.cursorPosition != cursorPosition else { return }
|
|
currentInput = result.input
|
|
cursorPosition = result.cursorPosition
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func deleteForward() {
|
|
guard cursorPosition < currentInput.count else {
|
|
return
|
|
}
|
|
|
|
let idx = currentInput.index(currentInput.startIndex, offsetBy: cursorPosition)
|
|
currentInput.remove(at: idx)
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func deleteForwardWord() {
|
|
let result = terminalDeleteForwardWord(
|
|
input: currentInput,
|
|
cursorPosition: cursorPosition
|
|
)
|
|
guard result.input != currentInput else { return }
|
|
currentInput = result.input
|
|
cursorPosition = result.cursorPosition
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func killLine() {
|
|
guard !currentInput.isEmpty else {
|
|
return
|
|
}
|
|
|
|
currentInput.removeAll(keepingCapacity: true)
|
|
cursorPosition = 0
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func killToEndOfLine() {
|
|
guard cursorPosition < currentInput.count else { return }
|
|
currentInput.removeSubrange(
|
|
currentInput.index(currentInput.startIndex, offsetBy: cursorPosition) ..< currentInput.endIndex
|
|
)
|
|
redrawInputLine()
|
|
}
|
|
|
|
// MARK: - History
|
|
|
|
private enum HistoryDirection {
|
|
case up
|
|
case down
|
|
}
|
|
|
|
private func navigateHistory(direction: HistoryDirection) {
|
|
guard !commandHistory.isEmpty else {
|
|
return
|
|
}
|
|
|
|
switch direction {
|
|
case .up:
|
|
if historyIndex < 0 {
|
|
savedInput = currentInput
|
|
historyIndex = commandHistory.count - 1
|
|
} else if historyIndex > 0 {
|
|
historyIndex -= 1
|
|
} else {
|
|
return
|
|
}
|
|
|
|
case .down:
|
|
guard historyIndex >= 0 else {
|
|
return
|
|
}
|
|
if historyIndex < commandHistory.count - 1 {
|
|
historyIndex += 1
|
|
} else {
|
|
historyIndex = -1
|
|
currentInput = savedInput
|
|
cursorPosition = currentInput.count
|
|
redrawInputLine()
|
|
return
|
|
}
|
|
}
|
|
|
|
currentInput = commandHistory[historyIndex]
|
|
cursorPosition = currentInput.count
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func resetHistoryState() {
|
|
historyIndex = -1
|
|
savedInput = ""
|
|
}
|
|
|
|
// MARK: - Text Handling
|
|
|
|
private func flushPendingText() {
|
|
guard !pendingText.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let (text, leftover) = decodeUTF8Incrementally(pendingText)
|
|
pendingText = leftover
|
|
|
|
guard !text.isEmpty else {
|
|
return
|
|
}
|
|
|
|
insertText(text)
|
|
}
|
|
|
|
private func submitCurrentInput() {
|
|
send("\r\n")
|
|
|
|
let command = currentInput.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
currentInput.removeAll(keepingCapacity: true)
|
|
cursorPosition = 0
|
|
|
|
if !command.isEmpty {
|
|
commandHistory.append(command)
|
|
}
|
|
resetHistoryState()
|
|
|
|
switch shell.processCommand(
|
|
command,
|
|
username: NSUserName(),
|
|
terminalSize: terminalSize
|
|
) {
|
|
case let .output(output):
|
|
if !output.isEmpty {
|
|
send(output)
|
|
}
|
|
sendPrompt()
|
|
|
|
case .clear:
|
|
send("\u{1B}[2J\u{1B}[H")
|
|
sendPrompt()
|
|
|
|
case .exit:
|
|
isTerminated = true
|
|
send("logout\r\n")
|
|
sessionBridge.session?.finish(
|
|
exitCode: 0,
|
|
runtimeMilliseconds: elapsedMilliseconds
|
|
)
|
|
}
|
|
}
|
|
|
|
private func sendPrompt() {
|
|
send(shell.prompt)
|
|
renderedInputState = terminalRenderedInputState(
|
|
promptDisplayWidth: shell.promptDisplayWidth,
|
|
input: currentInput,
|
|
cursorPosition: cursorPosition,
|
|
terminalColumns: Int(terminalSize.columns)
|
|
)
|
|
renderedInputRevision &+= 1
|
|
}
|
|
|
|
private func redrawInputLine() {
|
|
let nextState = terminalRenderedInputState(
|
|
promptDisplayWidth: shell.promptDisplayWidth,
|
|
input: currentInput,
|
|
cursorPosition: cursorPosition,
|
|
terminalColumns: Int(terminalSize.columns)
|
|
)
|
|
let renderedEndState = terminalRenderedInputState(
|
|
promptDisplayWidth: shell.promptDisplayWidth,
|
|
input: currentInput,
|
|
cursorPosition: currentInput.count,
|
|
terminalColumns: Int(terminalSize.columns)
|
|
)
|
|
let linesToClear = max(
|
|
renderedInputState.totalLineCount,
|
|
nextState.totalLineCount
|
|
)
|
|
|
|
shellDebugLog(
|
|
.actions,
|
|
"shell redraw promptWidth=\(shell.promptDisplayWidth) input=\(shellDebugDescribe(currentInput)) cursorPosition=\(cursorPosition) previousLines=\(renderedInputState.totalLineCount) nextLines=\(nextState.totalLineCount)"
|
|
)
|
|
|
|
moveCursorToRenderedInputStart(renderedInputState)
|
|
clearRenderedBlock(linesToClear)
|
|
send(shell.prompt)
|
|
send(currentInput)
|
|
moveCursor(
|
|
from: renderedEndState,
|
|
to: nextState
|
|
)
|
|
renderedInputState = nextState
|
|
renderedInputRevision &+= 1
|
|
}
|
|
|
|
private func redrawInputLineIfViewportStable(
|
|
_ expectedViewport: InMemoryTerminalViewport,
|
|
expectedRevision: UInt64
|
|
) {
|
|
guard hasStarted, !isTerminated else { return }
|
|
guard terminalSize == expectedViewport else { return }
|
|
guard renderedInputRevision == expectedRevision else {
|
|
shellDebugLog(
|
|
.actions,
|
|
"shell redraw settle skipped: revision changed expected=\(expectedRevision) actual=\(renderedInputRevision)"
|
|
)
|
|
return
|
|
}
|
|
|
|
shellDebugLog(
|
|
.actions,
|
|
"shell redraw settle viewport=\(expectedViewport.columns)x\(expectedViewport.rows) pixels=\(expectedViewport.widthPixels)x\(expectedViewport.heightPixels)"
|
|
)
|
|
redrawInputLine()
|
|
}
|
|
|
|
private func applyIncrementalAppendIfPossible(
|
|
insertedText: String,
|
|
previousInput: String,
|
|
previousCursorPosition: Int
|
|
) -> Bool {
|
|
guard canIncrementallyAppendInput(
|
|
previousInput: previousInput,
|
|
previousCursorPosition: previousCursorPosition,
|
|
insertedText: insertedText
|
|
) else {
|
|
return false
|
|
}
|
|
|
|
let nextState = terminalRenderedInputState(
|
|
promptDisplayWidth: shell.promptDisplayWidth,
|
|
input: currentInput,
|
|
cursorPosition: cursorPosition,
|
|
terminalColumns: Int(terminalSize.columns)
|
|
)
|
|
|
|
shellDebugLog(
|
|
.actions,
|
|
"shell incremental append text=\(shellDebugDescribe(insertedText)) input=\(shellDebugDescribe(currentInput)) cursorPosition=\(cursorPosition)"
|
|
)
|
|
send(insertedText)
|
|
renderedInputState = nextState
|
|
renderedInputRevision &+= 1
|
|
return true
|
|
}
|
|
|
|
private func moveCursorToRenderedInputStart(
|
|
_ state: TerminalRenderedInputState
|
|
) {
|
|
send("\r")
|
|
guard state.cursorLineOffset > 0 else { return }
|
|
send("\u{1B}[\(state.cursorLineOffset)A\r")
|
|
}
|
|
|
|
private func clearRenderedBlock(_ count: Int) {
|
|
guard count > 0 else { return }
|
|
shellDebugLog(
|
|
.actions,
|
|
"shell clear rendered block lines=\(count)"
|
|
)
|
|
send("\u{1B}[J")
|
|
}
|
|
|
|
private func moveCursor(
|
|
from current: TerminalRenderedInputState,
|
|
to target: TerminalRenderedInputState
|
|
) {
|
|
let rowDelta = current.cursorLineOffset - target.cursorLineOffset
|
|
if rowDelta > 0 {
|
|
send("\u{1B}[\(rowDelta)A")
|
|
} else if rowDelta < 0 {
|
|
send("\u{1B}[\(-rowDelta)B")
|
|
}
|
|
|
|
send("\u{1B}[\(target.cursorColumn)G")
|
|
}
|
|
|
|
private func send(_ string: String) {
|
|
sessionBridge.session?.receive(string)
|
|
}
|
|
|
|
private func send(_ data: Data) {
|
|
sessionBridge.session?.receive(data)
|
|
}
|
|
|
|
private var elapsedMilliseconds: UInt64 {
|
|
UInt64(max(0, Date().timeIntervalSince(startedAt) * 1000))
|
|
}
|
|
|
|
private func handleMeta(_ byte: UInt8) {
|
|
switch terminalMetaEditingAction(for: byte) {
|
|
case .moveBackwardWord:
|
|
moveCursorBackwardWord()
|
|
|
|
case .moveForwardWord:
|
|
moveCursorForwardWord()
|
|
|
|
case .deleteBackwardWord:
|
|
deleteBackwardWord()
|
|
|
|
case .deleteForwardWord:
|
|
deleteForwardWord()
|
|
|
|
case nil:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
enum TerminalMetaEditingAction: Equatable {
|
|
case moveBackwardWord
|
|
case moveForwardWord
|
|
case deleteBackwardWord
|
|
case deleteForwardWord
|
|
}
|
|
|
|
enum TerminalCSIEditingAction: Equatable {
|
|
case historyUp
|
|
case historyDown
|
|
case moveCursorLeft
|
|
case moveCursorRight
|
|
case moveCursorBackwardWord
|
|
case moveCursorForwardWord
|
|
case moveCursorToStart
|
|
case moveCursorToEnd
|
|
case deleteForward
|
|
case deleteForwardWord
|
|
}
|
|
|
|
func terminalMetaEditingAction(for byte: UInt8) -> TerminalMetaEditingAction? {
|
|
switch byte {
|
|
case 0x08, 0x7F:
|
|
.deleteBackwardWord
|
|
case 0x62:
|
|
.moveBackwardWord
|
|
case 0x64:
|
|
.deleteForwardWord
|
|
case 0x66:
|
|
.moveForwardWord
|
|
default:
|
|
nil
|
|
}
|
|
}
|
|
|
|
func terminalCSIEditingAction(
|
|
params: Data,
|
|
finalByte: UInt8
|
|
) -> TerminalCSIEditingAction? {
|
|
switch finalByte {
|
|
case 0x41: // A - Up
|
|
return .historyUp
|
|
|
|
case 0x42: // B - Down
|
|
return .historyDown
|
|
|
|
case 0x43: // C - Right
|
|
if terminalCSIHasAltModifier(params) {
|
|
return .moveCursorForwardWord
|
|
}
|
|
return .moveCursorRight
|
|
|
|
case 0x44: // D - Left
|
|
if terminalCSIHasAltModifier(params) {
|
|
return .moveCursorBackwardWord
|
|
}
|
|
return .moveCursorLeft
|
|
|
|
case 0x48: // H - Home
|
|
return .moveCursorToStart
|
|
|
|
case 0x46: // F - End
|
|
return .moveCursorToEnd
|
|
|
|
case 0x7E: // ~ - Extended keys
|
|
guard let csiParams = terminalCSIParameters(params) else { return nil }
|
|
guard csiParams.first == 3 else { return nil }
|
|
if csiParams.hasAltModifier {
|
|
return .deleteForwardWord
|
|
}
|
|
return .deleteForward
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func terminalCSIHasAltModifier(_ params: Data) -> Bool {
|
|
terminalCSIParameters(params)?.hasAltModifier == true
|
|
}
|
|
|
|
func terminalPreviousWordBoundary(
|
|
in input: String,
|
|
from cursorPosition: Int
|
|
) -> Int {
|
|
terminalPreviousBoundary(
|
|
in: input,
|
|
from: cursorPosition,
|
|
skippingTrailingCharactersWhere: { !$0.isTerminalWordCharacter },
|
|
consumingCharactersWhere: { $0.isTerminalWordCharacter }
|
|
)
|
|
}
|
|
|
|
func terminalNextWordBoundary(
|
|
in input: String,
|
|
from cursorPosition: Int
|
|
) -> Int {
|
|
terminalNextBoundary(
|
|
in: input,
|
|
from: cursorPosition,
|
|
skippingLeadingCharactersWhere: { !$0.isTerminalWordCharacter },
|
|
consumingCharactersWhere: { $0.isTerminalWordCharacter }
|
|
)
|
|
}
|
|
|
|
func terminalPreviousShellWordBoundary(
|
|
in input: String,
|
|
from cursorPosition: Int
|
|
) -> Int {
|
|
terminalPreviousBoundary(
|
|
in: input,
|
|
from: cursorPosition,
|
|
skippingTrailingCharactersWhere: { $0.isTerminalWordWhitespace },
|
|
consumingCharactersWhere: { !$0.isTerminalWordWhitespace }
|
|
)
|
|
}
|
|
|
|
func terminalNextShellWordBoundary(
|
|
in input: String,
|
|
from cursorPosition: Int
|
|
) -> Int {
|
|
terminalNextBoundary(
|
|
in: input,
|
|
from: cursorPosition,
|
|
skippingLeadingCharactersWhere: { $0.isTerminalWordWhitespace },
|
|
consumingCharactersWhere: { !$0.isTerminalWordWhitespace }
|
|
)
|
|
}
|
|
|
|
func terminalDeleteBackwardWord(
|
|
input: String,
|
|
cursorPosition: Int
|
|
) -> (input: String, cursorPosition: Int) {
|
|
let clampedCursorPosition = min(max(cursorPosition, 0), input.count)
|
|
let boundary = terminalPreviousWordBoundary(
|
|
in: input,
|
|
from: clampedCursorPosition
|
|
)
|
|
guard boundary < clampedCursorPosition else {
|
|
return (input, clampedCursorPosition)
|
|
}
|
|
|
|
var updatedInput = input
|
|
let start = updatedInput.index(updatedInput.startIndex, offsetBy: boundary)
|
|
let end = updatedInput.index(
|
|
updatedInput.startIndex,
|
|
offsetBy: clampedCursorPosition
|
|
)
|
|
updatedInput.removeSubrange(start ..< end)
|
|
return (updatedInput, boundary)
|
|
}
|
|
|
|
func terminalDeleteForwardWord(
|
|
input: String,
|
|
cursorPosition: Int
|
|
) -> (input: String, cursorPosition: Int) {
|
|
let clampedCursorPosition = min(max(cursorPosition, 0), input.count)
|
|
let boundary = terminalNextWordBoundary(
|
|
in: input,
|
|
from: clampedCursorPosition
|
|
)
|
|
guard clampedCursorPosition < boundary else {
|
|
return (input, clampedCursorPosition)
|
|
}
|
|
|
|
var updatedInput = input
|
|
let start = updatedInput.index(
|
|
updatedInput.startIndex,
|
|
offsetBy: clampedCursorPosition
|
|
)
|
|
let end = updatedInput.index(updatedInput.startIndex, offsetBy: boundary)
|
|
updatedInput.removeSubrange(start ..< end)
|
|
return (updatedInput, clampedCursorPosition)
|
|
}
|
|
|
|
func terminalDeleteBackwardShellWord(
|
|
input: String,
|
|
cursorPosition: Int
|
|
) -> (input: String, cursorPosition: Int) {
|
|
let clampedCursorPosition = min(max(cursorPosition, 0), input.count)
|
|
let boundary = terminalPreviousShellWordBoundary(
|
|
in: input,
|
|
from: clampedCursorPosition
|
|
)
|
|
guard boundary < clampedCursorPosition else {
|
|
return (input, clampedCursorPosition)
|
|
}
|
|
|
|
var updatedInput = input
|
|
let start = updatedInput.index(updatedInput.startIndex, offsetBy: boundary)
|
|
let end = updatedInput.index(
|
|
updatedInput.startIndex,
|
|
offsetBy: clampedCursorPosition
|
|
)
|
|
updatedInput.removeSubrange(start ..< end)
|
|
return (updatedInput, boundary)
|
|
}
|
|
|
|
func terminalCursorColumn(
|
|
promptDisplayWidth: Int,
|
|
input: String,
|
|
cursorPosition: Int
|
|
) -> Int {
|
|
terminalRenderedInputState(
|
|
promptDisplayWidth: promptDisplayWidth,
|
|
input: input,
|
|
cursorPosition: cursorPosition,
|
|
terminalColumns: .max
|
|
).cursorColumn
|
|
}
|
|
|
|
struct TerminalRenderedInputState: Equatable {
|
|
let totalLineCount: Int
|
|
let cursorLineOffset: Int
|
|
let cursorColumn: Int
|
|
}
|
|
|
|
func terminalRenderedInputState(
|
|
promptDisplayWidth: Int,
|
|
input: String,
|
|
cursorPosition: Int,
|
|
terminalColumns: Int
|
|
) -> TerminalRenderedInputState {
|
|
let columns = max(terminalColumns, 1)
|
|
let clampedCursorPosition = min(max(cursorPosition, 0), input.count)
|
|
let totalWidth = promptDisplayWidth + input.terminalDisplayWidth
|
|
let cursorWidth = promptDisplayWidth
|
|
+ String(input.prefix(clampedCursorPosition)).terminalDisplayWidth
|
|
let hasTrailingContent = cursorWidth < totalWidth
|
|
|
|
let cursorLineOffset: Int
|
|
let cursorColumn: Int
|
|
|
|
if cursorWidth <= 0 {
|
|
cursorLineOffset = 0
|
|
cursorColumn = 1
|
|
} else if cursorWidth % columns == 0, !hasTrailingContent {
|
|
cursorLineOffset = max((cursorWidth / columns) - 1, 0)
|
|
cursorColumn = columns
|
|
} else {
|
|
cursorLineOffset = cursorWidth / columns
|
|
cursorColumn = (cursorWidth % columns) + 1
|
|
}
|
|
|
|
return TerminalRenderedInputState(
|
|
totalLineCount: wrappedTerminalLineCount(
|
|
displayWidth: totalWidth,
|
|
terminalColumns: columns
|
|
),
|
|
cursorLineOffset: cursorLineOffset,
|
|
cursorColumn: cursorColumn
|
|
)
|
|
}
|
|
|
|
func wrappedTerminalLineCount(
|
|
displayWidth: Int,
|
|
terminalColumns: Int
|
|
) -> Int {
|
|
let columns = max(terminalColumns, 1)
|
|
return max(1, (max(displayWidth, 1) - 1) / columns + 1)
|
|
}
|
|
|
|
func terminalExpandedTabText(
|
|
promptDisplayWidth: Int,
|
|
input: String,
|
|
cursorPosition: Int,
|
|
terminalColumns: Int,
|
|
tabWidth: Int = 8
|
|
) -> String {
|
|
let cursorColumn = terminalRenderedInputState(
|
|
promptDisplayWidth: promptDisplayWidth,
|
|
input: input,
|
|
cursorPosition: cursorPosition,
|
|
terminalColumns: terminalColumns
|
|
).cursorColumn
|
|
let zeroBasedColumn = max(cursorColumn - 1, 0)
|
|
let spacesUntilNextStop = max(1, tabWidth - (zeroBasedColumn % tabWidth))
|
|
return String(repeating: " ", count: spacesUntilNextStop)
|
|
}
|
|
|
|
func canIncrementallyAppendInput(
|
|
previousInput: String,
|
|
previousCursorPosition: Int,
|
|
insertedText: String
|
|
) -> Bool {
|
|
guard !insertedText.isEmpty else { return false }
|
|
guard previousCursorPosition == previousInput.count else { return false }
|
|
return insertedText.unicodeScalars.allSatisfy { scalar in
|
|
scalar.value >= 0x20 && scalar.value != 0x7F
|
|
}
|
|
}
|
|
|
|
private extension Character {
|
|
var isTerminalWordWhitespace: Bool {
|
|
unicodeScalars.allSatisfy(\.properties.isWhitespace)
|
|
}
|
|
|
|
var isTerminalWordCharacter: Bool {
|
|
unicodeScalars.allSatisfy { scalar in
|
|
scalar.properties.isAlphabetic || scalar.properties.numericType != nil || scalar == "_"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct TerminalCSIParameters {
|
|
let values: [Int]
|
|
|
|
var first: Int? {
|
|
values.first
|
|
}
|
|
|
|
var hasAltModifier: Bool {
|
|
guard let last = values.last, values.count > 1 else { return false }
|
|
// Decode the xterm-style CSI modifier suffix (`CSI 1;<mod><final>` or
|
|
// `CSI 3;<mod>~`) where the trailing parameter stores 1 + bitmask.
|
|
// Bit 1 is Shift, bit 2 is Alt, and bit 4 is Control.
|
|
return max(last - 1, 0) & 0x2 != 0
|
|
}
|
|
}
|
|
|
|
private func terminalCSIParameters(_ params: Data) -> TerminalCSIParameters? {
|
|
guard !params.isEmpty else { return TerminalCSIParameters(values: []) }
|
|
guard let ascii = String(data: params, encoding: .ascii) else { return nil }
|
|
let components = ascii.split(separator: ";")
|
|
let values = components.compactMap { Int($0) }
|
|
guard values.count == components.count else { return nil }
|
|
return TerminalCSIParameters(values: values)
|
|
}
|
|
|
|
private func terminalPreviousBoundary(
|
|
in input: String,
|
|
from cursorPosition: Int,
|
|
skippingTrailingCharactersWhere shouldSkipTrailing: (Character) -> Bool,
|
|
consumingCharactersWhere shouldConsume: (Character) -> Bool
|
|
) -> Int {
|
|
var index = input.index(
|
|
input.startIndex,
|
|
offsetBy: min(max(cursorPosition, 0), input.count)
|
|
)
|
|
while index > input.startIndex {
|
|
let previous = input.index(before: index)
|
|
guard shouldSkipTrailing(input[previous]) else { break }
|
|
index = previous
|
|
}
|
|
while index > input.startIndex {
|
|
let previous = input.index(before: index)
|
|
guard shouldConsume(input[previous]) else { break }
|
|
index = previous
|
|
}
|
|
return input.distance(from: input.startIndex, to: index)
|
|
}
|
|
|
|
private func terminalNextBoundary(
|
|
in input: String,
|
|
from cursorPosition: Int,
|
|
skippingLeadingCharactersWhere shouldSkipLeading: (Character) -> Bool,
|
|
consumingCharactersWhere shouldConsume: (Character) -> Bool
|
|
) -> Int {
|
|
var index = input.index(
|
|
input.startIndex,
|
|
offsetBy: min(max(cursorPosition, 0), input.count)
|
|
)
|
|
while index < input.endIndex, shouldSkipLeading(input[index]) {
|
|
index = input.index(after: index)
|
|
}
|
|
while index < input.endIndex, shouldConsume(input[index]) {
|
|
index = input.index(after: index)
|
|
}
|
|
return input.distance(from: input.startIndex, to: index)
|
|
}
|
|
|
|
/// Decode as many complete UTF-8 characters as possible from raw bytes.
|
|
///
|
|
/// Returns the decoded text and any trailing bytes that form an incomplete
|
|
/// (but potentially valid) UTF-8 sequence. Invalid bytes are skipped
|
|
/// immediately — only genuinely incomplete tails are retained as leftover.
|
|
func decodeUTF8Incrementally(_ data: Data) -> (String, Data) {
|
|
var decoded = ""
|
|
var i = data.startIndex
|
|
|
|
while i < data.endIndex {
|
|
let byte = data[i]
|
|
|
|
let sequenceLength: Int
|
|
switch byte {
|
|
case 0x00 ... 0x7F: sequenceLength = 1
|
|
case 0xC2 ... 0xDF: sequenceLength = 2
|
|
case 0xE0 ... 0xEF: sequenceLength = 3
|
|
case 0xF0 ... 0xF4: sequenceLength = 4
|
|
default:
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
let remaining = data.endIndex - i
|
|
if remaining < sequenceLength {
|
|
// Verify trailing bytes are valid continuations (0x80-0xBF).
|
|
// If any trailing byte is NOT a continuation, the sequence can
|
|
// never be completed — skip the lead byte and keep scanning.
|
|
var validPrefix = true
|
|
for j in (i + 1) ..< data.endIndex {
|
|
if data[j] & 0xC0 != 0x80 {
|
|
validPrefix = false
|
|
break
|
|
}
|
|
}
|
|
if validPrefix {
|
|
break
|
|
}
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
let slice = data[i ..< i + sequenceLength]
|
|
if let char = String(data: Data(slice), encoding: .utf8) {
|
|
decoded += char
|
|
i += sequenceLength
|
|
} else {
|
|
i += 1
|
|
}
|
|
}
|
|
|
|
let leftover = i < data.endIndex ? Data(data[i...]) : Data()
|
|
return (decoded, leftover)
|
|
}
|
|
|
|
private func shellDebugLog(
|
|
_ category: TerminalDebugCategory,
|
|
_ message: @autoclosure () -> String
|
|
) {
|
|
guard TerminalDebugLog.isEnabled else { return }
|
|
guard TerminalDebugLog.categories.contains(category) else { return }
|
|
TerminalDebugLog.sink("[ShellCraftKit] \(message())")
|
|
}
|
|
|
|
private func shellDebugDescribe(_ string: String?) -> String {
|
|
guard let string else { return "nil" }
|
|
let truncated = String(string.prefix(96))
|
|
let escaped = truncated
|
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "\u{1B}", with: "\\e")
|
|
.replacingOccurrences(of: "\n", with: "\\n")
|
|
.replacingOccurrences(of: "\r", with: "\\r")
|
|
.replacingOccurrences(of: "\t", with: "\\t")
|
|
let suffix = string.count > truncated.count ? "..." : ""
|
|
return "\"\(escaped)\(suffix)\""
|
|
}
|