The turn log answered a repeated id with what that turn had answered, which was right for a prompt sent twice over a dropped link and wrong for a turn the web UI had just rewound to edit: the same id came back with the old reply. rewind now returns the ids it took out, both handlers forget them from the log, and the turn file drops them too, or the next load would have brought them back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
170 lines
6.4 KiB
TypeScript
170 lines
6.4 KiB
TypeScript
// One entry per prompt: where the conversation and the working tree were before it and after it.
|
|
// /undo takes the files the turn changed back to how they were and drops the turn from the
|
|
// conversation; /redo reverses that. A new prompt clears what could be redone.
|
|
import type { Snapshots } from "../git/snapshot.ts"
|
|
import type { Message } from "../provider/types.ts"
|
|
import type { Engine, PromptMeta } from "./engine.ts"
|
|
import type { Store } from "./store.ts"
|
|
|
|
interface Turn {
|
|
prompt: string
|
|
/** Its id: the one it was given (the web UI's message id), or the one made here. */
|
|
turnId: string
|
|
/** engine.messages.length before the prompt. */
|
|
at: number
|
|
before?: string
|
|
after?: string
|
|
firstRow: number
|
|
lastRow: number
|
|
}
|
|
|
|
interface Undone {
|
|
turn: Turn
|
|
/** The working tree when it was undone — what /redo puts back. */
|
|
current?: string
|
|
messages: Message[]
|
|
}
|
|
|
|
export interface UndoResult {
|
|
prompt: string
|
|
files: string[]
|
|
}
|
|
|
|
export class Turns {
|
|
/** The prompt running now: where the tree was before it, and its turn id. */
|
|
current?: { before?: string; turnId?: string }
|
|
private done: Turn[] = []
|
|
private undone: Undone[] = []
|
|
/** Tree at the first prompt of the session: the base of /diff. */
|
|
base?: string
|
|
|
|
constructor(
|
|
private engine: Engine,
|
|
private snaps: Snapshots | undefined,
|
|
private store?: Store,
|
|
) {}
|
|
|
|
get canUndo() {
|
|
return this.done.length > 0
|
|
}
|
|
get canRedo() {
|
|
return this.undone.length > 0
|
|
}
|
|
|
|
/** Run a prompt with a snapshot either side. Every turn has an id: the one it was given
|
|
* (the web UI's message id), or a new one — carried on its `task` and `prompt` events. */
|
|
async prompt(text: string, extra: (string | import("../provider/types.ts").ImagePart)[] = [], shown = text, meta: PromptMeta = {}) {
|
|
this.undone = []
|
|
const before = this.snaps?.track()
|
|
this.base ??= before
|
|
const turnId = meta.turnId || crypto.randomUUID()
|
|
const turn: Turn = { prompt: text, turnId, at: this.engine.messages.length, before, firstRow: this.engine.lastRowId + 1, lastRow: 0 }
|
|
this.current = { ...turn }
|
|
this.engine.o.bus.emit({ type: "task", state: "start", turnId })
|
|
try {
|
|
return await this.engine.prompt(text, extra, shown, { ...meta, turnId })
|
|
} finally {
|
|
this.current = undefined
|
|
turn.after = this.snaps?.track()
|
|
turn.lastRow = this.engine.lastRowId
|
|
this.done.push(turn)
|
|
this.engine.o.bus.emit({ type: "task", state: "end", turnId })
|
|
}
|
|
}
|
|
|
|
/** A message the user sent while a task runs. False when no task runs: send it as a prompt
|
|
* instead. */
|
|
steer(text: string, id?: string): boolean {
|
|
if (!this.current) return false
|
|
this.engine.enqueue(text, id)
|
|
return true
|
|
}
|
|
|
|
/** What was sent during the task and never reached the model (queue mode, a turn that ended
|
|
* first, or Esc): for the caller to send next, or to give back to the user. */
|
|
takeInbox(): string[] {
|
|
return this.engine.takeInbox()
|
|
}
|
|
|
|
undo(): UndoResult | undefined {
|
|
const turn = this.done.pop()
|
|
if (!turn) return undefined
|
|
const current = this.snaps?.track()
|
|
let files: string[] = []
|
|
if (this.snaps && turn.before && current) {
|
|
files = this.snaps.changed(turn.before, current)
|
|
this.snaps.restore(turn.before, files)
|
|
}
|
|
const messages = this.engine.messages.splice(turn.at)
|
|
if (this.store && this.engine.sessionId && turn.lastRow >= turn.firstRow) this.store.setKind(this.engine.sessionId, turn.firstRow, turn.lastRow, "reverted")
|
|
this.undone.push({ turn, current, messages })
|
|
return { prompt: turn.prompt, files }
|
|
}
|
|
|
|
redo(): UndoResult | undefined {
|
|
const u = this.undone.pop()
|
|
if (!u) return undefined
|
|
let files: string[] = []
|
|
if (this.snaps && u.turn.before && u.current) {
|
|
files = this.snaps.changed(u.turn.before, u.current)
|
|
this.snaps.restore(u.current, files)
|
|
}
|
|
this.engine.messages.push(...u.messages)
|
|
if (this.store && this.engine.sessionId && u.turn.lastRow >= u.turn.firstRow) this.store.setKind(this.engine.sessionId, u.turn.firstRow, u.turn.lastRow, "message")
|
|
this.done.push(u.turn)
|
|
return { prompt: u.turn.prompt, files }
|
|
}
|
|
|
|
/** `_lembas/session/rewind`: the turn `turnId` and every later one leave the conversation the
|
|
* model sees and the history — their rows re-kinded as /undo re-kinds a turn's, the messages
|
|
* dropped — and the files are left as they are (the client says so beside its control). The
|
|
* ids of the turns reverted, oldest first, or undefined for a turn this session does not have;
|
|
* the caller forgets them from its turn log, or a client re-sending one would get the old
|
|
* answer. Not while a prompt runs: the caller checks. */
|
|
rewind(turnId: string): string[] | undefined {
|
|
const sessionId = this.engine.sessionId
|
|
const i = this.done.findIndex((t) => t.turnId === turnId)
|
|
if (i >= 0) {
|
|
const dropped = this.done.splice(i)
|
|
const first = dropped[0]!
|
|
const last = dropped[dropped.length - 1]!
|
|
this.engine.messages.splice(first.at)
|
|
if (this.store && sessionId && last.lastRow >= first.firstRow) this.store.setKind(sessionId, first.firstRow, last.lastRow, "reverted")
|
|
this.undone = []
|
|
return dropped.map((t) => t.turnId)
|
|
}
|
|
// A turn run before this process had the session (opened from the store): by its row. Every
|
|
// turn run here came after it, so they all go too.
|
|
if (!this.store || !sessionId) return undefined
|
|
const row = this.store.turnRow(sessionId, turnId)
|
|
if (row === undefined) return undefined
|
|
const ids = this.store.turnIdsFrom(sessionId, row)
|
|
this.store.setKind(sessionId, row, Number.MAX_SAFE_INTEGER, "reverted")
|
|
const at = this.engine.messages.findIndex((m) => m.role === "user" && m.turnId === turnId)
|
|
if (at >= 0) this.engine.messages.splice(at)
|
|
this.done = []
|
|
this.undone = []
|
|
return ids
|
|
}
|
|
|
|
/** Everything changed since the session's first prompt, as a unified diff. */
|
|
diff(): string | undefined {
|
|
if (!this.snaps || !this.base) return undefined
|
|
const now = this.snaps.track()
|
|
return now ? this.snaps.diff(this.base, now) : undefined
|
|
}
|
|
|
|
/** Paths changed since the session's first prompt. */
|
|
touched(): string[] {
|
|
if (!this.snaps || !this.base) return []
|
|
const now = this.snaps.track()
|
|
return now ? this.snaps.changed(this.base, now) : []
|
|
}
|
|
|
|
reset() {
|
|
this.done = []
|
|
this.undone = []
|
|
this.base = undefined
|
|
}
|
|
}
|