@phenx-inc/ctlsurf 0.3.13 → 0.3.14

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.
Files changed (33) hide show
  1. package/bin/ctlsurf-worker.js +38 -22
  2. package/out/headless/index.mjs +247 -1
  3. package/out/headless/index.mjs.map +4 -4
  4. package/out/main/index.js +303 -46
  5. package/out/preload/index.js +5 -0
  6. package/out/renderer/assets/{cssMode-CYoo4t9f.js → cssMode-G_SDogBL.js} +3 -3
  7. package/out/renderer/assets/{freemarker2--UQnPZsn.js → freemarker2-BzEus0h2.js} +1 -1
  8. package/out/renderer/assets/{handlebars-DVDrmX0C.js → handlebars-Et995f6O.js} +1 -1
  9. package/out/renderer/assets/{html-D1-cXoLy.js → html-D4wgKxPD.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-f5nBuprq.js → htmlMode-DSxpefzL.js} +3 -3
  11. package/out/renderer/assets/{index-65hyKM_8.css → index-AQ346NMi.css} +386 -0
  12. package/out/renderer/assets/{index-D23nru43.js → index-ByJTqkiQ.js} +318 -22
  13. package/out/renderer/assets/{javascript-CcarFzBL.js → javascript-CzLoo8aq.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-BvF-xK9U.js → jsonMode-BrwPy7fY.js} +3 -3
  15. package/out/renderer/assets/{liquid-CHLtUKl2.js → liquid-BsfPf6YG.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-B9aNeatS.js → lspLanguageFeatures-CxLZ421s.js} +1 -1
  17. package/out/renderer/assets/{mdx-HGDrkifZ.js → mdx-CPvHIsAR.js} +1 -1
  18. package/out/renderer/assets/{python-B_dPzjJ6.js → python-Dr7dCUjG.js} +1 -1
  19. package/out/renderer/assets/{razor-CHheM4ot.js → razor-a7zjD7Y3.js} +1 -1
  20. package/out/renderer/assets/{tsMode-CdC3i1gG.js → tsMode-B7KLV2X6.js} +1 -1
  21. package/out/renderer/assets/{typescript-BX6guVRK.js → typescript-Cjuzf37q.js} +1 -1
  22. package/out/renderer/assets/{xml-CpS-pOPE.js → xml-Yz9xINtk.js} +1 -1
  23. package/out/renderer/assets/{yaml-Du0AjOHW.js → yaml-DtKnp5J0.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +1 -1
  26. package/src/main/ctlsurfApi.ts +11 -0
  27. package/src/main/index.ts +20 -0
  28. package/src/main/orchestrator.ts +37 -0
  29. package/src/main/ticketStore.ts +252 -0
  30. package/src/preload/index.ts +10 -0
  31. package/src/renderer/App.tsx +21 -0
  32. package/src/renderer/components/TicketPanel.tsx +308 -0
  33. package/src/renderer/styles.css +386 -0
package/src/main/index.ts CHANGED
@@ -366,6 +366,26 @@ ipcMain.handle('tracking:set', async (_event, enabled: boolean) => {
366
366
  return { active: orchestrator.isActiveTabTracking() }
367
367
  })
368
368
 
369
+ // ─── Tickets IPC ──────────────────────────────────
370
+
371
+ ipcMain.handle('tickets:project', () => {
372
+ const cwd = orchestrator.getActiveTabCwd()
373
+ return { cwd, name: cwd ? cwd.split('/').filter(Boolean).pop() || cwd : null }
374
+ })
375
+ ipcMain.handle('tickets:add', async (_event, input: {
376
+ title: string; description?: string; status?: string; priority?: string
377
+ }) => {
378
+ return orchestrator.addTicketForActiveTab(input)
379
+ })
380
+ ipcMain.handle('tickets:update', async (_event, rowId: string, input: {
381
+ title: string; description?: string; status?: string; priority?: string
382
+ }) => {
383
+ return orchestrator.updateTicketForActiveTab(rowId, input)
384
+ })
385
+ ipcMain.handle('tickets:list', async () => {
386
+ return orchestrator.listTicketsForActiveTab()
387
+ })
388
+
369
389
  // ─── Legacy Settings IPC ──────────────────────────
370
390
 
371
391
  ipcMain.handle('settings:get', (_event, key: string) => {
@@ -8,6 +8,7 @@ import { CtlsurfApi } from './ctlsurfApi'
8
8
  import { ConversationBridge } from './bridge'
9
9
  import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
10
10
  import { TimeTracker } from './timeTracker'
11
+ import { TicketStore, type TicketInput } from './ticketStore'
11
12
  import { log } from './logger'
12
13
 
13
14
  // ─── Types ────────────────────────────────────────
@@ -74,6 +75,7 @@ export class Orchestrator {
74
75
  readonly bridge = new ConversationBridge()
75
76
  readonly workerWs: WorkerWsClient
76
77
  readonly timeTracker = new TimeTracker(this.ctlsurfApi)
78
+ readonly ticketStore = new TicketStore(this.ctlsurfApi)
77
79
 
78
80
  // State
79
81
  private tabs = new Map<string, TabState>()
@@ -480,6 +482,41 @@ export class Orchestrator {
480
482
  }
481
483
  }
482
484
 
485
+ // ─── Tickets (active tab) ───────────────────────
486
+
487
+ /** cwd of the focused terminal tab, or null if no tab is active. */
488
+ getActiveTabCwd(): string | null {
489
+ if (!this.activeTabId) return null
490
+ return this.tabs.get(this.activeTabId)?.cwd ?? null
491
+ }
492
+
493
+ async addTicketForActiveTab(input: TicketInput): Promise<{ ok: boolean; error?: string }> {
494
+ const cwd = this.getActiveTabCwd()
495
+ if (!cwd) return { ok: false, error: 'No active terminal tab' }
496
+ if (!this.ctlsurfApi.getApiKey()) {
497
+ return { ok: false, error: 'ctlsurf API key not configured' }
498
+ }
499
+ return this.ticketStore.addTicket(cwd, input)
500
+ }
501
+
502
+ async updateTicketForActiveTab(rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
503
+ const cwd = this.getActiveTabCwd()
504
+ if (!cwd) return { ok: false, error: 'No active terminal tab' }
505
+ if (!this.ctlsurfApi.getApiKey()) {
506
+ return { ok: false, error: 'ctlsurf API key not configured' }
507
+ }
508
+ return this.ticketStore.updateTicket(cwd, rowId, input)
509
+ }
510
+
511
+ async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; error?: string }> {
512
+ const cwd = this.getActiveTabCwd()
513
+ if (!cwd) return { ok: false, tickets: [], error: 'No active terminal tab' }
514
+ if (!this.ctlsurfApi.getApiKey()) {
515
+ return { ok: false, tickets: [], error: 'ctlsurf API key not configured' }
516
+ }
517
+ return this.ticketStore.listTickets(cwd)
518
+ }
519
+
483
520
  // ─── Worker WebSocket ───────────────────────────
484
521
 
485
522
  connectWorkerWs(agent: AgentConfig, cwd: string): void {
@@ -0,0 +1,252 @@
1
+ import type { CtlsurfApi } from './ctlsurfApi'
2
+
3
+ const DATASTORE_TITLE = 'Tickets'
4
+ const AGENT_DATASTORE_PAGE_TITLE = 'Agent Datastore'
5
+ const SYSTEM_KEY = 'tickets' // stable identifier in props.system_key; lookup survives title rename
6
+
7
+ const STATUS_OPTIONS = [
8
+ { value: 'Open', color: 'blue' },
9
+ { value: 'In Progress', color: 'yellow' },
10
+ { value: 'Blocked', color: 'red' },
11
+ { value: 'Done', color: 'green' },
12
+ ]
13
+ const PRIORITY_OPTIONS = [
14
+ { value: 'Low', color: 'gray' },
15
+ { value: 'Med', color: 'yellow' },
16
+ { value: 'High', color: 'red' },
17
+ ]
18
+
19
+ interface ColumnSpec {
20
+ name: string
21
+ type: string
22
+ options?: Array<{ value: string; color?: string }>
23
+ }
24
+
25
+ const COLUMNS: ColumnSpec[] = [
26
+ { name: 'Title', type: 'text' },
27
+ { name: 'Description', type: 'text' },
28
+ { name: 'Status', type: 'select', options: STATUS_OPTIONS },
29
+ { name: 'Priority', type: 'select', options: PRIORITY_OPTIONS },
30
+ { name: 'Created', type: 'date' },
31
+ ]
32
+
33
+ export interface TicketInput {
34
+ title: string
35
+ description?: string
36
+ status?: string
37
+ priority?: string
38
+ }
39
+
40
+ export interface Ticket {
41
+ id: string
42
+ title: string
43
+ description: string
44
+ status: string
45
+ priority: string
46
+ created: string | null
47
+ }
48
+
49
+ function log(...args: unknown[]): void {
50
+ try { console.log('[ticket-store]', ...args) } catch { /* EPIPE safe */ }
51
+ }
52
+
53
+ function findPageByTitle(pages: any[], title: string): any | null {
54
+ for (const p of pages) {
55
+ if (p?.title === title) return p
56
+ if (p?.children?.length) {
57
+ const c = findPageByTitle(p.children, title)
58
+ if (c) return c
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * Per-project `Tickets` datastore manager. Mirrors TimeTracker's datastore
66
+ * resolution (find folder → Agent Datastore page → find/create block by
67
+ * system_key), minus all the session/checkpoint machinery — a ticket is a
68
+ * single manual capture, not a tracked session.
69
+ */
70
+ export class TicketStore {
71
+ private api: CtlsurfApi
72
+ private blockCache = new Map<string, string>()
73
+
74
+ constructor(api: CtlsurfApi) {
75
+ this.api = api
76
+ }
77
+
78
+ /** Resolves (or creates) the project's Tickets datastore and appends a row. */
79
+ async addTicket(cwd: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
80
+ const title = input.title?.trim()
81
+ if (!title) return { ok: false, error: 'Title is required' }
82
+ try {
83
+ const blockId = await this.ensureDatastore(cwd, true)
84
+ if (!blockId) {
85
+ return { ok: false, error: 'No ctlsurf project found for this folder' }
86
+ }
87
+ await this.api.addRow(blockId, {
88
+ Title: title,
89
+ Description: input.description?.trim() || '',
90
+ Status: input.status || 'Open',
91
+ Priority: input.priority || 'Med',
92
+ Created: new Date().toISOString(),
93
+ })
94
+ log(`Added ticket "${title}" for ${cwd}`)
95
+ return { ok: true }
96
+ } catch (err: any) {
97
+ log(`addTicket failed for ${cwd}: ${err?.message || err}`)
98
+ return { ok: false, error: err?.message || String(err) }
99
+ }
100
+ }
101
+
102
+ /** Updates an existing ticket row in the project's Tickets datastore. */
103
+ async updateTicket(cwd: string, rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
104
+ const title = input.title?.trim()
105
+ if (!title) return { ok: false, error: 'Title is required' }
106
+ try {
107
+ const blockId = await this.ensureDatastore(cwd, false)
108
+ if (!blockId) return { ok: false, error: 'No Tickets datastore for this project' }
109
+ await this.api.updateRow(blockId, rowId, {
110
+ Title: title,
111
+ Description: input.description?.trim() || '',
112
+ Status: input.status || 'Open',
113
+ Priority: input.priority || 'Med',
114
+ })
115
+ log(`Updated ticket ${rowId} for ${cwd}`)
116
+ return { ok: true }
117
+ } catch (err: any) {
118
+ log(`updateTicket failed for ${cwd}: ${err?.message || err}`)
119
+ return { ok: false, error: err?.message || String(err) }
120
+ }
121
+ }
122
+
123
+ /** Lists existing tickets for the project, newest first. Does not create the
124
+ * datastore — an unconfigured project simply has no tickets yet. */
125
+ async listTickets(cwd: string): Promise<{ ok: boolean; tickets: Ticket[]; error?: string }> {
126
+ try {
127
+ const blockId = await this.ensureDatastore(cwd, false)
128
+ if (!blockId) return { ok: true, tickets: [] }
129
+ const res = await this.api.queryRows(blockId, { orderBy: 'Created', order: 'desc', limit: 200 })
130
+ const tickets: Ticket[] = (res?.rows || []).map(r => ({
131
+ id: r.id,
132
+ title: String(r.data?.Title ?? ''),
133
+ description: String(r.data?.Description ?? ''),
134
+ status: String(r.data?.Status ?? 'Open'),
135
+ priority: String(r.data?.Priority ?? 'Med'),
136
+ created: (r.data?.Created as string) ?? r.created_at ?? null,
137
+ }))
138
+ return { ok: true, tickets }
139
+ } catch (err: any) {
140
+ log(`listTickets failed for ${cwd}: ${err?.message || err}`)
141
+ return { ok: false, tickets: [], error: err?.message || String(err) }
142
+ }
143
+ }
144
+
145
+ private async ensureDatastore(cwd: string, create: boolean): Promise<string | null> {
146
+ const cached = this.blockCache.get(cwd)
147
+ if (cached) return cached
148
+
149
+ let folder: any = null
150
+ try {
151
+ folder = await this.api.findFolderByPath(cwd)
152
+ } catch {
153
+ return null
154
+ }
155
+ if (!folder?.id) return null
156
+
157
+ const folderDetail = await this.api.getFolder(folder.id)
158
+ const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
159
+ if (!agentPage?.id) return null
160
+
161
+ const blockId = await this.findOrAdoptBlock(agentPage.id)
162
+ if (blockId) {
163
+ await this.ensureColumns(blockId)
164
+ this.blockCache.set(cwd, blockId)
165
+ return blockId
166
+ }
167
+
168
+ if (!create) return null
169
+
170
+ const columns = COLUMNS.map((c, i) => ({
171
+ id: `col_${i}`,
172
+ name: c.name,
173
+ type: c.type,
174
+ ...(c.options ? { options: c.options } : {}),
175
+ }))
176
+ const created = await this.api.createBlock(agentPage.id, {
177
+ type: 'datastore',
178
+ title: DATASTORE_TITLE,
179
+ props: { columns, system_key: SYSTEM_KEY },
180
+ })
181
+ if (created?.id) {
182
+ log(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`)
183
+ this.blockCache.set(cwd, created.id)
184
+ return created.id
185
+ }
186
+ return null
187
+ }
188
+
189
+ /** Finds an existing Tickets block on the page. Prefers a system_key match
190
+ * (which survives title renames). Falls back to title match for legacy blocks
191
+ * and backfills system_key on the way out. */
192
+ private async findOrAdoptBlock(pageId: string): Promise<string | null> {
193
+ const summaries = (await this.api.getPageBlockSummaries(pageId)) || []
194
+ const datastoreSummaries = summaries.filter((b: any) => b?.type === 'datastore' && b?.id)
195
+ if (datastoreSummaries.length === 0) return null
196
+
197
+ let titleFallbackId: string | null = null
198
+ let titleFallbackProps: Record<string, unknown> | null = null
199
+ for (const s of datastoreSummaries) {
200
+ try {
201
+ const block = await this.api.getBlock(s.id)
202
+ const props = (block?.props || {}) as Record<string, unknown>
203
+ if (props.system_key === SYSTEM_KEY) {
204
+ return s.id
205
+ }
206
+ if (s.title === DATASTORE_TITLE && titleFallbackId === null) {
207
+ titleFallbackId = s.id
208
+ titleFallbackProps = props
209
+ }
210
+ } catch (err: any) {
211
+ log(`getBlock(${s.id}) failed during lookup: ${err?.message || err}`)
212
+ }
213
+ }
214
+
215
+ if (titleFallbackId) {
216
+ try {
217
+ await this.api.updateBlock(titleFallbackId, {
218
+ props: { ...(titleFallbackProps || {}), system_key: SYSTEM_KEY },
219
+ })
220
+ log(`Backfilled system_key on legacy Tickets block ${titleFallbackId}`)
221
+ } catch (err: any) {
222
+ log(`backfill system_key failed on ${titleFallbackId}: ${err?.message || err}`)
223
+ }
224
+ return titleFallbackId
225
+ }
226
+ return null
227
+ }
228
+
229
+ private async ensureColumns(blockId: string): Promise<void> {
230
+ try {
231
+ const schema = await this.api.getDatastoreSchema(blockId)
232
+ const existingCols = schema.columns || []
233
+ const existingNames = new Set(existingCols.map(c => c.name))
234
+ const missing = COLUMNS.filter(c => !existingNames.has(c.name))
235
+ if (missing.length === 0) return
236
+
237
+ const usedIds = new Set(existingCols.map(c => c.id))
238
+ let nextIdx = existingCols.length
239
+ const appended = missing.map(c => {
240
+ let id = `col_${nextIdx++}`
241
+ while (usedIds.has(id)) id = `col_${nextIdx++}`
242
+ usedIds.add(id)
243
+ return { id, name: c.name, type: c.type, ...(c.options ? { options: c.options } : {}) }
244
+ })
245
+ const merged = [...existingCols, ...appended]
246
+ await this.api.updateDatastoreSchema(blockId, merged)
247
+ log(`Added ${missing.length} missing column(s) to existing Tickets datastore: ${missing.map(c => c.name).join(', ')}`)
248
+ } catch (err: any) {
249
+ log(`ensureColumns failed: ${err?.message || err}`)
250
+ }
251
+ }
252
+ }
@@ -82,6 +82,16 @@ const api = {
82
82
  setTracking: (enabled: boolean): Promise<{ active: boolean }> =>
83
83
  ipcRenderer.invoke('tracking:set', enabled),
84
84
 
85
+ // Tickets (active tab)
86
+ getTicketProject: (): Promise<{ cwd: string | null; name: string | null }> =>
87
+ ipcRenderer.invoke('tickets:project'),
88
+ addTicket: (input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
89
+ ipcRenderer.invoke('tickets:add', input),
90
+ updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
91
+ ipcRenderer.invoke('tickets:update', rowId, input),
92
+ listTickets: (): Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; error?: string }> =>
93
+ ipcRenderer.invoke('tickets:list'),
94
+
85
95
  // Chat logging (global)
86
96
  getLogChat: (): Promise<{ enabled: boolean }> =>
87
97
  ipcRenderer.invoke('logchat:get'),
@@ -12,6 +12,7 @@ import {
12
12
  } from './components/PaneLayout'
13
13
  import { StatusBar } from './components/StatusBar'
14
14
  import { SettingsDialog } from './components/SettingsDialog'
15
+ import { TicketPanel } from './components/TicketPanel'
15
16
 
16
17
  interface AgentConfig {
17
18
  id: string
@@ -44,6 +45,10 @@ declare global {
44
45
  saveProfile: (profileId: string, data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string }) => Promise<{ ok: boolean }>
45
46
  switchProfile: (profileId: string) => Promise<{ ok: boolean }>
46
47
  deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
48
+ getTicketProject: () => Promise<{ cwd: string | null; name: string | null }>
49
+ addTicket: (input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
50
+ updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
51
+ listTickets: () => Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; error?: string }>
47
52
  getTracking: () => Promise<{ active: boolean }>
48
53
  setTracking: (enabled: boolean) => Promise<{ active: boolean }>
49
54
  getLogChat: () => Promise<{ enabled: boolean }>
@@ -116,6 +121,7 @@ export default function App() {
116
121
  })
117
122
  const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
118
123
  const [trackingActive, setTrackingActive] = useState(false)
124
+ const [showTicketPanel, setShowTicketPanel] = useState(false)
119
125
 
120
126
  // Agent picker state: which tab is being configured (null = initial picker for first tab)
121
127
  const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
@@ -412,6 +418,20 @@ export default function App() {
412
418
  <span className="tracking-icon">⏱</span>
413
419
  <span className={`tracking-dot ${trackingActive ? 'on' : 'off'}`} />
414
420
  </button>
421
+ <button
422
+ className={`titlebar-btn titlebar-icon-btn ${showTicketPanel ? 'active' : ''}`}
423
+ onClick={() => setShowTicketPanel(v => !v)}
424
+ title="Tickets for the active project"
425
+ aria-label="Tickets"
426
+ >
427
+ <svg className="ticket-tag-icon" viewBox="0 0 24 24" width="13" height="13"
428
+ fill="none" stroke="currentColor" strokeWidth="2"
429
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
430
+ <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
431
+ <line x1="7" y1="7" x2="7.01" y2="7" />
432
+ </svg>
433
+ <span>Tickets</span>
434
+ </button>
415
435
  <span className="titlebar-separator" />
416
436
  {agents.map(a => {
417
437
  const activeTab = tabs.find(t => t.id === activeTabId)
@@ -457,6 +477,7 @@ export default function App() {
457
477
 
458
478
  <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} updateInfo={updateInfo} />
459
479
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
480
+ <TicketPanel open={showTicketPanel} onClose={() => setShowTicketPanel(false)} />
460
481
 
461
482
  {showAgentPicker && agents.length > 0 && (
462
483
  <AgentPicker