Skip to content

Commit d90963a

Browse files
committed
Update with revised specs from Docs/semantic-prompt-design.md
1 parent 4206d3a commit d90963a

14 files changed

Lines changed: 3747 additions & 343 deletions

Docs/semantic-prompt-design.md

Lines changed: 404 additions & 0 deletions
Large diffs are not rendered by default.

Sources/SwiftTerm/Apple/AppleTerminalView.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2636,9 +2636,25 @@ extension TerminalView {
26362636
/**
26372637
* Sends the specified slice of byte arrays to the program running under the terminal emulator
26382638
* - Parameter data: the slice of an array to send to the client
2639+
*
2640+
* Thread contract (F.4): this runs the OSC 133 submission heuristic
2641+
* (`terminal.registerUserInput`) synchronously, mutating terminal state, so
2642+
* it MUST be called on the same thread that drives `terminal.feed` — the
2643+
* main thread for a `TerminalView`. Do not call it from a background I/O
2644+
* thread while feeding the terminal from another; doing so races the
2645+
* scanner against the parser and can leave the buffer armed after a
2646+
* submission (the injection direction). A host that needs a different
2647+
* threading model must marshal input onto the view's thread itself. The
2648+
* debug precondition below catches violations early.
26392649
*/
26402650
public func send(data: ArraySlice<UInt8>)
26412651
{
2652+
#if DEBUG
2653+
// Catch a host that violates the same-thread contract. Uses
2654+
// `Thread.isMainThread` rather than `dispatchPrecondition(.onQueue:)`,
2655+
// which is unreliable under Swift Concurrency's main-actor executor.
2656+
assert(Thread.isMainThread, "TerminalView.send(data:) must be called on the main thread")
2657+
#endif
26422658
recordUserInput()
26432659
ensureCaretIsVisible ()
26442660
#if os(iOS) || os(visionOS)
@@ -2648,7 +2664,8 @@ extension TerminalView {
26482664
TerminalView.textInputLogCounter += 1
26492665
}
26502666
#endif
2651-
terminalDelegate?.send (source: self, data: data)
2667+
terminal.registerUserInput(data)
2668+
terminalDelegate?.send(source: self, data: data)
26522669
}
26532670

26542671
/**

Sources/SwiftTerm/Buffer.swift

Lines changed: 400 additions & 23 deletions
Large diffs are not rendered by default.

Sources/SwiftTerm/BufferLine.swift

Lines changed: 154 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,32 @@ public final class BufferLine: CustomDebugStringConvertible {
2727
/// BiDi state for the paragraph that contains this row.
2828
public internal(set) var bidiState: BidiPresentationState { didSet { bump() } }
2929
var renderMode: RenderLineMode = .single { didSet { bump() } }
30-
/// Semantic prompt classification for this row. Cell-level roles live in
31-
/// `CharData.semanticContent`; this distinguishes primary and continuation
32-
/// prompt rows even when they are currently blank.
33-
public private(set) var semanticPromptKind: SemanticPromptKind? { didSet { bump() } }
30+
/// Shell-authored OSC 133 marks on this line, at most one per kind.
31+
/// A line can carry both a left prompt and a right prompt mark.
32+
private(set) var semanticMarks: [SemanticMark] = [] { didSet { bump() } }
33+
/// The prompt-group epoch of a line created by a hard line feed inside a
34+
/// prompt group: the active group ID at stamp time, or nil for a line that
35+
/// is not a hard continuation. Together with `isWrapped` this is what makes
36+
/// a row derivable as a continuation, and the group ID is what keeps an old
37+
/// group's stranded rows from joining a new prompt (R1/R5). Exactly one
38+
/// function assigns it: `Terminal.finishSemanticLineAdvance`.
39+
///
40+
/// It is structural, not content: cell erasure (EL/ED) never touches it.
41+
/// Derivation-only metadata, not drawn, so it bumps the render generation
42+
/// only on an actual change.
43+
var semanticHardContinuationGroup: UInt64? = nil {
44+
didSet { if semanticHardContinuationGroup != oldValue { bump() } }
45+
}
46+
/// Weak link to the owning buffer, used only so `copyFrom` can ask which
47+
/// of two colliding same-kind marks is the live origin. Lines that never
48+
/// carry marks (bare templates) leave this nil.
49+
weak var owningBuffer: Buffer?
50+
/// Bumped each time this line object is reused for different content
51+
/// (recycle, reset). A deferred pointer click captures this alongside the
52+
/// line identity; a mismatch at fire time means the object was recycled
53+
/// into a new row and the click must be dropped — identity alone cannot
54+
/// tell, because `CircularList.recycle` keeps the object in the array.
55+
private(set) var recycleGeneration: UInt64 = 0
3456
private var data: UnsafeMutableBufferPointer<CharData>
3557
private var dataSize: Int
3658

@@ -56,7 +78,6 @@ public final class BufferLine: CustomDebugStringConvertible {
5678
data = buf
5779
dataSize = cols
5880
self.isWrapped = isWrapped
59-
semanticPromptKind = nil
6081
self.bidiState = bidiState
6182
}
6283

@@ -66,7 +87,11 @@ public final class BufferLine: CustomDebugStringConvertible {
6687
isWrapped = other.isWrapped
6788
bidiState = other.bidiState
6889
renderMode = other.renderMode
69-
semanticPromptKind = other.semanticPromptKind
90+
semanticMarks = other.semanticMarks
91+
semanticHardContinuationGroup = other.semanticHardContinuationGroup
92+
// owningBuffer is NOT inherited: a clone is stamped with its real
93+
// owner when it is attached to a buffer (B.3). Inheriting it from a
94+
// cross-buffer template leaks the wrong owner.
7095
images = other.images
7196
let otherSize = other.dataSize
7297
let buf = UnsafeMutableBufferPointer<CharData>.allocate(capacity: otherSize)
@@ -128,13 +153,22 @@ public final class BufferLine: CustomDebugStringConvertible {
128153
return Int (data [index].width)
129154
}
130155

156+
/// Erases the cell contents. Marks are line-level metadata and survive
157+
/// this: destruction of a recycled line goes through `destroySemanticState`.
131158
func clear(with attribute: Attribute) {
132159
let empty = CharData(attribute: attribute)
133160
data.update(repeating: empty)
134-
semanticPromptKind = nil
135161
images = nil
162+
recycleGeneration &+= 1
136163
bump()
137164
}
165+
166+
/// Removes the semantic prompt metadata. Called only when the line
167+
/// itself is destroyed (recycled for reuse), never by cell mutations.
168+
func destroySemanticState() {
169+
semanticMarks.removeAll(keepingCapacity: true)
170+
semanticHardContinuationGroup = nil
171+
}
138172
/// Test whether contains any chars.
139173
public func hasContent (index: Int) -> Bool {
140174
data [index].code != 0 || data [index].attribute != CharData.defaultAttr;
@@ -171,6 +205,7 @@ public final class BufferLine: CustomDebugStringConvertible {
171205
data [i] = fillData
172206
}
173207
}
208+
marksShift(from: pos, by: n, rightMargin: rightMargin)
174209
bump()
175210
}
176211

@@ -191,10 +226,51 @@ public final class BufferLine: CustomDebugStringConvertible {
191226
data [i] = fillData
192227
}
193228
}
229+
marksShift(from: pos, by: -n, rightMargin: rightMargin)
194230
bump()
195231
}
196232

197-
/// Replaces the cells in the start to end range with the specified fill data
233+
/// Shifts mark columns with their cells when ICH inserts or DCH deletes
234+
/// cells at `pos` inside the margin. Marks are never removed here: a mark
235+
/// whose cell was deleted lands on `pos`, one pushed past the margin
236+
/// stays on the margin.
237+
func marksShift(from pos: Int, by delta: Int, rightMargin: Int) {
238+
guard delta != 0, !semanticMarks.isEmpty else { return }
239+
semanticMarks = semanticMarks.map { mark in
240+
guard mark.column >= pos, mark.column <= rightMargin else { return mark }
241+
var result = mark
242+
result.column = min(max(mark.column + delta, pos), rightMargin)
243+
return result
244+
}
245+
}
246+
247+
/// Clamps every mark column into the line's content width, for shrinking
248+
/// resizes. A zero-width line can hold no marks.
249+
func marksClampTo(width: Int) {
250+
guard !semanticMarks.isEmpty else { return }
251+
if width <= 0 {
252+
semanticMarks.removeAll(keepingCapacity: true)
253+
return
254+
}
255+
semanticMarks = semanticMarks.map { mark in
256+
var result = mark
257+
result.column = min(max(mark.column, 0), width - 1)
258+
return result
259+
}
260+
}
261+
262+
/// Stores a shell-authored mark. Setting a mark of a kind the line
263+
/// already carries replaces that mark: readline redisplay re-emits `A`
264+
/// on the same row on every repaint.
265+
func setSemanticMark(kind: SemanticPromptKind, column: Int, group: UInt64) {
266+
semanticMarks.removeAll { $0.kind == kind }
267+
semanticMarks.append(SemanticMark(kind: kind, column: column, group: group))
268+
semanticMarks.sort { $0.column < $1.column }
269+
}
270+
271+
/// Replaces the cells in the start to end range with the specified fill data.
272+
/// Cell erasure never touches the continuation epoch (R1: structural, not
273+
/// content).
198274
public func replaceCells (start: Int, end: Int, fillData : CharData)
199275
{
200276
let length = dataSize
@@ -259,6 +335,7 @@ public final class BufferLine: CustomDebugStringConvertible {
259335
data = UnsafeMutableBufferPointer<CharData>.allocate(capacity: 0)
260336
dataSize = 0
261337
}
338+
marksClampTo(width: cols)
262339
}
263340
}
264341

@@ -305,15 +382,12 @@ public final class BufferLine: CustomDebugStringConvertible {
305382
}
306383
dataSize = srcSize
307384
isWrapped = line.isWrapped
308-
semanticPromptKind = line.semanticPromptKind
385+
semanticMarks = line.semanticMarks
386+
semanticHardContinuationGroup = line.semanticHardContinuationGroup
309387
bidiState = line.bidiState
310388
bump()
311389
}
312390

313-
func setSemanticPromptKind(_ kind: SemanticPromptKind?) {
314-
semanticPromptKind = kind
315-
}
316-
317391
/// Returns the trimmed length in terms of cells used from the BufferLine
318392
///
319393
public func getTrimmedLength () -> Int
@@ -334,6 +408,8 @@ public final class BufferLine: CustomDebugStringConvertible {
334408
/// - len: the number of elements to copy
335409
public func copyFrom (_ src: BufferLine, srcCol: Int, dstCol: Int, len: Int)
336410
{
411+
let movesSemanticHardContinuation = srcCol == 0 && dstCol == 0 && len >= count
412+
let movedSemanticHardContinuationGroup = src.semanticHardContinuationGroup
337413
if src === self && srcCol > dstCol {
338414
// Overlapping forward copy: go left-to-right (already safe)
339415
for i in 0..<len {
@@ -349,6 +425,71 @@ public final class BufferLine: CustomDebugStringConvertible {
349425
data[dstCol + i] = src.data[srcCol + i]
350426
}
351427
}
428+
// Marks travel with their cells. The margin-scroll paths and reflow
429+
// shuffle cell ranges between line objects through this call, and a
430+
// mark's row is wherever its cells went: this moves marks, it never
431+
// authors them. Marks outside the copied range stay where they are.
432+
let moved = src.semanticMarks.filter { $0.column >= srcCol && $0.column < srcCol + len }
433+
// Snapshot the live origin before the marks move, so a same-kind
434+
// collision can be resolved by liveness rather than position. Skipped
435+
// entirely on the common no-marks scroll row (E.5).
436+
let origin = moved.isEmpty ? nil : owningBuffer?.rawSemanticOrigin()
437+
if src === self {
438+
semanticMarks.removeAll {
439+
($0.column >= srcCol && $0.column < srcCol + len) ||
440+
($0.column >= dstCol && $0.column < dstCol + len)
441+
}
442+
} else {
443+
src.semanticMarks.removeAll { $0.column >= srcCol && $0.column < srcCol + len }
444+
semanticMarks.removeAll { $0.column >= dstCol && $0.column < dstCol + len }
445+
}
446+
if !moved.isEmpty {
447+
var originMovedHere = false
448+
for mark in moved {
449+
let newColumn = mark.column - srcCol + dstCol
450+
let movedIsOrigin = origin.map {
451+
$0.line === src && $0.kind == mark.kind && $0.column == mark.column
452+
} ?? false
453+
if let idx = semanticMarks.firstIndex(where: { $0.kind == mark.kind }) {
454+
// A same-kind mark outside the copied destination range did
455+
// not move. The live origin always wins the collision: if
456+
// the origin is the mark that moved here, it displaces the
457+
// stationary one; otherwise the stationary mark is kept
458+
// (it is the origin, or — absent origin info — the
459+
// conservative choice) and the moved duplicate is dropped.
460+
let stationaryIsOrigin = origin.map {
461+
$0.line === self && $0.kind == mark.kind && $0.column == semanticMarks[idx].column
462+
} ?? false
463+
if movedIsOrigin && !stationaryIsOrigin {
464+
semanticMarks.remove(at: idx)
465+
semanticMarks.append(SemanticMark(kind: mark.kind, column: newColumn,
466+
group: mark.group))
467+
originMovedHere = true
468+
}
469+
} else {
470+
semanticMarks.append(SemanticMark(kind: mark.kind, column: newColumn,
471+
group: mark.group))
472+
if movedIsOrigin {
473+
originMovedHere = true
474+
}
475+
}
476+
}
477+
semanticMarks.sort { $0.column < $1.column }
478+
if originMovedHere, src !== self {
479+
// The origin's cells now live on this line; follow them so
480+
// the getter binds to this object instead of relying on the
481+
// rebind heuristic.
482+
owningBuffer?.reassignSemanticOrigin(to: self)
483+
}
484+
}
485+
// A full-width copy moves the continuation epoch with the content; a
486+
// narrow-margin copy leaves it (R1: dead clicks over injection).
487+
if movesSemanticHardContinuation {
488+
semanticHardContinuationGroup = movedSemanticHardContinuationGroup
489+
if src !== self {
490+
src.semanticHardContinuationGroup = nil
491+
}
492+
}
352493
bump()
353494
}
354495

Sources/SwiftTerm/CharData.swift

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -292,17 +292,49 @@ public struct CharData: CustomDebugStringConvertible {
292292
// semantic metadata does not increase the size of a terminal cell.
293293
private var semanticContentCode: UInt8
294294

295+
// The single source of truth for the cell-storage encoding of a
296+
// SemanticContent. Both directions switch exhaustively over this enum, so
297+
// a new SemanticContent (or SemanticPromptKind) case fails to compile
298+
// until it is mapped in both — it can never silently decode to `.none`.
299+
private enum SemanticContentCode: UInt8 {
300+
case none = 0
301+
case promptInitial = 1
302+
case promptRight = 2
303+
case promptContinuation = 3
304+
case promptSecondary = 4
305+
case input = 5
306+
case output = 6
307+
308+
var content: SemanticContent {
309+
switch self {
310+
case .none: return .none
311+
case .promptInitial: return .prompt(.initial)
312+
case .promptRight: return .prompt(.right)
313+
case .promptContinuation: return .prompt(.continuation)
314+
case .promptSecondary: return .prompt(.secondary)
315+
case .input: return .input
316+
case .output: return .output
317+
}
318+
}
319+
320+
init(_ content: SemanticContent) {
321+
switch content {
322+
case .none: self = .none
323+
case .prompt(.initial): self = .promptInitial
324+
case .prompt(.right): self = .promptRight
325+
case .prompt(.continuation): self = .promptContinuation
326+
case .prompt(.secondary): self = .promptSecondary
327+
case .input: self = .input
328+
case .output: self = .output
329+
}
330+
}
331+
}
332+
295333
/// The OSC 133 role assigned to this cell, if any.
296334
public var semanticContent: SemanticContent {
297-
switch semanticContentCode {
298-
case 1: return .prompt(.initial)
299-
case 2: return .prompt(.right)
300-
case 3: return .prompt(.continuation)
301-
case 4: return .prompt(.secondary)
302-
case 5: return .input
303-
case 6: return .output
304-
default: return .none
305-
}
335+
// An out-of-range byte can only come from corrupt storage; decode it
336+
// as `.none` rather than trapping.
337+
(SemanticContentCode(rawValue: semanticContentCode) ?? .none).content
306338
}
307339

308340
/// The color and character attributes for the cell
@@ -345,15 +377,7 @@ public struct CharData: CustomDebugStringConvertible {
345377
}
346378

347379
mutating func setSemanticContent(_ content: SemanticContent) {
348-
switch content {
349-
case .none: semanticContentCode = 0
350-
case .prompt(.initial): semanticContentCode = 1
351-
case .prompt(.right): semanticContentCode = 2
352-
case .prompt(.continuation): semanticContentCode = 3
353-
case .prompt(.secondary): semanticContentCode = 4
354-
case .input: semanticContentCode = 5
355-
case .output: semanticContentCode = 6
356-
}
380+
semanticContentCode = SemanticContentCode(content).rawValue
357381
}
358382

359383
public func getPayload () -> Any?

0 commit comments

Comments
 (0)