@phenx-inc/ctlsurf 0.12.0 → 0.13.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 +29 -19
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +35 -19
- package/out/renderer/assets/{cssMode-R8fTFLae.js → cssMode-KhYyY-BM.js} +3 -3
- package/out/renderer/assets/{freemarker2-CyjWzWoP.js → freemarker2-BFsSgcWk.js} +1 -1
- package/out/renderer/assets/{handlebars-CrhCGIRM.js → handlebars-Be6_aBsm.js} +1 -1
- package/out/renderer/assets/{html-Cc3dPNZr.js → html-G8mvsyfc.js} +1 -1
- package/out/renderer/assets/{htmlMode-BWtG6ywk.js → htmlMode-Cfd5VCjw.js} +3 -3
- package/out/renderer/assets/{index-BL9njXIL.css → index-Bi_MjBqs.css} +233 -8
- package/out/renderer/assets/{index-kkG56aKv.js → index-MqgBcJzy.js} +392 -120
- package/out/renderer/assets/{javascript-C8T_SdLC.js → javascript-XyDWi6dC.js} +2 -2
- package/out/renderer/assets/{jsonMode-D1zR4VVU.js → jsonMode-DZT5gBVW.js} +3 -3
- package/out/renderer/assets/{liquid-COSMe9Uf.js → liquid-BuajCNJ9.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-CXGfrtzl.js → lspLanguageFeatures-BYkg2Yc-.js} +1 -1
- package/out/renderer/assets/{mdx-RfS44T-L.js → mdx-D0Qx2GMS.js} +1 -1
- package/out/renderer/assets/{python-DFSWKHUu.js → python-ChncNy6S.js} +1 -1
- package/out/renderer/assets/{razor-DtH2_uvo.js → razor-C6MUtfaS.js} +1 -1
- package/out/renderer/assets/{tsMode-BYIQ-S9Z.js → tsMode-C2aMdzdU.js} +1 -1
- package/out/renderer/assets/{typescript-HhQUr1Rb.js → typescript-CB71AtAT.js} +1 -1
- package/out/renderer/assets/{xml-KuhgdKfD.js → xml-tHS1Ec9w.js} +1 -1
- package/out/renderer/assets/{yaml-W_kzCFH7.js → yaml-CZcp5ueF.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/index.ts +13 -1
- package/src/main/orchestrator.ts +8 -1
- package/src/main/ticketStore.ts +26 -20
- package/src/renderer/App.tsx +19 -3
- package/src/renderer/components/CtlsurfPanel.tsx +169 -35
- package/src/renderer/components/SpeakControls.tsx +8 -2
- package/src/renderer/components/TerminalPanel.tsx +141 -44
- package/src/renderer/components/TicketPanel.tsx +126 -29
- package/src/renderer/styles.css +233 -8
|
@@ -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-CB71AtAT.js";
|
|
2
|
+
import "./index-MqgBcJzy.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-MqgBcJzy.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-BYkg2Yc-.js";
|
|
3
|
+
import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-BYkg2Yc-.js";
|
|
4
4
|
const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
|
|
5
5
|
class WorkerManager {
|
|
6
6
|
constructor(defaults) {
|
package/out/renderer/assets/{lspLanguageFeatures-CXGfrtzl.js → lspLanguageFeatures-BYkg2Yc-.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-MqgBcJzy.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-MqgBcJzy.js";
|
|
2
2
|
class WorkerManager {
|
|
3
3
|
constructor(_modeId, _defaults) {
|
|
4
4
|
this._modeId = _modeId;
|
package/out/renderer/index.html
CHANGED
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
color: #565f89;
|
|
50
50
|
}
|
|
51
51
|
</style>
|
|
52
|
-
<script type="module" crossorigin src="./assets/index-
|
|
53
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
52
|
+
<script type="module" crossorigin src="./assets/index-MqgBcJzy.js"></script>
|
|
53
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Bi_MjBqs.css">
|
|
54
54
|
</head>
|
|
55
55
|
<body>
|
|
56
56
|
<div id="boot-splash">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phenx-inc/ctlsurf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
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": {
|
package/src/main/index.ts
CHANGED
|
@@ -220,7 +220,10 @@ const orchestrator = new Orchestrator(
|
|
|
220
220
|
},
|
|
221
221
|
onCwdChanged: () => {
|
|
222
222
|
mainWindow?.webContents.send('app:cwdChanged')
|
|
223
|
-
|
|
223
|
+
// Reflect the active tab's bound project (remote SSH sessions bind to a
|
|
224
|
+
// chosen ctlsurf project whose root_path is meaningful), falling back to
|
|
225
|
+
// the local launch cwd for unbound/local tabs.
|
|
226
|
+
updateProjectBadge(orchestrator.getActiveTabProjectPath() || orchestrator.cwd)
|
|
224
227
|
},
|
|
225
228
|
}
|
|
226
229
|
)
|
|
@@ -251,6 +254,15 @@ function createWindow(): void {
|
|
|
251
254
|
callback(true)
|
|
252
255
|
})
|
|
253
256
|
|
|
257
|
+
// Keep links opened from inside the embedded ctlsurf webview (target=_blank,
|
|
258
|
+
// window.open) in the panel instead of spawning a detached popup window.
|
|
259
|
+
mainWindow.webContents.on('did-attach-webview', (_e, contents) => {
|
|
260
|
+
contents.setWindowOpenHandler(({ url }) => {
|
|
261
|
+
setImmediate(() => contents.loadURL(url))
|
|
262
|
+
return { action: 'deny' }
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
254
266
|
if (process.env.ELECTRON_RENDERER_URL) {
|
|
255
267
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
|
256
268
|
} else {
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -627,12 +627,19 @@ export class Orchestrator {
|
|
|
627
627
|
}
|
|
628
628
|
|
|
629
629
|
setActiveTab(tabId: string): void {
|
|
630
|
+
const prevProjectPath = this.getActiveTabProjectPath()
|
|
630
631
|
this.activeTabId = tabId
|
|
631
632
|
const tab = this.tabs.get(tabId)
|
|
632
633
|
if (tab) {
|
|
633
634
|
this.currentAgent = tab.agent
|
|
634
635
|
this.currentCwd = tab.cwd
|
|
635
636
|
}
|
|
637
|
+
// Switching tabs changes which project the dock badge / window title and
|
|
638
|
+
// the renderer's ticket/ctlsurf panels should reflect. Only signal when the
|
|
639
|
+
// effective project path actually changed to avoid needless renderer churn.
|
|
640
|
+
if (this.getActiveTabProjectPath() !== prevProjectPath) {
|
|
641
|
+
this.events.onCwdChanged()
|
|
642
|
+
}
|
|
636
643
|
}
|
|
637
644
|
|
|
638
645
|
getTabIds(): string[] {
|
|
@@ -701,7 +708,7 @@ export class Orchestrator {
|
|
|
701
708
|
return this.ticketStore.updateTicket(cwd, rowId, input)
|
|
702
709
|
}
|
|
703
710
|
|
|
704
|
-
async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; error?: string }> {
|
|
711
|
+
async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; blockId?: string; pageId?: string; error?: string }> {
|
|
705
712
|
const cwd = this.getActiveTabProjectPath()
|
|
706
713
|
if (!cwd) return { ok: false, tickets: [], error: 'No active terminal tab' }
|
|
707
714
|
if (!this.ctlsurfApi.getApiKey()) {
|
package/src/main/ticketStore.ts
CHANGED
|
@@ -69,7 +69,10 @@ function findPageByTitle(pages: any[], title: string): any | null {
|
|
|
69
69
|
*/
|
|
70
70
|
export class TicketStore {
|
|
71
71
|
private api: CtlsurfApi
|
|
72
|
-
|
|
72
|
+
// Per-cwd cache of the resolved Tickets datastore: the block (the datastore
|
|
73
|
+
// element) and the Agent Datastore page it lives on. Both ids are handed to
|
|
74
|
+
// the coding agent so it can read/update tickets via the ctlsurf MCP tools.
|
|
75
|
+
private dsCache = new Map<string, { blockId: string; pageId: string }>()
|
|
73
76
|
|
|
74
77
|
constructor(api: CtlsurfApi) {
|
|
75
78
|
this.api = api
|
|
@@ -80,11 +83,11 @@ export class TicketStore {
|
|
|
80
83
|
const title = input.title?.trim()
|
|
81
84
|
if (!title) return { ok: false, error: 'Title is required' }
|
|
82
85
|
try {
|
|
83
|
-
const
|
|
84
|
-
if (!
|
|
86
|
+
const ds = await this.ensureDatastore(cwd, true)
|
|
87
|
+
if (!ds) {
|
|
85
88
|
return { ok: false, error: 'No ctlsurf project found for this folder' }
|
|
86
89
|
}
|
|
87
|
-
await this.api.addRow(blockId, {
|
|
90
|
+
await this.api.addRow(ds.blockId, {
|
|
88
91
|
Title: title,
|
|
89
92
|
Description: input.description?.trim() || '',
|
|
90
93
|
Status: input.status || 'Open',
|
|
@@ -104,9 +107,9 @@ export class TicketStore {
|
|
|
104
107
|
const title = input.title?.trim()
|
|
105
108
|
if (!title) return { ok: false, error: 'Title is required' }
|
|
106
109
|
try {
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
109
|
-
await this.api.updateRow(blockId, rowId, {
|
|
110
|
+
const ds = await this.ensureDatastore(cwd, false)
|
|
111
|
+
if (!ds) return { ok: false, error: 'No Tickets datastore for this project' }
|
|
112
|
+
await this.api.updateRow(ds.blockId, rowId, {
|
|
110
113
|
Title: title,
|
|
111
114
|
Description: input.description?.trim() || '',
|
|
112
115
|
Status: input.status || 'Open',
|
|
@@ -122,11 +125,11 @@ export class TicketStore {
|
|
|
122
125
|
|
|
123
126
|
/** Lists existing tickets for the project, newest first. Does not create the
|
|
124
127
|
* datastore — an unconfigured project simply has no tickets yet. */
|
|
125
|
-
async listTickets(cwd: string): Promise<{ ok: boolean; tickets: Ticket[]; error?: string }> {
|
|
128
|
+
async listTickets(cwd: string): Promise<{ ok: boolean; tickets: Ticket[]; blockId?: string; pageId?: string; error?: string }> {
|
|
126
129
|
try {
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
129
|
-
const res = await this.api.queryRows(blockId, { orderBy: 'Created', order: 'desc', limit: 200 })
|
|
130
|
+
const ds = await this.ensureDatastore(cwd, false)
|
|
131
|
+
if (!ds) return { ok: true, tickets: [] }
|
|
132
|
+
const res = await this.api.queryRows(ds.blockId, { orderBy: 'Created', order: 'desc', limit: 200 })
|
|
130
133
|
const tickets: Ticket[] = (res?.rows || []).map(r => ({
|
|
131
134
|
id: r.id,
|
|
132
135
|
title: String(r.data?.Title ?? ''),
|
|
@@ -135,15 +138,15 @@ export class TicketStore {
|
|
|
135
138
|
priority: String(r.data?.Priority ?? 'Med'),
|
|
136
139
|
created: (r.data?.Created as string) ?? r.created_at ?? null,
|
|
137
140
|
}))
|
|
138
|
-
return { ok: true, tickets }
|
|
141
|
+
return { ok: true, tickets, blockId: ds.blockId, pageId: ds.pageId }
|
|
139
142
|
} catch (err: any) {
|
|
140
143
|
log(`listTickets failed for ${cwd}: ${err?.message || err}`)
|
|
141
144
|
return { ok: false, tickets: [], error: err?.message || String(err) }
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
private async ensureDatastore(cwd: string, create: boolean): Promise<string | null> {
|
|
146
|
-
const cached = this.
|
|
148
|
+
private async ensureDatastore(cwd: string, create: boolean): Promise<{ blockId: string; pageId: string } | null> {
|
|
149
|
+
const cached = this.dsCache.get(cwd)
|
|
147
150
|
if (cached) return cached
|
|
148
151
|
|
|
149
152
|
let folder: any = null
|
|
@@ -157,12 +160,14 @@ export class TicketStore {
|
|
|
157
160
|
const folderDetail = await this.api.getFolder(folder.id)
|
|
158
161
|
const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
|
|
159
162
|
if (!agentPage?.id) return null
|
|
163
|
+
const pageId: string = agentPage.id
|
|
160
164
|
|
|
161
|
-
const blockId = await this.findOrAdoptBlock(
|
|
165
|
+
const blockId = await this.findOrAdoptBlock(pageId)
|
|
162
166
|
if (blockId) {
|
|
163
167
|
await this.ensureColumns(blockId)
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
const ds = { blockId, pageId }
|
|
169
|
+
this.dsCache.set(cwd, ds)
|
|
170
|
+
return ds
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
if (!create) return null
|
|
@@ -173,15 +178,16 @@ export class TicketStore {
|
|
|
173
178
|
type: c.type,
|
|
174
179
|
...(c.options ? { options: c.options } : {}),
|
|
175
180
|
}))
|
|
176
|
-
const created = await this.api.createBlock(
|
|
181
|
+
const created = await this.api.createBlock(pageId, {
|
|
177
182
|
type: 'datastore',
|
|
178
183
|
title: DATASTORE_TITLE,
|
|
179
184
|
props: { columns, system_key: SYSTEM_KEY },
|
|
180
185
|
})
|
|
181
186
|
if (created?.id) {
|
|
182
187
|
log(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`)
|
|
183
|
-
|
|
184
|
-
|
|
188
|
+
const ds = { blockId: created.id, pageId }
|
|
189
|
+
this.dsCache.set(cwd, ds)
|
|
190
|
+
return ds
|
|
185
191
|
}
|
|
186
192
|
return null
|
|
187
193
|
}
|
package/src/renderer/App.tsx
CHANGED
|
@@ -60,7 +60,7 @@ declare global {
|
|
|
60
60
|
getTicketProject: () => Promise<{ cwd: string | null; name: string | null }>
|
|
61
61
|
addTicket: (input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
|
|
62
62
|
updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
|
|
63
|
-
listTickets: () => Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; error?: string }>
|
|
63
|
+
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 }>
|
|
64
64
|
getTracking: () => Promise<{ active: boolean }>
|
|
65
65
|
setTracking: (enabled: boolean) => Promise<{ active: boolean }>
|
|
66
66
|
getLogChat: () => Promise<{ enabled: boolean }>
|
|
@@ -269,6 +269,18 @@ export default function App() {
|
|
|
269
269
|
setTimeout(() => window.worker.writePty(activeTabId, '\r'), 80)
|
|
270
270
|
}, [activeTabId])
|
|
271
271
|
|
|
272
|
+
// Send a prepared prompt (e.g. a ticket) into the active terminal and submit
|
|
273
|
+
// it, so the coding agent can start working immediately. Same paste-then-
|
|
274
|
+
// delayed-\r dance as voice typing — a slightly longer delay since ticket
|
|
275
|
+
// prompts are multi-line and take a touch longer to drain before the Enter.
|
|
276
|
+
const handleSendToTerminal = useCallback((text: string) => {
|
|
277
|
+
const trimmed = text.trim()
|
|
278
|
+
if (!trimmed) return
|
|
279
|
+
window.worker.writePty(activeTabId, trimmed)
|
|
280
|
+
focusTerminal(activeTabId)
|
|
281
|
+
setTimeout(() => window.worker.writePty(activeTabId, '\r'), 120)
|
|
282
|
+
}, [activeTabId])
|
|
283
|
+
|
|
272
284
|
const cwdRef = useRef<string | null>(null)
|
|
273
285
|
|
|
274
286
|
const handleSpawn = useCallback(async (tabId: string, agent: AgentConfig) => {
|
|
@@ -438,6 +450,10 @@ export default function App() {
|
|
|
438
450
|
return () => window.removeEventListener('ctlsurf-open-url', onOpen)
|
|
439
451
|
}, [layout, togglePane])
|
|
440
452
|
|
|
453
|
+
// The active tab's agent — remote SSH sessions (id `remote-…`) can't have
|
|
454
|
+
// their replies spoken, so the speaker toggle is disabled for them.
|
|
455
|
+
const activeAgentIsRemote = !!tabs.find(t => t.id === activeTabId)?.agent?.id?.startsWith('remote-')
|
|
456
|
+
|
|
441
457
|
// Build pane contents (always rendered, layout controls visibility)
|
|
442
458
|
const panes: PaneContent[] = [
|
|
443
459
|
{ id: 'editor', label: 'Editor', content: <EditorPanel cwd={cwd} /> },
|
|
@@ -506,7 +522,7 @@ export default function App() {
|
|
|
506
522
|
),
|
|
507
523
|
},
|
|
508
524
|
{ id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
|
|
509
|
-
{ id: 'tickets', label: 'Tickets', content: <TicketPane /> },
|
|
525
|
+
{ id: 'tickets', label: 'Tickets', content: <TicketPane onSendToTerminal={handleSendToTerminal} /> },
|
|
510
526
|
]
|
|
511
527
|
|
|
512
528
|
return (
|
|
@@ -562,7 +578,7 @@ export default function App() {
|
|
|
562
578
|
<line x1="8" y1="23" x2="16" y2="23" />
|
|
563
579
|
</svg>
|
|
564
580
|
</button>
|
|
565
|
-
<SpeakControls />
|
|
581
|
+
<SpeakControls remote={activeAgentIsRemote} />
|
|
566
582
|
<span className="titlebar-separator" />
|
|
567
583
|
{agents.map(a => {
|
|
568
584
|
const activeTab = tabs.find(t => t.id === activeTabId)
|
|
@@ -1,65 +1,199 @@
|
|
|
1
|
-
import { useRef, useEffect, useState } from 'react'
|
|
1
|
+
import { useRef, useEffect, useState, useCallback } from 'react'
|
|
2
2
|
|
|
3
3
|
interface CtlsurfPanelProps {
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// A URL to load (e.g. from a terminal link click). `n` bumps per request so
|
|
5
|
+
// repeat navigations to the same URL still apply.
|
|
6
6
|
navigate?: { url: string; n: number } | null
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// The <webview> element exposes navigation methods at runtime that aren't in
|
|
10
|
+
// React's typings — narrow the ref to the bits we use.
|
|
11
|
+
type WebviewEl = HTMLElement & {
|
|
12
|
+
loadURL: (url: string) => void
|
|
13
|
+
goBack: () => void
|
|
14
|
+
goForward: () => void
|
|
15
|
+
reload: () => void
|
|
16
|
+
stop: () => void
|
|
17
|
+
canGoBack: () => boolean
|
|
18
|
+
canGoForward: () => boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Strip the noisy auth/embed params so the address bar shows a clean URL.
|
|
22
|
+
function displayUrl(raw: string): string {
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(raw)
|
|
25
|
+
u.searchParams.delete('_code')
|
|
26
|
+
u.searchParams.delete('embed')
|
|
27
|
+
return u.toString()
|
|
28
|
+
} catch {
|
|
29
|
+
return raw
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
9
33
|
export function CtlsurfPanel({ navigate }: CtlsurfPanelProps) {
|
|
10
|
-
const webviewRef = useRef<
|
|
11
|
-
const [
|
|
12
|
-
const [
|
|
34
|
+
const webviewRef = useRef<WebviewEl | null>(null)
|
|
35
|
+
const [src, setSrc] = useState<string | null>(null) // initial webview src
|
|
36
|
+
const [address, setAddress] = useState('') // editable address-bar text
|
|
37
|
+
const [loading, setLoading] = useState(false)
|
|
38
|
+
const [nav, setNav] = useState({ back: false, forward: false })
|
|
39
|
+
const [key, setKey] = useState(0) // remount on cwd change
|
|
40
|
+
|
|
41
|
+
const hasSrcRef = useRef(false) // has the webview been seeded?
|
|
42
|
+
const readyRef = useRef(false) // is the webview dom-ready?
|
|
43
|
+
const pendingRef = useRef<string | null>(null) // url queued before dom-ready
|
|
44
|
+
|
|
45
|
+
// Resolve a raw URL to an authenticated ctlsurf embed URL when it points at
|
|
46
|
+
// ctlsurf, else return it unchanged.
|
|
47
|
+
const resolve = useCallback(async (raw: string): Promise<string> => {
|
|
48
|
+
try {
|
|
49
|
+
return (await window.worker.getEmbedUrl(raw)) || raw
|
|
50
|
+
} catch {
|
|
51
|
+
return raw
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
// Load a URL into the webview. The first load seeds the `src` attribute (the
|
|
56
|
+
// element doesn't exist yet); later loads call loadURL so in-webview history
|
|
57
|
+
// stays intact for the back/forward buttons.
|
|
58
|
+
const load = useCallback((url: string) => {
|
|
59
|
+
if (!url) return
|
|
60
|
+
setAddress(displayUrl(url))
|
|
61
|
+
if (!hasSrcRef.current) {
|
|
62
|
+
hasSrcRef.current = true
|
|
63
|
+
setSrc(url)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const wv = webviewRef.current
|
|
67
|
+
if (wv && readyRef.current) wv.loadURL(url)
|
|
68
|
+
else pendingRef.current = url
|
|
69
|
+
}, [])
|
|
13
70
|
|
|
14
|
-
|
|
71
|
+
// Default-load the project page, preferring the authenticated page URL.
|
|
72
|
+
const loadProject = useCallback(async () => {
|
|
15
73
|
try {
|
|
16
74
|
const info = await window.worker.getWebviewInfo()
|
|
17
|
-
|
|
75
|
+
load(info.pageUrl || info.frontendUrl)
|
|
18
76
|
} catch (err) {
|
|
19
77
|
console.error('[ctlsurf] Failed to get webview info:', err)
|
|
20
|
-
|
|
78
|
+
load('https://app.ctlsurf.com')
|
|
21
79
|
}
|
|
22
|
-
}
|
|
80
|
+
}, [load])
|
|
23
81
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
useEffect(() => { if (!navigate?.url)
|
|
82
|
+
// On mount, load the project page unless a navigation is already queued (e.g.
|
|
83
|
+
// the pane was just revealed to show a clicked link).
|
|
84
|
+
useEffect(() => { if (!navigate?.url) loadProject() }, [])
|
|
27
85
|
|
|
28
|
-
// Navigate to a specific
|
|
86
|
+
// Navigate to a specific URL when requested (terminal / external link click).
|
|
29
87
|
useEffect(() => {
|
|
30
|
-
if (navigate?.url)
|
|
31
|
-
setUrl(navigate.url)
|
|
32
|
-
setKey(k => k + 1)
|
|
33
|
-
}
|
|
88
|
+
if (navigate?.url) resolve(navigate.url).then(load)
|
|
34
89
|
}, [navigate?.n])
|
|
35
90
|
|
|
36
|
-
// Reload
|
|
91
|
+
// Reload (remount) when the cwd changes — new project context.
|
|
37
92
|
useEffect(() => {
|
|
38
93
|
const unsub = window.worker.onCwdChanged(() => {
|
|
39
|
-
|
|
94
|
+
hasSrcRef.current = false
|
|
95
|
+
readyRef.current = false
|
|
96
|
+
pendingRef.current = null
|
|
97
|
+
setSrc(null)
|
|
40
98
|
setKey(k => k + 1)
|
|
41
|
-
|
|
99
|
+
loadProject()
|
|
42
100
|
})
|
|
43
101
|
return unsub
|
|
44
|
-
}, [])
|
|
102
|
+
}, [loadProject])
|
|
45
103
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
104
|
+
// Wire webview lifecycle + navigation events to the address bar.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const wv = webviewRef.current
|
|
107
|
+
if (!wv) return
|
|
108
|
+
const syncNav = () =>
|
|
109
|
+
setNav({ back: wv.canGoBack?.() ?? false, forward: wv.canGoForward?.() ?? false })
|
|
110
|
+
const onReady = () => {
|
|
111
|
+
readyRef.current = true
|
|
112
|
+
if (pendingRef.current) {
|
|
113
|
+
wv.loadURL(pendingRef.current)
|
|
114
|
+
pendingRef.current = null
|
|
115
|
+
}
|
|
116
|
+
syncNav()
|
|
117
|
+
}
|
|
118
|
+
const onNavigate = (e: Event) => {
|
|
119
|
+
const url = (e as Event & { url?: string }).url
|
|
120
|
+
if (url) setAddress(displayUrl(url))
|
|
121
|
+
syncNav()
|
|
122
|
+
}
|
|
123
|
+
const onStart = () => setLoading(true)
|
|
124
|
+
const onStop = () => { setLoading(false); syncNav() }
|
|
125
|
+
wv.addEventListener('dom-ready', onReady)
|
|
126
|
+
wv.addEventListener('did-navigate', onNavigate)
|
|
127
|
+
wv.addEventListener('did-navigate-in-page', onNavigate)
|
|
128
|
+
wv.addEventListener('did-start-loading', onStart)
|
|
129
|
+
wv.addEventListener('did-stop-loading', onStop)
|
|
130
|
+
return () => {
|
|
131
|
+
wv.removeEventListener('dom-ready', onReady)
|
|
132
|
+
wv.removeEventListener('did-navigate', onNavigate)
|
|
133
|
+
wv.removeEventListener('did-navigate-in-page', onNavigate)
|
|
134
|
+
wv.removeEventListener('did-start-loading', onStart)
|
|
135
|
+
wv.removeEventListener('did-stop-loading', onStop)
|
|
136
|
+
}
|
|
137
|
+
}, [src, key])
|
|
138
|
+
|
|
139
|
+
const onSubmit = (e: React.FormEvent) => {
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
let target = address.trim()
|
|
142
|
+
if (!target) return
|
|
143
|
+
// Bare hostnames (no scheme) default to https.
|
|
144
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) target = 'https://' + target
|
|
145
|
+
resolve(target).then(load)
|
|
52
146
|
}
|
|
53
147
|
|
|
54
148
|
return (
|
|
55
149
|
<div className="ctlsurf-panel">
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
150
|
+
<div className="ctlsurf-addressbar">
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
className="ctlsurf-nav-btn"
|
|
154
|
+
title="Back"
|
|
155
|
+
disabled={!nav.back}
|
|
156
|
+
onClick={() => webviewRef.current?.goBack?.()}
|
|
157
|
+
>‹</button>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
className="ctlsurf-nav-btn"
|
|
161
|
+
title="Forward"
|
|
162
|
+
disabled={!nav.forward}
|
|
163
|
+
onClick={() => webviewRef.current?.goForward?.()}
|
|
164
|
+
>›</button>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className="ctlsurf-nav-btn"
|
|
168
|
+
title={loading ? 'Stop' : 'Reload'}
|
|
169
|
+
onClick={() => (loading ? webviewRef.current?.stop?.() : webviewRef.current?.reload?.())}
|
|
170
|
+
>{loading ? '×' : '⟳'}</button>
|
|
171
|
+
<form className="ctlsurf-address-form" onSubmit={onSubmit}>
|
|
172
|
+
<input
|
|
173
|
+
className="ctlsurf-address-input"
|
|
174
|
+
value={address}
|
|
175
|
+
spellCheck={false}
|
|
176
|
+
placeholder="Enter a URL"
|
|
177
|
+
onChange={e => setAddress(e.target.value)}
|
|
178
|
+
onFocus={e => e.currentTarget.select()}
|
|
179
|
+
/>
|
|
180
|
+
</form>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="ctlsurf-webview-host">
|
|
183
|
+
{src ? (
|
|
184
|
+
<webview
|
|
185
|
+
key={key}
|
|
186
|
+
ref={webviewRef as any}
|
|
187
|
+
src={src}
|
|
188
|
+
style={{ width: '100%', height: '100%' }}
|
|
189
|
+
allowpopups={'true' as any}
|
|
190
|
+
/>
|
|
191
|
+
) : (
|
|
192
|
+
<div className="ctlsurf-webview-loading">
|
|
193
|
+
<span style={{ color: '#565f89', fontSize: '13px' }}>Loading ctlsurf...</span>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
63
197
|
</div>
|
|
64
198
|
)
|
|
65
199
|
}
|
|
@@ -18,7 +18,10 @@ import {
|
|
|
18
18
|
|
|
19
19
|
const SAMPLE = 'This is how spoken agent replies will sound.'
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// `remote` is true while the active tab is a remote SSH session, where spoken
|
|
22
|
+
// replies can't work: the agent's transcript is written on the remote machine,
|
|
23
|
+
// so there's nothing local to read aloud. The toggle is disabled in that case.
|
|
24
|
+
export function SpeakControls({ remote = false }: { remote?: boolean }) {
|
|
22
25
|
const [enabled, setEnabled] = useState(false)
|
|
23
26
|
const [engine, setEngineState] = useState<TtsEngineId>(getEngine)
|
|
24
27
|
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
|
|
@@ -124,7 +127,9 @@ export function SpeakControls() {
|
|
|
124
127
|
speech.enqueue(SAMPLE)
|
|
125
128
|
}, [])
|
|
126
129
|
|
|
127
|
-
const title =
|
|
130
|
+
const title = remote
|
|
131
|
+
? "Spoken replies aren't available for remote SSH sessions — the agent's transcript lives on the remote machine"
|
|
132
|
+
: enabled ? 'Spoken replies on — click to mute' : 'Speak agent replies (off)'
|
|
128
133
|
|
|
129
134
|
return (
|
|
130
135
|
<div className="speak-controls" ref={wrapRef}>
|
|
@@ -133,6 +138,7 @@ export function SpeakControls() {
|
|
|
133
138
|
className={`titlebar-btn titlebar-icon-btn speak-btn ${enabled ? 'active' : ''}`}
|
|
134
139
|
onClick={toggle}
|
|
135
140
|
title={title}
|
|
141
|
+
disabled={remote}
|
|
136
142
|
aria-label="Toggle spoken agent replies"
|
|
137
143
|
aria-pressed={enabled}
|
|
138
144
|
>
|