@phenx-inc/ctlsurf 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/headless/index.mjs +344 -55
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +273 -7
- package/package.json +1 -1
- package/src/main/orchestrator.ts +35 -7
- package/src/main/transcripts.ts +341 -0
- package/src/main/tui.ts +39 -17
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transcript Tailer
|
|
7
|
+
*
|
|
8
|
+
* Primary chat-log source for agents that write native session transcripts
|
|
9
|
+
* (Claude Code, Codex CLI). Instead of scraping the TUI screen — which hard-
|
|
10
|
+
* wraps paragraphs at terminal width and leaks chrome — we tail the agent's
|
|
11
|
+
* own JSONL transcript and forward exact user/assistant messages.
|
|
12
|
+
*
|
|
13
|
+
* The ConversationBridge screen-scraper remains the fallback for agents
|
|
14
|
+
* without a known transcript format (plain shell, unknown CLIs).
|
|
15
|
+
*
|
|
16
|
+
* Formats:
|
|
17
|
+
* - Claude Code: ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl
|
|
18
|
+
* One JSON object per line: { type: 'user'|'assistant', message, timestamp, cwd, ... }
|
|
19
|
+
* - Codex CLI: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl
|
|
20
|
+
* First line is { type: 'session_meta', payload: { cwd, ... } }; chat content
|
|
21
|
+
* arrives as { type: 'event_msg', payload: { type: 'user_message'|'agent_message', message } }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface ChatLogEntry {
|
|
25
|
+
ts: string
|
|
26
|
+
type: 'user_input' | 'terminal_output'
|
|
27
|
+
content: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ChatLogSink = (entry: ChatLogEntry) => void
|
|
31
|
+
|
|
32
|
+
export type TranscriptAgentId = 'claude' | 'codex'
|
|
33
|
+
|
|
34
|
+
export function supportsTranscriptLogging(agentId: string): agentId is TranscriptAgentId {
|
|
35
|
+
return agentId === 'claude' || agentId === 'codex'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TranscriptTailerOptions {
|
|
39
|
+
agentId: TranscriptAgentId
|
|
40
|
+
cwd: string
|
|
41
|
+
sink: ChatLogSink
|
|
42
|
+
/** Override transcript roots (for tests). */
|
|
43
|
+
claudeProjectsDir?: string
|
|
44
|
+
codexSessionsDir?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const POLL_INTERVAL_MS = 1_000
|
|
48
|
+
const DISCOVERY_SLACK_MS = 10_000
|
|
49
|
+
const READ_CHUNK_BYTES = 64 * 1024
|
|
50
|
+
const MAX_ENTRY_CHARS = 20_000
|
|
51
|
+
|
|
52
|
+
interface FileTail {
|
|
53
|
+
offset: number
|
|
54
|
+
remainder: string
|
|
55
|
+
/** Codex file whose session_meta cwd belongs to a different project. */
|
|
56
|
+
excluded: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class TranscriptTailer {
|
|
60
|
+
private readonly agentId: TranscriptAgentId
|
|
61
|
+
private readonly cwd: string
|
|
62
|
+
private readonly sink: ChatLogSink
|
|
63
|
+
private readonly claudeProjectsDir: string
|
|
64
|
+
private readonly codexSessionsDir: string
|
|
65
|
+
|
|
66
|
+
private files = new Map<string, FileTail>()
|
|
67
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null
|
|
68
|
+
private sinceMs = 0
|
|
69
|
+
|
|
70
|
+
constructor(options: TranscriptTailerOptions) {
|
|
71
|
+
this.agentId = options.agentId
|
|
72
|
+
this.cwd = stripTrailingSep(options.cwd)
|
|
73
|
+
this.sink = options.sink
|
|
74
|
+
this.claudeProjectsDir = options.claudeProjectsDir || path.join(os.homedir(), '.claude', 'projects')
|
|
75
|
+
this.codexSessionsDir = options.codexSessionsDir || path.join(os.homedir(), '.codex', 'sessions')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
start(): void {
|
|
79
|
+
if (this.pollTimer) return
|
|
80
|
+
this.sinceMs = Date.now()
|
|
81
|
+
this.files.clear()
|
|
82
|
+
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS)
|
|
83
|
+
console.log(`[transcripts] Tailing ${this.agentId} transcripts for ${this.cwd}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
stop(): void {
|
|
87
|
+
if (!this.pollTimer) return
|
|
88
|
+
clearInterval(this.pollTimer)
|
|
89
|
+
this.pollTimer = null
|
|
90
|
+
// Final drain so messages written just before exit aren't lost.
|
|
91
|
+
this.poll()
|
|
92
|
+
this.files.clear()
|
|
93
|
+
console.log('[transcripts] Stopped')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private poll(): void {
|
|
97
|
+
try {
|
|
98
|
+
this.discover()
|
|
99
|
+
for (const [filePath, tail] of this.files) {
|
|
100
|
+
if (!tail.excluded) this.drainFile(filePath, tail)
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error('[transcripts] Poll error:', err)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Discovery ──────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Track every transcript file with recent activity, not just the first
|
|
111
|
+
* match: /clear (Claude) or /new (Codex) starts a new session file in the
|
|
112
|
+
* middle of one PTY run, and tailing all active candidates handles the
|
|
113
|
+
* switch without special cases. Old idle files never match (stale mtime).
|
|
114
|
+
*/
|
|
115
|
+
private discover(): void {
|
|
116
|
+
const dirs = this.agentId === 'claude' ? [this.claudeProjectsDirForCwd()] : this.codexDateDirs()
|
|
117
|
+
|
|
118
|
+
for (const dir of dirs) {
|
|
119
|
+
let names: string[]
|
|
120
|
+
try {
|
|
121
|
+
names = fs.readdirSync(dir)
|
|
122
|
+
} catch {
|
|
123
|
+
continue // Directory may not exist until the agent writes its first message
|
|
124
|
+
}
|
|
125
|
+
for (const name of names) {
|
|
126
|
+
if (!name.endsWith('.jsonl')) continue
|
|
127
|
+
if (this.agentId === 'codex' && !name.startsWith('rollout-')) continue
|
|
128
|
+
const filePath = path.join(dir, name)
|
|
129
|
+
if (this.files.has(filePath)) continue
|
|
130
|
+
try {
|
|
131
|
+
const stat = fs.statSync(filePath)
|
|
132
|
+
if (stat.mtimeMs >= this.sinceMs - DISCOVERY_SLACK_MS) {
|
|
133
|
+
this.files.set(filePath, { offset: 0, remainder: '', excluded: false })
|
|
134
|
+
}
|
|
135
|
+
} catch { /* ignore — file may have vanished */ }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private claudeProjectsDirForCwd(): string {
|
|
141
|
+
// Claude Code slugs the cwd by replacing every non-alphanumeric char with '-'
|
|
142
|
+
// e.g. /Users/me/Code/quick_apps/app → -Users-me-Code-quick-apps-app
|
|
143
|
+
const slug = this.cwd.replace(/[^a-zA-Z0-9]/g, '-')
|
|
144
|
+
return path.join(this.claudeProjectsDir, slug)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private codexDateDirs(): string[] {
|
|
148
|
+
// Codex groups sessions by local date; check the start date and today to
|
|
149
|
+
// cover sessions running across midnight.
|
|
150
|
+
const dirs = new Set<string>()
|
|
151
|
+
for (const ms of [this.sinceMs, Date.now()]) {
|
|
152
|
+
const d = new Date(ms)
|
|
153
|
+
const yyyy = String(d.getFullYear())
|
|
154
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
|
155
|
+
const dd = String(d.getDate()).padStart(2, '0')
|
|
156
|
+
dirs.add(path.join(this.codexSessionsDir, yyyy, mm, dd))
|
|
157
|
+
}
|
|
158
|
+
return [...dirs]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Tailing ────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
private drainFile(filePath: string, tail: FileTail): void {
|
|
164
|
+
let size: number
|
|
165
|
+
try {
|
|
166
|
+
size = fs.statSync(filePath).size
|
|
167
|
+
} catch {
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (size <= tail.offset) return
|
|
171
|
+
|
|
172
|
+
let fd: number
|
|
173
|
+
try {
|
|
174
|
+
fd = fs.openSync(filePath, 'r')
|
|
175
|
+
} catch {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const buf = Buffer.alloc(READ_CHUNK_BYTES)
|
|
180
|
+
while (tail.offset < size && !tail.excluded) {
|
|
181
|
+
const bytesRead = fs.readSync(fd, buf, 0, READ_CHUNK_BYTES, tail.offset)
|
|
182
|
+
if (bytesRead <= 0) break
|
|
183
|
+
tail.offset += bytesRead
|
|
184
|
+
tail.remainder += buf.toString('utf-8', 0, bytesRead)
|
|
185
|
+
|
|
186
|
+
const lines = tail.remainder.split('\n')
|
|
187
|
+
tail.remainder = lines.pop() || ''
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
this.handleLine(line, tail)
|
|
190
|
+
if (tail.excluded) break
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error(`[transcripts] Read error for ${filePath}:`, err)
|
|
195
|
+
} finally {
|
|
196
|
+
try { fs.closeSync(fd) } catch { /* ignore */ }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private handleLine(line: string, tail: FileTail): void {
|
|
201
|
+
const trimmed = line.trim()
|
|
202
|
+
if (!trimmed) return
|
|
203
|
+
|
|
204
|
+
let obj: any
|
|
205
|
+
try {
|
|
206
|
+
obj = JSON.parse(trimmed)
|
|
207
|
+
} catch {
|
|
208
|
+
return // tolerate partial/corrupt lines
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const entry = this.agentId === 'claude' ? this.parseClaudeLine(obj) : this.parseCodexLine(obj, tail)
|
|
212
|
+
if (!entry) return
|
|
213
|
+
|
|
214
|
+
// Skip history replayed into resumed/forked session files.
|
|
215
|
+
const ms = Date.parse(entry.ts)
|
|
216
|
+
if (Number.isFinite(ms) && ms < this.sinceMs - DISCOVERY_SLACK_MS) return
|
|
217
|
+
|
|
218
|
+
this.sink({ ...entry, content: capLength(entry.content) })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Claude Code format ─────────────────────────
|
|
222
|
+
|
|
223
|
+
private parseClaudeLine(obj: any): ChatLogEntry | null {
|
|
224
|
+
if (!obj || typeof obj !== 'object') return null
|
|
225
|
+
if (obj.isMeta) return null
|
|
226
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') return null
|
|
227
|
+
// Belt-and-braces: the slug dir already scopes to this cwd, but lines
|
|
228
|
+
// carry the cwd too (subagent/sidechain lines can differ).
|
|
229
|
+
if (typeof obj.cwd === 'string' && stripTrailingSep(obj.cwd) !== this.cwd) return null
|
|
230
|
+
if (obj.isSidechain) return null
|
|
231
|
+
|
|
232
|
+
const message = obj.message
|
|
233
|
+
if (!message) return null
|
|
234
|
+
|
|
235
|
+
const text = extractClaudeText(message.content, obj.type === 'user')
|
|
236
|
+
if (!text) return null
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
ts: typeof obj.timestamp === 'string' ? obj.timestamp : new Date().toISOString(),
|
|
240
|
+
type: obj.type === 'user' ? 'user_input' : 'terminal_output',
|
|
241
|
+
content: text,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Codex CLI format ───────────────────────────
|
|
246
|
+
|
|
247
|
+
private parseCodexLine(obj: any, tail: FileTail): ChatLogEntry | null {
|
|
248
|
+
if (!obj || typeof obj !== 'object') return null
|
|
249
|
+
const payload = obj.payload
|
|
250
|
+
|
|
251
|
+
if (obj.type === 'session_meta') {
|
|
252
|
+
const metaCwd = payload?.cwd
|
|
253
|
+
if (typeof metaCwd === 'string' && stripTrailingSep(metaCwd) !== this.cwd) {
|
|
254
|
+
tail.excluded = true // another project's session sharing the date dir
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (obj.type !== 'event_msg' || !payload || typeof payload !== 'object') return null
|
|
260
|
+
|
|
261
|
+
let type: ChatLogEntry['type']
|
|
262
|
+
if (payload.type === 'user_message') {
|
|
263
|
+
type = 'user_input'
|
|
264
|
+
} else if (payload.type === 'agent_message') {
|
|
265
|
+
type = 'terminal_output'
|
|
266
|
+
} else {
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const text = typeof payload.message === 'string' ? payload.message.trim() : ''
|
|
271
|
+
if (!text || isCodexNoise(text)) return null
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
ts: typeof obj.timestamp === 'string' ? obj.timestamp : new Date().toISOString(),
|
|
275
|
+
type,
|
|
276
|
+
content: text,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── Text extraction & filters ────────────────────
|
|
282
|
+
|
|
283
|
+
/** Prefixes of injected/meta user content that isn't something the user typed. */
|
|
284
|
+
const CLAUDE_NOISE_PREFIXES = [
|
|
285
|
+
'<local-command-caveat>',
|
|
286
|
+
'<command-name>',
|
|
287
|
+
'<command-message>',
|
|
288
|
+
'<local-command-stdout>',
|
|
289
|
+
'<bash-input>',
|
|
290
|
+
'<bash-stdout>',
|
|
291
|
+
'<bash-stderr>',
|
|
292
|
+
'<system-reminder>',
|
|
293
|
+
'<task-notification>',
|
|
294
|
+
'caveat: the messages below',
|
|
295
|
+
'[request interrupted',
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
const CODEX_NOISE_PREFIXES = [
|
|
299
|
+
'<environment_context>',
|
|
300
|
+
'<user_instructions>',
|
|
301
|
+
'<permissions instructions>',
|
|
302
|
+
'<turn_aborted>',
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
function isClaudeNoise(text: string): boolean {
|
|
306
|
+
const lower = text.trimStart().toLowerCase()
|
|
307
|
+
return CLAUDE_NOISE_PREFIXES.some((p) => lower.startsWith(p))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isCodexNoise(text: string): boolean {
|
|
311
|
+
const lower = text.trimStart().toLowerCase()
|
|
312
|
+
return CODEX_NOISE_PREFIXES.some((p) => lower.startsWith(p))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function extractClaudeText(content: unknown, isUser: boolean): string {
|
|
316
|
+
if (typeof content === 'string') {
|
|
317
|
+
const text = content.trim()
|
|
318
|
+
return text && !isClaudeNoise(text) ? text : ''
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(content)) return ''
|
|
321
|
+
|
|
322
|
+
// Tool results come back as user-role lines; they're not typed input.
|
|
323
|
+
if (isUser && content.some((b: any) => b?.type === 'tool_result')) return ''
|
|
324
|
+
|
|
325
|
+
const parts: string[] = []
|
|
326
|
+
for (const block of content) {
|
|
327
|
+
if (block?.type !== 'text' || typeof block.text !== 'string') continue
|
|
328
|
+
const text = block.text.trim()
|
|
329
|
+
if (text && !isClaudeNoise(text)) parts.push(text)
|
|
330
|
+
}
|
|
331
|
+
return parts.join('\n\n')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function stripTrailingSep(p: string): string {
|
|
335
|
+
return p.length > 1 ? p.replace(/[/\\]+$/, '') : p
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function capLength(str: string): string {
|
|
339
|
+
if (str.length <= MAX_ENTRY_CHARS) return str
|
|
340
|
+
return str.slice(0, MAX_ENTRY_CHARS) + `… [truncated, ${str.length} total chars]`
|
|
341
|
+
}
|
package/src/main/tui.ts
CHANGED
|
@@ -140,6 +140,10 @@ export class Tui {
|
|
|
140
140
|
let selected = 0
|
|
141
141
|
let trackTime = options.initialTrackTime
|
|
142
142
|
let logChat = options.initialLogChat
|
|
143
|
+
// Navigable items: agent rows, then Track time and Log chat checkbox rows
|
|
144
|
+
const trackIdx = agents.length
|
|
145
|
+
const logIdx = agents.length + 1
|
|
146
|
+
const itemCount = agents.length + 2
|
|
143
147
|
const modalWidth = 44
|
|
144
148
|
// +4 for borders/title/sep, +3 for separator + track-time row + log-chat row
|
|
145
149
|
const modalHeight = agents.length + 4 + 3
|
|
@@ -190,26 +194,27 @@ export class Tui {
|
|
|
190
194
|
const sepRow = startRow + 3 + agents.length
|
|
191
195
|
this.write(`${CSI}${sepRow};${startCol}H${BG_MODAL}${FG_DIM}${innerSep}${RESET}`)
|
|
192
196
|
|
|
197
|
+
const drawCheckboxRow = (row: number, label: string, checked: boolean, isSelected: boolean) => {
|
|
198
|
+
const bg = isSelected ? BG_SELECTED : BG_MODAL
|
|
199
|
+
const pointer = isSelected ? `${FG_ACCENT}▸ ` : ' '
|
|
200
|
+
const checkbox = checked ? `${FG_GREEN}[✓]${RESET}${bg}` : `${FG_DIM}[ ]${RESET}${bg}`
|
|
201
|
+
const labelFg = checked || isSelected ? FG_WHITE : FG_DIM
|
|
202
|
+
const content = `${pointer}${checkbox} ${labelFg}${label}${RESET}${bg}`
|
|
203
|
+
const contentLen = 2 + 3 + 1 + label.length
|
|
204
|
+
const pad = ' '.repeat(Math.max(0, modalWidth - 2 - contentLen))
|
|
205
|
+
this.write(`${CSI}${row};${startCol}H${bg}${FG_DIM}│${RESET}${bg}${content}${pad}${RESET}${BG_MODAL}${FG_DIM}│${RESET}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
193
208
|
const trackRow = sepRow + 1
|
|
194
|
-
|
|
195
|
-
const trackLabelFg = trackTime ? FG_WHITE : FG_DIM
|
|
196
|
-
const trackContent = ` ${checkbox} ${trackLabelFg}Track time${RESET}${BG_MODAL}`
|
|
197
|
-
const trackContentLen = 2 + 3 + 1 + 'Track time'.length
|
|
198
|
-
const trackPad = ' '.repeat(Math.max(0, modalWidth - 2 - trackContentLen))
|
|
199
|
-
this.write(`${CSI}${trackRow};${startCol}H${BG_MODAL}${FG_DIM}│${RESET}${BG_MODAL}${trackContent}${trackPad}${FG_DIM}│${RESET}`)
|
|
209
|
+
drawCheckboxRow(trackRow, 'Track time', trackTime, selected === trackIdx)
|
|
200
210
|
|
|
201
211
|
const logRow = trackRow + 1
|
|
202
|
-
|
|
203
|
-
const logLabelFg = logChat ? FG_WHITE : FG_DIM
|
|
204
|
-
const logContent = ` ${logCheckbox} ${logLabelFg}Log chat${RESET}${BG_MODAL}`
|
|
205
|
-
const logContentLen = 2 + 3 + 1 + 'Log chat'.length
|
|
206
|
-
const logPad = ' '.repeat(Math.max(0, modalWidth - 2 - logContentLen))
|
|
207
|
-
this.write(`${CSI}${logRow};${startCol}H${BG_MODAL}${FG_DIM}\u2502${RESET}${BG_MODAL}${logContent}${logPad}${FG_DIM}\u2502${RESET}`)
|
|
212
|
+
drawCheckboxRow(logRow, 'Log chat', logChat, selected === logIdx)
|
|
208
213
|
|
|
209
214
|
const botRow = logRow + 1
|
|
210
215
|
this.write(`${CSI}${botRow};${startCol}H${BG_MODAL}${FG_DIM}${botBorder}${RESET}`)
|
|
211
216
|
|
|
212
|
-
const hint = '\u2191\u2193 nav \u00B7 Enter
|
|
217
|
+
const hint = '\u2191\u2193 nav \u00B7 Enter select \u00B7 Space toggle \u00B7 q quit'
|
|
213
218
|
const hintCol = Math.max(1, Math.floor((this.cols - hint.length) / 2))
|
|
214
219
|
this.write(`${CSI}${botRow + 2};${hintCol}H${FG_DIM}${hint}${RESET}`)
|
|
215
220
|
}
|
|
@@ -224,11 +229,23 @@ export class Tui {
|
|
|
224
229
|
const onKey = (data: Buffer) => {
|
|
225
230
|
const key = data.toString()
|
|
226
231
|
|
|
232
|
+
const toggleSelected = (): boolean => {
|
|
233
|
+
if (selected === trackIdx) {
|
|
234
|
+
trackTime = !trackTime
|
|
235
|
+
} else if (selected === logIdx) {
|
|
236
|
+
logChat = !logChat
|
|
237
|
+
} else {
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
drawModal()
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
|
|
227
244
|
if (key === '\x1b[A' || key === 'k') {
|
|
228
|
-
selected = (selected - 1 +
|
|
245
|
+
selected = (selected - 1 + itemCount) % itemCount
|
|
229
246
|
drawModal()
|
|
230
247
|
} else if (key === '\x1b[B' || key === 'j') {
|
|
231
|
-
selected = (selected + 1) %
|
|
248
|
+
selected = (selected + 1) % itemCount
|
|
232
249
|
drawModal()
|
|
233
250
|
} else if (key === 't' || key === 'T') {
|
|
234
251
|
trackTime = !trackTime
|
|
@@ -236,9 +253,14 @@ export class Tui {
|
|
|
236
253
|
} else if (key === 'l' || key === 'L') {
|
|
237
254
|
logChat = !logChat
|
|
238
255
|
drawModal()
|
|
256
|
+
} else if (key === ' ') {
|
|
257
|
+
toggleSelected()
|
|
239
258
|
} else if (key === '\r' || key === '\n') {
|
|
240
|
-
|
|
241
|
-
|
|
259
|
+
// Enter on a checkbox row toggles it; on an agent row it confirms
|
|
260
|
+
if (!toggleSelected()) {
|
|
261
|
+
cleanup()
|
|
262
|
+
resolve({ agentIdx: selected, trackTime, logChat })
|
|
263
|
+
}
|
|
242
264
|
} else if (key === 'q' || key === '\x1b' || key === '\x03') {
|
|
243
265
|
cleanup()
|
|
244
266
|
this.write(`${CSI}?25h`)
|