@aryacode/dsh-aryacode-polish 0.6.2 → 0.8.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.
@@ -30,6 +30,12 @@
30
30
  "label": "Add a workspace from this PC or GitHub",
31
31
  "description": "Put an On this PC / GitHub repository source menu behind the Workspaces + button.",
32
32
  "defaultOn": true
33
+ },
34
+ {
35
+ "key": "panelScreenshot",
36
+ "label": "Right-panel screenshot",
37
+ "description": "Copy the open right panel into the chat as an image, and onto the clipboard.",
38
+ "defaultOn": true
33
39
  }
34
40
  ]
35
41
  }
@@ -31,6 +31,30 @@ server rejects a bearer token for clone, which is why the header carries
31
31
  `x-access-token` basic auth. Every line of git output is scrubbed of the token
32
32
  before it can reach the browser, and the route answers `cache-control: no-store`.
33
33
 
34
+ ## Signing in and out
35
+
36
+ The session belongs to the GitHub CLI, and both directions stay inside the app:
37
+
38
+ - **Sign in** runs `gh auth login --hostname github.com --git-protocol https
39
+ --web --clipboard --skip-ssh-key` on the Host. With piped stdio that command
40
+ needs no terminal: it prints `! One-time code (XXXX-XXXX) copied to clipboard`
41
+ and `https://github.com/login/device`, then polls GitHub until the code is
42
+ approved. The dialog shows exactly that code with a copy button and the device
43
+ page, polls `loginStatus` every two seconds, and refreshes the repository list
44
+ the moment the CLI reports success. A flow that is never approved is killed
45
+ after five minutes, and cancelling it kills the child immediately; the child
46
+ also dies with the feature, because a pending sign-in is a live process.
47
+ - **Sign out** runs `gh auth logout --hostname github.com --user <login>` after a
48
+ second confirming click, then re-reads the session. "not logged in" counts as
49
+ success, because that is the state the click asked for.
50
+ - **Where you see it** is the sidebar foot: `GitHub · <login>` or
51
+ `GitHub · signed out`, on the same line as the balance. The dialog repeats it,
52
+ and the footer row re-reads on window focus so a `gh auth login` run in a
53
+ terminal shows up without a restart.
54
+
55
+ Nothing about this stores a credential of its own: the code is a one-time device
56
+ code, and the token never leaves the CLI's own credential store.
57
+
34
58
  ## How the entry point works
35
59
 
36
60
  The "+" menu's **Add workspace…** entry only appears when something occupies
