File size: 2,581 Bytes
a8a1ac1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import { EventEmitter } from '../../ink/events/emitter.js'

export interface QuestionOption {
  label: string
  description?: string
  preview?: string
}

export interface QuestionInfo {
  question: string
  header?: string
  options: QuestionOption[]
  multiSelect?: boolean
  custom?: boolean
}

export interface QuestionRequest {
  id: string
  questions: QuestionInfo[]
  tool?: {
    messageID: string
    callID: string
  }
}

export type QuestionAnswer = string[]

export type ReplyInput = {
  requestID: string
  answers: QuestionAnswer[]
}

interface Pending {
  readonly request: QuestionRequest
  resolve: (answers: ReadonlyArray<QuestionAnswer>) => void
  reject: (reason?: Error) => void
}

class RejectedError extends Error {
  constructor() {
    super('The user dismissed this question')
    this.name = 'QuestionV2.RejectedError'
  }
}

/**
 * Standalone question channel, decoupled from the permission system.
 * Mirrors opencode's QuestionV2 service: a tool's `call` awaits `ask`,
 * the TUI renders an independent overlay, and `reply`/`reject` resolve it.
 */
class QuestionService {
  private pending = new Map<string, Pending>()
  private seq = 0
  readonly events = new EventEmitter()

  private nextID(): string {
    this.seq += 1
    return `que_${Date.now().toString(36)}_${this.seq}`
  }

  ask(questions: QuestionInfo[]): Promise<ReadonlyArray<QuestionAnswer>> {
    const id = this.nextID()
    const request: QuestionRequest = { id, questions }
    return new Promise<ReadonlyArray<QuestionAnswer>>((resolve, reject) => {
      this.pending.set(id, { request, resolve, reject })
      this.events.emit('asked', request)
    })
  }

  reply(input: ReplyInput): boolean {
    const existing = this.pending.get(input.requestID)
    if (!existing) return false
    this.events.emit('replied', {
      requestID: existing.request.id,
      answers: input.answers.map(a => [...a]),
    })
    existing.resolve(input.answers)
    this.pending.delete(input.requestID)
    return true
  }

  reject(requestID: string): boolean {
    const existing = this.pending.get(requestID)
    if (!existing) return false
    this.events.emit('rejected', { requestID: existing.request.id })
    existing.reject(new RejectedError())
    this.pending.delete(requestID)
    return true
  }

  list(): QuestionRequest[] {
    return Array.from(this.pending.values(), p => p.request)
  }

  current(): QuestionRequest | undefined {
    const all = this.list()
    return all[all.length - 1]
  }
}

export const questionService = new QuestionService()
export { RejectedError }