@phenx-inc/ctlsurf 0.14.2 → 0.15.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/THIRD-PARTY-NOTICES.md +82 -0
- package/out/headless/index.mjs +91 -72
- package/out/headless/index.mjs.map +3 -3
- package/out/main/index.js +117 -72
- package/out/preload/index.js +6 -1
- package/out/renderer/assets/{cssMode-bQYoCKra.js → cssMode-B77M9HlN.js} +3 -3
- package/out/renderer/assets/{freemarker2-BS1ERQl_.js → freemarker2-oUT2zvyQ.js} +1 -1
- package/out/renderer/assets/{handlebars-B-ZEHRLr.js → handlebars-jCckoi5j.js} +1 -1
- package/out/renderer/assets/{html-lk9e1Lkl.js → html-kAgrAaKW.js} +1 -1
- package/out/renderer/assets/{htmlMode-Ds1eMYgM.js → htmlMode-DIWkIawy.js} +3 -3
- package/out/renderer/assets/{index-BpCORklm.css → index-3sFJEz7i.css} +787 -399
- package/out/renderer/assets/{index-BJNR4hRK.js → index-CN3UN7Tm.js} +431 -104
- package/out/renderer/assets/{javascript-D6OeziKX.js → javascript-B9GYUE-I.js} +2 -2
- package/out/renderer/assets/{jsonMode-t5SESE75.js → jsonMode-BzFKTZjJ.js} +3 -3
- package/out/renderer/assets/{liquid-B5Wjh0p_.js → liquid-Ccw-S_Y6.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-BEgiDCIW.js → lspLanguageFeatures-BE0g7ubH.js} +1 -1
- package/out/renderer/assets/{mdx-ByNAXh8t.js → mdx-oXeAA0qU.js} +1 -1
- package/out/renderer/assets/{python-CIFO0XQW.js → python-DlSX3TvA.js} +1 -1
- package/out/renderer/assets/{razor-LXHhGcNO.js → razor-ButO9S7r.js} +1 -1
- package/out/renderer/assets/{tsMode-WiO8CDaI.js → tsMode-CTCpE-67.js} +1 -1
- package/out/renderer/assets/{typescript-ZBQlq256.js → typescript-9o62WOnS.js} +1 -1
- package/out/renderer/assets/{xml-DBaLJlQU.js → xml-C4nOH2_D.js} +1 -1
- package/out/renderer/assets/{yaml-CId9skah.js → yaml-mXYgKoWW.js} +1 -1
- package/out/renderer/index.html +60 -3
- package/package.json +5 -4
- package/src/main/index.ts +54 -3
- package/src/main/orchestrator.ts +10 -2
- package/src/main/ticketStore.ts +100 -66
- package/src/preload/index.ts +13 -3
- package/src/renderer/App.tsx +4 -2
- package/src/renderer/components/CtlsurfPanel.tsx +1 -1
- package/src/renderer/components/EditorPanel.tsx +29 -1
- package/src/renderer/components/ObserverPanel.tsx +13 -6
- package/src/renderer/components/SettingsDialog.tsx +82 -0
- package/src/renderer/components/TerminalPanel.tsx +12 -22
- package/src/renderer/components/TicketPanel.tsx +33 -15
- package/src/renderer/index.html +58 -1
- package/src/renderer/lib/theme.ts +173 -0
- package/src/renderer/lib/themes.ts +126 -0
- package/src/renderer/styles.css +787 -399
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { conf as conf$1, language as language$1 } from "./typescript-
|
|
2
|
-
import "./index-
|
|
1
|
+
import { conf as conf$1, language as language$1 } from "./typescript-9o62WOnS.js";
|
|
2
|
+
import "./index-CN3UN7Tm.js";
|
|
3
3
|
const conf = conf$1;
|
|
4
4
|
const language = {
|
|
5
5
|
// Set defaultToken to invalid to see what you do not tokenize yet
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { c as createWebWorker, l as languages, e as editor } from "./index-
|
|
2
|
-
import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-
|
|
3
|
-
import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-
|
|
1
|
+
import { c as createWebWorker, l as languages, e as editor } from "./index-CN3UN7Tm.js";
|
|
2
|
+
import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-BE0g7ubH.js";
|
|
3
|
+
import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-BE0g7ubH.js";
|
|
4
4
|
const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
|
|
5
5
|
class WorkerManager {
|
|
6
6
|
constructor(defaults) {
|
package/out/renderer/assets/{lspLanguageFeatures-BEgiDCIW.js → lspLanguageFeatures-BE0g7ubH.js}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-
|
|
1
|
+
import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-CN3UN7Tm.js";
|
|
2
2
|
var DocumentUri;
|
|
3
3
|
(function(DocumentUri2) {
|
|
4
4
|
function is(value) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-
|
|
1
|
+
import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-CN3UN7Tm.js";
|
|
2
2
|
class WorkerManager {
|
|
3
3
|
constructor(_modeId, _defaults) {
|
|
4
4
|
this._modeId = _modeId;
|
package/out/renderer/index.html
CHANGED
|
@@ -4,10 +4,42 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>ctlsurf-worker</title>
|
|
7
|
+
<!-- Stamp the theme before the first paint. This has to be an inline,
|
|
8
|
+
synchronous script in <head>: reading the pref over IPC (or waiting for
|
|
9
|
+
the bundle) would let one frame of the wrong theme through, which reads
|
|
10
|
+
as a flash every launch. lib/theme.ts owns the same key afterwards. -->
|
|
11
|
+
<script>
|
|
12
|
+
(function () {
|
|
13
|
+
var DARK = 'tokyo-night', LIGHT = 'tokyo-day'
|
|
14
|
+
var a = null
|
|
15
|
+
try {
|
|
16
|
+
var raw = localStorage.getItem('ctlsurf.appearance')
|
|
17
|
+
if (raw) a = JSON.parse(raw)
|
|
18
|
+
else {
|
|
19
|
+
// Migrate the original system/light/dark preference. lib/theme.ts
|
|
20
|
+
// performs the same mapping; both must agree or the first frame
|
|
21
|
+
// disagrees with the second.
|
|
22
|
+
var legacy = localStorage.getItem('ctlsurf.theme')
|
|
23
|
+
if (legacy === 'light') a = { sync: false, theme: LIGHT }
|
|
24
|
+
else if (legacy === 'dark') a = { sync: false, theme: DARK }
|
|
25
|
+
}
|
|
26
|
+
} catch (e) { /* private mode, or corrupt JSON */ }
|
|
27
|
+
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
|
|
28
|
+
var id
|
|
29
|
+
if (a && a.sync === false) id = a.theme
|
|
30
|
+
else if (a) id = prefersLight ? a.light : a.dark
|
|
31
|
+
else id = prefersLight ? LIGHT : DARK
|
|
32
|
+
document.documentElement.setAttribute('data-theme', id || (prefersLight ? LIGHT : DARK))
|
|
33
|
+
})()
|
|
34
|
+
</script>
|
|
7
35
|
<!-- Boot splash styles are inlined here so they paint on the first frame,
|
|
8
36
|
before the JS bundle and styles.css have loaded. Without this the window
|
|
9
|
-
shows an empty dark pane while React mounts and the startup IPC runs.
|
|
37
|
+
shows an empty dark pane while React mounts and the startup IPC runs.
|
|
38
|
+
They cannot use the tokens in styles.css for the same reason, so the two
|
|
39
|
+
palettes are repeated here — keep them in step with :root there. -->
|
|
10
40
|
<style>
|
|
41
|
+
:root { color-scheme: dark }
|
|
42
|
+
:root[data-theme='tokyo-day'], :root[data-theme='catppuccin-latte'] { color-scheme: light }
|
|
11
43
|
#boot-splash {
|
|
12
44
|
position: fixed;
|
|
13
45
|
inset: 0;
|
|
@@ -48,9 +80,34 @@
|
|
|
48
80
|
letter-spacing: 0.04em;
|
|
49
81
|
color: #565f89;
|
|
50
82
|
}
|
|
83
|
+
/* One rule per theme: the splash paints before styles.css exists, so it
|
|
84
|
+
cannot use the tokens. Keep in step with the :root blocks there. */
|
|
85
|
+
:root[data-theme='tokyo-day'] #boot-splash { background: #e9eaef; }
|
|
86
|
+
:root[data-theme='tokyo-day'] .boot-spinner {
|
|
87
|
+
border-color: rgba(46, 125, 233, 0.22);
|
|
88
|
+
border-top-color: #2e7de9;
|
|
89
|
+
}
|
|
90
|
+
:root[data-theme='tokyo-day'] .boot-splash-title { color: #1b1e33; }
|
|
91
|
+
:root[data-theme='tokyo-day'] .boot-splash-label { color: #6b7190; }
|
|
92
|
+
|
|
93
|
+
:root[data-theme='catppuccin-mocha'] #boot-splash { background: #1e1e2e; }
|
|
94
|
+
:root[data-theme='catppuccin-mocha'] .boot-spinner {
|
|
95
|
+
border-color: rgba(137, 180, 250, 0.22);
|
|
96
|
+
border-top-color: #89b4fa;
|
|
97
|
+
}
|
|
98
|
+
:root[data-theme='catppuccin-mocha'] .boot-splash-title { color: #cdd6f4; }
|
|
99
|
+
:root[data-theme='catppuccin-mocha'] .boot-splash-label { color: #7f849c; }
|
|
100
|
+
|
|
101
|
+
:root[data-theme='catppuccin-latte'] #boot-splash { background: #eff1f5; }
|
|
102
|
+
:root[data-theme='catppuccin-latte'] .boot-spinner {
|
|
103
|
+
border-color: rgba(30, 102, 245, 0.22);
|
|
104
|
+
border-top-color: #1e66f5;
|
|
105
|
+
}
|
|
106
|
+
:root[data-theme='catppuccin-latte'] .boot-splash-title { color: #2f3145; }
|
|
107
|
+
:root[data-theme='catppuccin-latte'] .boot-splash-label { color: #7c7f93; }
|
|
51
108
|
</style>
|
|
52
|
-
<script type="module" crossorigin src="./assets/index-
|
|
53
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
109
|
+
<script type="module" crossorigin src="./assets/index-CN3UN7Tm.js"></script>
|
|
110
|
+
<link rel="stylesheet" crossorigin href="./assets/index-3sFJEz7i.css">
|
|
54
111
|
</head>
|
|
55
112
|
<body>
|
|
56
113
|
<div id="boot-splash">
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phenx-inc/ctlsurf",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agent-agnostic terminal and desktop app for ctlsurf
|
|
3
|
+
"version": "0.15.1",
|
|
4
|
+
"description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
|
|
5
5
|
"main": "out/main/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ctlsurf": "./bin/ctlsurf-worker.js",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"src/",
|
|
16
16
|
"package.json",
|
|
17
17
|
"electron-vite.config.ts",
|
|
18
|
-
"tsconfig*.json"
|
|
18
|
+
"tsconfig*.json",
|
|
19
|
+
"THIRD-PARTY-NOTICES.md"
|
|
19
20
|
],
|
|
20
21
|
"scripts": {
|
|
21
22
|
"dev": "electron-vite dev",
|
|
@@ -84,4 +85,4 @@
|
|
|
84
85
|
"bugs": {
|
|
85
86
|
"email": "phenx@phenx.io"
|
|
86
87
|
}
|
|
87
|
-
}
|
|
88
|
+
}
|
package/src/main/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { app, BrowserWindow, ipcMain, dialog, nativeImage, clipboard } from 'electron'
|
|
1
|
+
import { app, BrowserWindow, ipcMain, dialog, nativeImage, clipboard, nativeTheme } from 'electron'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import fs from 'fs'
|
|
4
4
|
|
|
@@ -233,12 +233,24 @@ const orchestrator = new Orchestrator(
|
|
|
233
233
|
// ─── Window ───────────────────────────────────────
|
|
234
234
|
|
|
235
235
|
function createWindow(): void {
|
|
236
|
+
// Replay last session's appearance choice before the window exists, so the
|
|
237
|
+
// frame Electron paints before the renderer loads is already the right
|
|
238
|
+
// colour. The renderer re-asserts this over app:setAppearance once booted.
|
|
239
|
+
const saved = orchestrator.settingsData
|
|
240
|
+
if (saved.themeSource === 'light' || saved.themeSource === 'dark' || saved.themeSource === 'system') {
|
|
241
|
+
nativeTheme.themeSource = saved.themeSource
|
|
242
|
+
}
|
|
243
|
+
const savedBg = typeof saved.themeBackground === 'string' && /^#[0-9a-fA-F]{6}$/.test(saved.themeBackground)
|
|
244
|
+
? saved.themeBackground
|
|
245
|
+
: null
|
|
246
|
+
|
|
236
247
|
mainWindow = new BrowserWindow({
|
|
237
248
|
width: 1400,
|
|
238
249
|
height: 900,
|
|
239
250
|
minWidth: 600,
|
|
240
251
|
minHeight: 400,
|
|
241
252
|
title: 'ctlsurf-worker',
|
|
253
|
+
backgroundColor: savedBg ?? (nativeTheme.shouldUseDarkColors ? '#1a1b26' : '#e9eaef'),
|
|
242
254
|
titleBarStyle: 'hiddenInset',
|
|
243
255
|
icon: path.join(__dirname, '../../resources/icon.png'),
|
|
244
256
|
webPreferences: {
|
|
@@ -388,6 +400,45 @@ ipcMain.handle('app:homePath', () => app.getPath('home'))
|
|
|
388
400
|
ipcMain.handle('app:cwd', () => process.env.CTLSURF_WORKER_CWD || process.cwd())
|
|
389
401
|
ipcMain.handle('app:projectName', () => orchestrator.projectName)
|
|
390
402
|
|
|
403
|
+
// The renderer owns the theme preference (see lib/theme.ts) and mirrors it here
|
|
404
|
+
// so the parts Chromium paints for us — native form controls, scrollbars, and
|
|
405
|
+
// the window background between loads — match the app chrome. Also remembered
|
|
406
|
+
// so the next launch can paint the right backgroundColor before the renderer
|
|
407
|
+
// has booted.
|
|
408
|
+
// The renderer owns the appearance preference (see renderer/lib/theme.ts) and
|
|
409
|
+
// mirrors it here for two reasons: so Chromium paints native form controls,
|
|
410
|
+
// scrollbars and the pre-load window background to match, and so createWindow
|
|
411
|
+
// can pick the right backgroundColor on the *next* launch, before the renderer
|
|
412
|
+
// has booted. It is also the recovery source if localStorage is ever wiped.
|
|
413
|
+
ipcMain.handle('app:getAppearance', () => ({
|
|
414
|
+
pref: orchestrator.settingsData.appearance ?? null,
|
|
415
|
+
}))
|
|
416
|
+
|
|
417
|
+
ipcMain.handle('app:setAppearance', (_e, data: {
|
|
418
|
+
pref?: unknown
|
|
419
|
+
source?: 'system' | 'light' | 'dark'
|
|
420
|
+
background?: string
|
|
421
|
+
}) => {
|
|
422
|
+
const source = data?.source
|
|
423
|
+
if (source !== 'system' && source !== 'light' && source !== 'dark') return { ok: false }
|
|
424
|
+
nativeTheme.themeSource = source
|
|
425
|
+
|
|
426
|
+
const settings = orchestrator.settingsData
|
|
427
|
+
if (data.pref && typeof data.pref === 'object') {
|
|
428
|
+
settings.appearance = data.pref as Record<string, unknown>
|
|
429
|
+
}
|
|
430
|
+
settings.themeSource = source
|
|
431
|
+
// Only trust a plain hex colour — this value is handed straight to Electron.
|
|
432
|
+
if (typeof data.background === 'string' && /^#[0-9a-fA-F]{6}$/.test(data.background)) {
|
|
433
|
+
settings.themeBackground = data.background
|
|
434
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
435
|
+
mainWindow.setBackgroundColor(data.background)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
orchestrator.saveSettings()
|
|
439
|
+
return { ok: true }
|
|
440
|
+
})
|
|
441
|
+
|
|
391
442
|
ipcMain.handle('app:browseCwd', async () => {
|
|
392
443
|
if (!mainWindow) return null
|
|
393
444
|
const result = await dialog.showOpenDialog(mainWindow, {
|
|
@@ -659,8 +710,8 @@ ipcMain.handle('tickets:add', async (_event, input: {
|
|
|
659
710
|
})
|
|
660
711
|
ipcMain.handle('tickets:update', async (_event, rowId: string, input: {
|
|
661
712
|
title: string; description?: string; status?: string; priority?: string
|
|
662
|
-
}) => {
|
|
663
|
-
return orchestrator.updateTicketForActiveTab(rowId, input)
|
|
713
|
+
}, blockId?: string) => {
|
|
714
|
+
return orchestrator.updateTicketForActiveTab(rowId, input, blockId)
|
|
664
715
|
})
|
|
665
716
|
ipcMain.handle('tickets:list', async () => {
|
|
666
717
|
return orchestrator.listTicketsForActiveTab()
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -43,6 +43,14 @@ export interface SettingsData {
|
|
|
43
43
|
// src/main/observer/. Requires an Anthropic API key for Tiers 1–2;
|
|
44
44
|
// without one it still records the ledger, Tier-0 tags, and spans.
|
|
45
45
|
observer?: ObserverSettings
|
|
46
|
+
// Mirror of the renderer's appearance preference (localStorage owns the
|
|
47
|
+
// authoritative copy — index.html has to read it before first paint). Kept
|
|
48
|
+
// here so createWindow can pick the right backgroundColor on the next launch
|
|
49
|
+
// before the renderer boots, and so the choice survives a wiped localStorage.
|
|
50
|
+
// Shape is owned by renderer/lib/theme.ts; the main process only round-trips it.
|
|
51
|
+
appearance?: Record<string, unknown>
|
|
52
|
+
themeSource?: 'system' | 'light' | 'dark'
|
|
53
|
+
themeBackground?: string
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export interface OrchestratorEvents {
|
|
@@ -772,13 +780,13 @@ export class Orchestrator {
|
|
|
772
780
|
return this.ticketStore.addTicket(cwd, input)
|
|
773
781
|
}
|
|
774
782
|
|
|
775
|
-
async updateTicketForActiveTab(rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
|
|
783
|
+
async updateTicketForActiveTab(rowId: string, input: TicketInput, blockId?: string): Promise<{ ok: boolean; error?: string }> {
|
|
776
784
|
const cwd = this.getActiveTabProjectPath()
|
|
777
785
|
if (!cwd) return { ok: false, error: 'No active terminal tab' }
|
|
778
786
|
if (!this.ctlsurfApi.getApiKey()) {
|
|
779
787
|
return { ok: false, error: 'ctlsurf API key not configured' }
|
|
780
788
|
}
|
|
781
|
-
return this.ticketStore.updateTicket(cwd, rowId, input)
|
|
789
|
+
return this.ticketStore.updateTicket(cwd, rowId, input, blockId)
|
|
782
790
|
}
|
|
783
791
|
|
|
784
792
|
async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; blockId?: string; pageId?: string; error?: string }> {
|
package/src/main/ticketStore.ts
CHANGED
|
@@ -45,23 +45,31 @@ export interface Ticket {
|
|
|
45
45
|
status: string
|
|
46
46
|
priority: string
|
|
47
47
|
created: string | null
|
|
48
|
+
// Owning datastore, so edits and agent dispatches target the store this row
|
|
49
|
+
// actually lives in — a folder can accumulate more than one Tickets store.
|
|
50
|
+
blockId: string
|
|
51
|
+
pageId: string
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
function log(...args: unknown[]): void {
|
|
51
55
|
baseLog('[ticket-store]', ...args)
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
|
|
58
|
+
/** Collects every page with the given title, in tree order. A folder can end
|
|
59
|
+
* up with duplicate "Agent Datastore" pages (e.g. an agent creating its own
|
|
60
|
+
* under an unrelated page), with tickets split across them. */
|
|
61
|
+
function findPagesByTitle(pages: any[], title: string): any[] {
|
|
62
|
+
const found: any[] = []
|
|
55
63
|
for (const p of pages) {
|
|
56
|
-
if (p?.title === title)
|
|
57
|
-
if (p?.children?.length)
|
|
58
|
-
const c = findPageByTitle(p.children, title)
|
|
59
|
-
if (c) return c
|
|
60
|
-
}
|
|
64
|
+
if (p?.title === title) found.push(p)
|
|
65
|
+
if (p?.children?.length) found.push(...findPagesByTitle(p.children, title))
|
|
61
66
|
}
|
|
62
|
-
return
|
|
67
|
+
return found
|
|
63
68
|
}
|
|
64
69
|
|
|
70
|
+
interface DsRef { blockId: string; pageId: string }
|
|
71
|
+
interface ResolvedStores { primary: DsRef; all: DsRef[] }
|
|
72
|
+
|
|
65
73
|
/**
|
|
66
74
|
* Per-project `Tickets` datastore manager. Mirrors TimeTracker's datastore
|
|
67
75
|
* resolution (find folder → Agent Datastore page → find/create block by
|
|
@@ -70,10 +78,10 @@ function findPageByTitle(pages: any[], title: string): any | null {
|
|
|
70
78
|
*/
|
|
71
79
|
export class TicketStore {
|
|
72
80
|
private api: CtlsurfApi
|
|
73
|
-
// Per-cwd cache of
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
private dsCache = new Map<string,
|
|
81
|
+
// Per-cwd cache of every resolved Tickets datastore. `primary` is where new
|
|
82
|
+
// tickets are written and what gets handed to the coding agent; `all` covers
|
|
83
|
+
// duplicate Agent Datastore pages so listing misses nothing.
|
|
84
|
+
private dsCache = new Map<string, ResolvedStores>()
|
|
77
85
|
|
|
78
86
|
constructor(api: CtlsurfApi) {
|
|
79
87
|
this.api = api
|
|
@@ -84,11 +92,11 @@ export class TicketStore {
|
|
|
84
92
|
const title = input.title?.trim()
|
|
85
93
|
if (!title) return { ok: false, error: 'Title is required' }
|
|
86
94
|
try {
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
95
|
+
const stores = await this.resolveStores(cwd, true)
|
|
96
|
+
if (!stores) {
|
|
89
97
|
return { ok: false, error: 'No ctlsurf project found for this folder' }
|
|
90
98
|
}
|
|
91
|
-
await this.api.addRow(
|
|
99
|
+
await this.api.addRow(stores.primary.blockId, {
|
|
92
100
|
Title: title,
|
|
93
101
|
Description: input.description?.trim() || '',
|
|
94
102
|
Status: input.status || 'Open',
|
|
@@ -103,14 +111,15 @@ export class TicketStore {
|
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
/** Updates an existing ticket row
|
|
107
|
-
|
|
114
|
+
/** Updates an existing ticket row. `blockId` names the store the row lives
|
|
115
|
+
* in (from listTickets); without it the primary store is assumed. */
|
|
116
|
+
async updateTicket(cwd: string, rowId: string, input: TicketInput, blockId?: string): Promise<{ ok: boolean; error?: string }> {
|
|
108
117
|
const title = input.title?.trim()
|
|
109
118
|
if (!title) return { ok: false, error: 'Title is required' }
|
|
110
119
|
try {
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
113
|
-
await this.api.updateRow(
|
|
120
|
+
const stores = await this.resolveStores(cwd, false)
|
|
121
|
+
if (!stores) return { ok: false, error: 'No Tickets datastore for this project' }
|
|
122
|
+
await this.api.updateRow(blockId || stores.primary.blockId, rowId, {
|
|
114
123
|
Title: title,
|
|
115
124
|
Description: input.description?.trim() || '',
|
|
116
125
|
Status: input.status || 'Open',
|
|
@@ -124,29 +133,42 @@ export class TicketStore {
|
|
|
124
133
|
}
|
|
125
134
|
}
|
|
126
135
|
|
|
127
|
-
/** Lists existing tickets
|
|
128
|
-
*
|
|
136
|
+
/** Lists existing tickets across every Tickets store in the project's
|
|
137
|
+
* folder, newest first. Does not create the datastore — an unconfigured
|
|
138
|
+
* project simply has no tickets yet. */
|
|
129
139
|
async listTickets(cwd: string): Promise<{ ok: boolean; tickets: Ticket[]; blockId?: string; pageId?: string; error?: string }> {
|
|
130
140
|
try {
|
|
131
|
-
const
|
|
132
|
-
if (!
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
const stores = await this.resolveStores(cwd, false)
|
|
142
|
+
if (!stores) return { ok: true, tickets: [] }
|
|
143
|
+
const tickets: Ticket[] = []
|
|
144
|
+
for (const ds of stores.all) {
|
|
145
|
+
const res = await this.api.queryRows(ds.blockId, { orderBy: 'Created', order: 'desc', limit: 200 })
|
|
146
|
+
for (const r of res?.rows || []) {
|
|
147
|
+
tickets.push({
|
|
148
|
+
id: r.id,
|
|
149
|
+
title: String(r.data?.Title ?? ''),
|
|
150
|
+
description: String(r.data?.Description ?? ''),
|
|
151
|
+
status: String(r.data?.Status ?? 'Open'),
|
|
152
|
+
priority: String(r.data?.Priority ?? 'Med'),
|
|
153
|
+
created: (r.data?.Created as string) ?? r.created_at ?? null,
|
|
154
|
+
blockId: ds.blockId,
|
|
155
|
+
pageId: ds.pageId,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Each store is queried newest-first; re-sort the merged list so rows
|
|
160
|
+
// from different stores interleave correctly (ISO strings compare fine).
|
|
161
|
+
if (stores.all.length > 1) {
|
|
162
|
+
tickets.sort((a, b) => (b.created || '').localeCompare(a.created || ''))
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, tickets, blockId: stores.primary.blockId, pageId: stores.primary.pageId }
|
|
143
165
|
} catch (err: any) {
|
|
144
166
|
log(`listTickets failed for ${cwd}: ${err?.message || err}`)
|
|
145
167
|
return { ok: false, tickets: [], error: err?.message || String(err) }
|
|
146
168
|
}
|
|
147
169
|
}
|
|
148
170
|
|
|
149
|
-
private async
|
|
171
|
+
private async resolveStores(cwd: string, create: boolean): Promise<ResolvedStores | null> {
|
|
150
172
|
const cached = this.dsCache.get(cwd)
|
|
151
173
|
if (cached) return cached
|
|
152
174
|
|
|
@@ -159,20 +181,34 @@ export class TicketStore {
|
|
|
159
181
|
if (!folder?.id) return null
|
|
160
182
|
|
|
161
183
|
const folderDetail = await this.api.getFolder(folder.id)
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
const pageId: string = agentPage.id
|
|
184
|
+
const agentPages = findPagesByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
|
|
185
|
+
if (agentPages.length === 0) return null
|
|
165
186
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
187
|
+
// The page bound to the codebase root is the canonical one; prefer it as
|
|
188
|
+
// the write target. Stable sort keeps tree order among the rest.
|
|
189
|
+
const rootPath = folder.root_path || cwd
|
|
190
|
+
agentPages.sort((a, b) => Number(b?.root_path === rootPath) - Number(a?.root_path === rootPath))
|
|
191
|
+
if (agentPages.length > 1) {
|
|
192
|
+
log(`Found ${agentPages.length} "Agent Datastore" pages for ${cwd}; aggregating tickets across all of them`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const all: DsRef[] = []
|
|
196
|
+
for (const page of agentPages) {
|
|
197
|
+
for (const blockId of await this.findTicketBlocks(page.id)) {
|
|
198
|
+
all.push({ blockId, pageId: page.id })
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (all.length > 0) {
|
|
203
|
+
for (const ds of all) await this.ensureColumns(ds.blockId)
|
|
204
|
+
const stores = { primary: all[0], all }
|
|
205
|
+
this.dsCache.set(cwd, stores)
|
|
206
|
+
return stores
|
|
172
207
|
}
|
|
173
208
|
|
|
174
209
|
if (!create) return null
|
|
175
210
|
|
|
211
|
+
const pageId: string = agentPages[0].id
|
|
176
212
|
const columns = COLUMNS.map((c, i) => ({
|
|
177
213
|
id: `col_${i}`,
|
|
178
214
|
name: c.name,
|
|
@@ -187,50 +223,48 @@ export class TicketStore {
|
|
|
187
223
|
if (created?.id) {
|
|
188
224
|
log(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`)
|
|
189
225
|
const ds = { blockId: created.id, pageId }
|
|
190
|
-
|
|
191
|
-
|
|
226
|
+
const stores = { primary: ds, all: [ds] }
|
|
227
|
+
this.dsCache.set(cwd, stores)
|
|
228
|
+
return stores
|
|
192
229
|
}
|
|
193
230
|
return null
|
|
194
231
|
}
|
|
195
232
|
|
|
196
|
-
/** Finds
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
|
|
233
|
+
/** Finds every Tickets block on the page: system_key matches first, then
|
|
234
|
+
* title matches for legacy blocks. When no system_key block exists, the
|
|
235
|
+
* first legacy block is adopted (system_key backfilled so it survives a
|
|
236
|
+
* later rename); extra title matches are still listed but left unstamped so
|
|
237
|
+
* the primary can't flip between sessions. */
|
|
238
|
+
private async findTicketBlocks(pageId: string): Promise<string[]> {
|
|
200
239
|
const summaries = (await this.api.getPageBlockSummaries(pageId)) || []
|
|
201
240
|
const datastoreSummaries = summaries.filter((b: any) => b?.type === 'datastore' && b?.id)
|
|
202
|
-
if (datastoreSummaries.length === 0) return
|
|
241
|
+
if (datastoreSummaries.length === 0) return []
|
|
203
242
|
|
|
204
|
-
|
|
205
|
-
|
|
243
|
+
const keyed: string[] = []
|
|
244
|
+
const legacy: Array<{ id: string; props: Record<string, unknown> }> = []
|
|
206
245
|
for (const s of datastoreSummaries) {
|
|
207
246
|
try {
|
|
208
247
|
const block = await this.api.getBlock(s.id)
|
|
209
248
|
const props = (block?.props || {}) as Record<string, unknown>
|
|
210
|
-
if (props.system_key === SYSTEM_KEY)
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
if (s.title === DATASTORE_TITLE && titleFallbackId === null) {
|
|
214
|
-
titleFallbackId = s.id
|
|
215
|
-
titleFallbackProps = props
|
|
216
|
-
}
|
|
249
|
+
if (props.system_key === SYSTEM_KEY) keyed.push(s.id)
|
|
250
|
+
else if (s.title === DATASTORE_TITLE) legacy.push({ id: s.id, props })
|
|
217
251
|
} catch (err: any) {
|
|
218
252
|
log(`getBlock(${s.id}) failed during lookup: ${err?.message || err}`)
|
|
219
253
|
}
|
|
220
254
|
}
|
|
221
255
|
|
|
222
|
-
if (
|
|
256
|
+
if (keyed.length === 0 && legacy.length > 0) {
|
|
257
|
+
const adopt = legacy[0]
|
|
223
258
|
try {
|
|
224
|
-
await this.api.updateBlock(
|
|
225
|
-
props: { ...
|
|
259
|
+
await this.api.updateBlock(adopt.id, {
|
|
260
|
+
props: { ...adopt.props, system_key: SYSTEM_KEY },
|
|
226
261
|
})
|
|
227
|
-
log(`Backfilled system_key on legacy Tickets block ${
|
|
262
|
+
log(`Backfilled system_key on legacy Tickets block ${adopt.id}`)
|
|
228
263
|
} catch (err: any) {
|
|
229
|
-
log(`backfill system_key failed on ${
|
|
264
|
+
log(`backfill system_key failed on ${adopt.id}: ${err?.message || err}`)
|
|
230
265
|
}
|
|
231
|
-
return titleFallbackId
|
|
232
266
|
}
|
|
233
|
-
return
|
|
267
|
+
return [...keyed, ...legacy.map(l => l.id)]
|
|
234
268
|
}
|
|
235
269
|
|
|
236
270
|
private async ensureColumns(blockId: string): Promise<void> {
|
package/src/preload/index.ts
CHANGED
|
@@ -79,6 +79,16 @@ const api = {
|
|
|
79
79
|
},
|
|
80
80
|
browseCwd: (): Promise<string | null> =>
|
|
81
81
|
ipcRenderer.invoke('app:browseCwd'),
|
|
82
|
+
// Mirror the renderer's appearance preference onto Chromium's nativeTheme and
|
|
83
|
+
// persist it. localStorage stays the source of truth (see renderer/lib/theme.ts);
|
|
84
|
+
// this copy backs the pre-load window background and localStorage recovery.
|
|
85
|
+
setAppearance: (data: {
|
|
86
|
+
pref: unknown
|
|
87
|
+
source: 'system' | 'light' | 'dark'
|
|
88
|
+
background: string
|
|
89
|
+
}): Promise<{ ok: boolean }> => ipcRenderer.invoke('app:setAppearance', data),
|
|
90
|
+
getAppearance: (): Promise<{ pref: unknown }> =>
|
|
91
|
+
ipcRenderer.invoke('app:getAppearance'),
|
|
82
92
|
|
|
83
93
|
// Clipboard — write a rich-HTML table (+ plain-text fallback) for email paste.
|
|
84
94
|
// `ok` reflects a read-back verification in the main process, not just that the
|
|
@@ -128,9 +138,9 @@ const api = {
|
|
|
128
138
|
ipcRenderer.invoke('tickets:project'),
|
|
129
139
|
addTicket: (input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
|
|
130
140
|
ipcRenderer.invoke('tickets:add', input),
|
|
131
|
-
updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
|
|
132
|
-
ipcRenderer.invoke('tickets:update', rowId, input),
|
|
133
|
-
listTickets: (): Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; error?: string }> =>
|
|
141
|
+
updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }, blockId?: string): Promise<{ ok: boolean; error?: string }> =>
|
|
142
|
+
ipcRenderer.invoke('tickets:update', rowId, input, blockId),
|
|
143
|
+
listTickets: (): Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null; blockId?: string; pageId?: string }>; blockId?: string; pageId?: string; error?: string }> =>
|
|
134
144
|
ipcRenderer.invoke('tickets:list'),
|
|
135
145
|
|
|
136
146
|
// Chat logging (global)
|
package/src/renderer/App.tsx
CHANGED
|
@@ -50,6 +50,8 @@ declare global {
|
|
|
50
50
|
getProjectName: () => Promise<string | null>
|
|
51
51
|
onProjectChanged: (callback: (name: string | null) => void) => () => void
|
|
52
52
|
browseCwd: () => Promise<string | null>
|
|
53
|
+
setAppearance?: (data: { pref: unknown; source: 'system' | 'light' | 'dark'; background: string }) => Promise<{ ok: boolean }>
|
|
54
|
+
getAppearance?: () => Promise<{ pref: unknown }>
|
|
53
55
|
getSetting: (key: string) => Promise<string | null>
|
|
54
56
|
setSetting: (key: string, value: string) => Promise<{ ok: boolean }>
|
|
55
57
|
getAllSettings: () => Promise<Record<string, string | null>>
|
|
@@ -60,8 +62,8 @@ declare global {
|
|
|
60
62
|
deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
|
|
61
63
|
getTicketProject: () => Promise<{ cwd: string | null; name: string | null }>
|
|
62
64
|
addTicket: (input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
|
|
63
|
-
updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
|
|
64
|
-
listTickets: () => Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; blockId?: string; pageId?: string; error?: string }>
|
|
65
|
+
updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }, blockId?: string) => Promise<{ ok: boolean; error?: string }>
|
|
66
|
+
listTickets: () => Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null; blockId?: string; pageId?: string }>; blockId?: string; pageId?: string; error?: string }>
|
|
65
67
|
getTracking: () => Promise<{ active: boolean }>
|
|
66
68
|
setTracking: (enabled: boolean) => Promise<{ active: boolean }>
|
|
67
69
|
getLogChat: () => Promise<{ enabled: boolean }>
|
|
@@ -190,7 +190,7 @@ export function CtlsurfPanel({ navigate }: CtlsurfPanelProps) {
|
|
|
190
190
|
/>
|
|
191
191
|
) : (
|
|
192
192
|
<div className="ctlsurf-webview-loading">
|
|
193
|
-
<span style={{ color: '
|
|
193
|
+
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Loading ctlsurf...</span>
|
|
194
194
|
</div>
|
|
195
195
|
)}
|
|
196
196
|
</div>
|