@@ -0,0 +1,98 @@
1
+ # Right-panel screenshot
2
+
3
+ The right panel grows a camera button in its own top-right corner, beside the
4
+ Shell's split, expand and close controls. One click photographs the right
5
+ panel's **open tab**, puts the picture on the clipboard, and attaches it to the
6
+ message being written — the same place a pasted screenshot lands, so it can be
7
+ sent with the next prompt without touching another tool.
8
+
9
+ This page records how that works, and — more importantly — what it cannot do,
10
+ because the honest limits are the part that decides whether it replaces a snip
11
+ tool for a given job.
12
+
13
+ ## What it captures
14
+
15
+ The right panel is ordinary DOM in the same document as the chat, so the target
16
+ is found with an attribute selector rather than a private class name:
17
+
18
+ | target | selector |
19
+ |---|---|
20
+ | the open tab's body | `[data-sidebar-right-session][data-sidebar-right-open] [data-dockkit-pane-active]` |
21
+ | the panel itself (fallback) | `[data-sidebar-right-session][data-sidebar-right-open]` |
22
+ | the bottom panel (fallback) | `[data-dsh-panel][data-dsh-bottom-panel]` |
23
+
24
+ The first selector is what makes this work for both right sides: the Shell's own
25
+ sidebar and `dsh-better-sidebar` both render their tabs through the same dock
26
+ container, so the *active pane* is the current tab in either one. Targets are
27
+ skipped when they are smaller than 48 px in either direction, which keeps a
28
+ collapsed panel from producing a sliver.
29
+
30
+ ## How the picture is made
31
+
32
+ `html2canvas` (vendored at `vendor/html2canvas.min.js`, inlined into
33
+ `lib/client.js` by `scripts/build-client.mjs`) rasterises the element in the page
34
+ at `min(2, max(1.5, devicePixelRatio))`. The background is the first opaque
35
+ ancestor colour, so a transparent panel still reads as a surface.
36
+
37
+ This is the only route a plugin has. The Shell exposes:
38
+
39
+ - **no capture bridge** — the desktop preload exposes `__DSH_DIRECTORY_PICKER__`,
40
+ `__DSH_HOST_PATHS__`, `dshDesktopBoot`, `dshDesktop`, `dshPlatform`, and no
41
+ screenshot channel;
42
+ - **no working `getDisplayMedia`** — the API exists, but nothing installs a
43
+ display-media request handler on the app's session, so the call is refused
44
+ outright rather than offering a source;
45
+ - **no Host route to pixels** — the Host runs in a separate process and has no
46
+ access to the renderer.
47
+
48
+ Capture therefore happens in the renderer, from the DOM, which is also why it
49
+ works identically in the web build and in the app.
50
+
51
+ ## What it cannot capture
52
+
53
+ 1. **The browser tab and the HTML preview.** Both render inside an
54
+ `<iframe>`/`<webview>` whose document is not readable from the page, so a
55
+ rasteriser sees an empty rectangle there. Everything else — files, documents,
56
+ images, diffs, plans, subagent chats, plugin tabs — is plain DOM and captures
57
+ normally.
58
+ 2. **Anything outside the app window.** The button photographs a panel, not the
59
+ desktop, so a snip tool still wins for a region spanning two applications or a
60
+ native dialog.
61
+ 3. **Live pixels that refuse to be copied.** A cross-origin image without CORS
62
+ headers taints the canvas and would throw; the capture fails with a notice
63
+ instead of producing a blank picture. The Shell's own panels are same-origin,
64
+ so this only shows up with third-party content.
65
+
66
+ ## How the image reaches the message
67
+
68
+ Two calls, in this order, mirroring the composer's own intake:
69
+
70
+ ```js
71
+ // mints the draft attachment and starts its upload
72
+ const drafts = ctx.get('conversation').createDrafts(sessionId, [file], true)
73
+ // puts the minted ids in the composer, where the user can see and remove them
74
+ props.inputActions.addAttachments(drafts.map((draft) => draft.id))
75
+ ```
76
+
77
+ `createDrafts` is not part of the public `IConversation` interface, but it is the
78
+ only `File` → id entry point in the Shell, and the product itself reaches it the
79
+ same way. `addAttachments` is the public half: it is a standard prop on
80
+ session-scoped slots, which the composer row already is. The button itself is
81
+ root-level (it sits over the panel, not inside its DOM), so the composer dock
82
+ keeps an invisible entry that publishes the session context into a store; the
83
+ button reads that store and disappears when no Session is open. The bytes travel over
84
+ the Shell's own upload route, so a screenshot behaves exactly like a pasted
85
+ image, including for the model that receives it.
86
+
87
+ ## When it refuses
88
+
89
+ The button reports its own state instead of failing silently:
90
+
91
+ - no panel open → *open a panel on the right first*
92
+ - clipboard blocked → the image is still attached, with *right panel attached,
93
+ clipboard refused*
94
+ - anything else → the reason in the row, plus
95
+ `[aryacode-polish] panel screenshot failed:` in the console
96
+
97
+ The switch is in the row's Configure page like every other surface, and turning
98
+ it off removes the button without unloading anything else.
package/entries/github.js CHANGED
@@ -114,6 +114,189 @@ export async function ghToken(run = runCommand) {
114
114
  return token === '' ? undefined : token
115
115
  }
116
116
 
117
+ /**
118
+ * Pull the one-time code out of what `gh auth login --web` prints.
119
+ *
120
+ * The CLI writes `! One-time code (AE22-76C7) copied to clipboard` to stderr —
121
+ * the dash is the one the browser form accepts, so it is kept verbatim.
122
+ * @param text - captured output.
123
+ * @returns the code, or undefined when the line has not arrived yet.
124
+ */
125
+ export function parseDeviceCode(text) {
126
+ const match = /one-time code \(([A-Z0-9]{4}-[A-Z0-9]{4})\)/i.exec(String(text))
127
+ return match === null ? undefined : match[1].toUpperCase()
128
+ }
129
+
130
+ /**
131
+ * Pull the verification URL out of what `gh auth login --web` prints.
132
+ * @param text - captured output.
133
+ * @returns the URL, defaulting to GitHub's device page.
134
+ */
135
+ export function parseDeviceUrl(text) {
136
+ const match = /(https:\/\/github\.com\/login\/device)/.exec(String(text))
137
+ return match === null ? 'https://github.com/login/device' : match[1]
138
+ }
139
+
140
+ /** The single sign-in this process is running, if any. */
141
+ let loginSession
142
+
143
+ /** Default ceiling on a pending sign-in: the CLI polls until the code is used. */
144
+ export const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
145
+
146
+ /**
147
+ * Start the GitHub CLI's device flow for this machine.
148
+ *
149
+ * `gh auth login --web` needs no terminal: with piped stdio it prints the
150
+ * one-time code and then polls GitHub until the code is approved, which is
151
+ * exactly the shape a dialog can render. The child is kept alive here so the
152
+ * browser can poll for the outcome, and killed when the flow is cancelled,
153
+ * times out, or the feature is disposed.
154
+ * @param options - spawn override plus the timeout.
155
+ * @returns the code and URL to show, or the reason it could not start.
156
+ */
157
+ export function startLogin(options = {}) {
158
+ if (loginSession !== undefined && loginSession.state === 'pending') {
159
+ return { ok: true, value: { state: 'pending', code: loginSession.code, url: loginSession.url } }
160
+ }
161
+ const spawnImpl = options.spawn ?? spawn
162
+ const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS
163
+ const session = { state: 'starting', code: undefined, url: 'https://github.com/login/device', reason: '', child: undefined, timer: undefined }
164
+ let text = ''
165
+ const read = chunk => {
166
+ text += chunk.toString()
167
+ const code = parseDeviceCode(text)
168
+ if (code !== undefined && session.code === undefined) {
169
+ session.code = code
170
+ session.url = parseDeviceUrl(text)
171
+ session.state = 'pending'
172
+ }
173
+ }
174
+ try {
175
+ const child = spawnImpl('gh', ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web', '--clipboard', '--skip-ssh-key'], {
176
+ stdio: ['pipe', 'pipe', 'pipe'],
177
+ windowsHide: true,
178
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '' },
179
+ })
180
+ session.child = child
181
+ child.stdout?.setEncoding?.('utf8')
182
+ child.stderr?.setEncoding?.('utf8')
183
+ child.stdout?.on?.('data', read)
184
+ child.stderr?.on?.('data', read)
185
+ child.on?.('error', error => {
186
+ session.state = 'failed'
187
+ session.reason = error instanceof Error ? error.message : 'the GitHub CLI could not be started'
188
+ })
189
+ child.on?.('close', code => {
190
+ if (session.state === 'cancelled') return
191
+ if (code === 0) {
192
+ session.state = 'succeeded'
193
+ session.reason = ''
194
+ return
195
+ }
196
+ session.state = 'failed'
197
+ const parsed = parseDeviceCode(text)
198
+ session.reason = parsed === undefined
199
+ ? 'the GitHub CLI stopped before showing a one-time code'
200
+ : 'the one-time code was not approved'
201
+ })
202
+ session.timer = setTimeout(() => {
203
+ if (session.state !== 'pending' && session.state !== 'starting') return
204
+ session.state = 'failed'
205
+ session.reason = 'the one-time code expired before it was approved'
206
+ session.child?.kill?.()
207
+ }, timeoutMs)
208
+ session.timer?.unref?.()
209
+ loginSession = session
210
+ return { ok: true, value: { state: session.state, code: session.code, url: session.url } }
211
+ } catch (error) {
212
+ session.state = 'failed'
213
+ session.reason = error instanceof Error ? error.message : 'the GitHub CLI could not be started'
214
+ loginSession = session
215
+ return { ok: false, error: session.reason }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Report the sign-in this process is running.
221
+ * @returns the state, plus the code and URL while it is pending.
222
+ */
223
+ export function loginStatus() {
224
+ if (loginSession === undefined) return { state: 'idle', code: undefined, url: 'https://github.com/login/device', reason: '' }
225
+ return { state: loginSession.state, code: loginSession.code, url: loginSession.url, reason: loginSession.reason }
226
+ }
227
+
228
+ /**
229
+ * Stop a pending sign-in and forget it.
230
+ * @returns the state after cancelling.
231
+ */
232
+ export function cancelLogin() {
233
+ if (loginSession === undefined) return { state: 'idle', code: undefined, url: 'https://github.com/login/device', reason: '' }
234
+ if (loginSession.timer !== undefined) clearTimeout(loginSession.timer)
235
+ try {
236
+ loginSession.child?.kill?.()
237
+ } catch {
238
+ // A child that already exited is the normal case here.
239
+ }
240
+ loginSession.state = 'cancelled'
241
+ loginSession.reason = 'sign-in was cancelled'
242
+ return { state: loginSession.state, code: loginSession.code, url: loginSession.url, reason: loginSession.reason }
243
+ }
244
+
245
+ /**
246
+ * Sign the GitHub CLI out, which is what this feature's sign-in state reads.
247
+ * @param login - the account to remove; the CLI prompts without it.
248
+ * @param run - command runner, injected by tests.
249
+ * @returns ok when the account is gone (including when it was never there).
250
+ */
251
+ export async function signOut(login, run = runCommand) {
252
+ const args = ['auth', 'logout', '--hostname', 'github.com']
253
+ if (typeof login === 'string' && login !== '') args.push('--user', login)
254
+ const answer = await run('gh', args)
255
+ if (answer.code === 0) return { ok: true }
256
+ const text = `${answer.stdout}${answer.stderr}`.trim()
257
+ if (/not logged in/i.test(text)) return { ok: true }
258
+ return { ok: false, error: text === '' ? 'the GitHub CLI could not sign out' : text }
259
+ }
260
+
261
+ /**
262
+ * Read every account the GitHub CLI holds for github.com.
263
+ *
264
+ * A machine can carry more than one account in the CLI keyring, and `gh auth
265
+ * logout --user` only removes the one it names — the CLI then makes another one
266
+ * active, which is why a sign-out can appear to do nothing.
267
+ *
268
+ * @param run - command runner, injected by tests.
269
+ * @returns the accounts in the CLI's own order, the active one first when known.
270
+ */
271
+ export async function listAccounts(run = runCommand) {
272
+ const answer = await run('gh', ['auth', 'status'])
273
+ const text = `${answer.stdout}${answer.stderr}`
274
+ const logins = []
275
+ const pattern = /Logged in to github\.com account ([^\s]+)/g
276
+ let match = pattern.exec(text)
277
+ while (match !== null) {
278
+ if (logins.indexOf(match[1]) === -1) logins.push(match[1])
279
+ match = pattern.exec(text)
280
+ }
281
+ return logins
282
+ }
283
+
284
+ /**
285
+ * Sign every account out of the GitHub CLI.
286
+ * @param logins - accounts to remove.
287
+ * @param run - command runner, injected by tests.
288
+ * @returns ok when no account is left, else the first failure.
289
+ */
290
+ export async function signOutAll(logins, run = runCommand) {
291
+ const removed = []
292
+ for (const login of logins) {
293
+ const answer = await signOut(login, run)
294
+ if (answer.ok !== true) return { ok: false, error: answer.error, removed }
295
+ removed.push(login)
296
+ }
297
+ return { ok: true, removed }
298
+ }
299
+
117
300
  /**
118
301
  * Read the login that owns a token.
119
302
  * @param token - GitHub access token.
@@ -257,19 +440,49 @@ export async function cloneRepo(owner, name, token, options = {}) {
257
440
  */
258
441
  export async function handleRequest(body, deps = {}) {
259
442
  const action = body?.action
443
+ // Sign-in is reachable while signed out: it is how a machine gets there.
444
+ if (action === 'login') return (deps.startLogin ?? startLogin)()
445
+ if (action === 'loginStatus') return { ok: true, value: (deps.loginStatus ?? loginStatus)() }
446
+ if (action === 'loginCancel') return { ok: true, value: (deps.cancelLogin ?? cancelLogin)() }
447
+ // Signing everything out is reachable while signed in, so it belongs above the
448
+ // token gate together with the sign-in verbs.
449
+ if (action === 'logoutAll') {
450
+ const accounts = await (deps.accounts ?? listAccounts)()
451
+ if (accounts.length === 0) return { ok: true, value: { logins: [] } }
452
+ const answer = await (deps.signOutAll ?? signOutAll)(accounts)
453
+ if (answer.ok !== true) return { ok: false, error: answer.error }
454
+ return { ok: true, value: { logins: answer.removed } }
455
+ }
260
456
  const token = await (deps.token ?? ghToken)()
261
457
  if (action === 'status') {
458
+ const accounts = await (deps.accounts ?? listAccounts)().catch(() => [])
262
459
  if (token === undefined) {
263
460
  return {
264
461
  ok: true,
265
- value: { signedIn: false, login: '', root: cloneRoot(deps.home), reason: 'The GitHub CLI is missing or not signed in: run `gh auth login` in a terminal.' },
462
+ value: {
463
+ signedIn: false,
464
+ login: '',
465
+ accounts: [],
466
+ root: cloneRoot(deps.home),
467
+ reason: 'The GitHub CLI is not signed in on this machine. Sign in from this dialog, or run `gh auth login` in a terminal.',
468
+ },
266
469
  }
267
470
  }
268
471
  const login = await (deps.login ?? fetchLogin)(token)
269
- return { ok: true, value: { signedIn: true, login, root: cloneRoot(deps.home), reason: '' } }
472
+ return { ok: true, value: { signedIn: true, login, accounts, root: cloneRoot(deps.home), reason: '' } }
270
473
  }
271
474
  if (token === undefined) {
272
- return { ok: false, error: 'Sign in with the GitHub CLI first: run `gh auth login` in a terminal, then try again.' }
475
+ return { ok: false, error: 'Sign in to GitHub first: use Sign in here, or run `gh auth login` in a terminal.' }
476
+ }
477
+ if (action === 'logout') {
478
+ const login = await (deps.login ?? fetchLogin)(token)
479
+ const answer = await (deps.signOut ?? signOut)(login)
480
+ if (answer.ok !== true) return { ok: false, error: answer.error }
481
+ // The CLI may fall back to another account in its keyring; report which one,
482
+ // so the dialog can say what is signed in now instead of going silent.
483
+ const after = await (deps.token ?? ghToken)().catch(() => undefined)
484
+ const remaining = after === undefined ? '' : await (deps.login ?? fetchLogin)(after).catch(() => '')
485
+ return { ok: true, value: { login, remaining } }
273
486
  }
274
487
  if (action === 'repos') {
275
488
  const repos = await (deps.repos ?? fetchRepos)(token)
@@ -307,6 +520,8 @@ export const githubFeature = {
307
520
  name: 'polish-github',
308
521
  inject: ['connection'],
309
522
  apply(ctx, config = {}) {
523
+ // A pending device flow holds a child process: it must die with the feature.
524
+ ctx.effect(() => () => { cancelLogin() })
310
525
  ctx.effect(() => ctx.connection.fetch.register({
311
526
  path: GITHUB_ROUTE,
312
527
  // Without a method list the platform would only route GET, which is why the
@@ -68,6 +68,12 @@ export const features = [
68
68
  defaultOn: true,
69
69
  mount: mountFolders,
70
70
  },
71
+ {
72
+ id: 'panelScreenshot',
73
+ label: 'Right-panel screenshot',
74
+ description: 'Copy the open right panel into the chat as an image, and onto the clipboard.',
75
+ defaultOn: true,
76
+ },
71
77
  ]
72
78
 
73
79
  /**