@shardflux/desktop-linux-arm64 0.0.0-stage → 0.1.0-next.5

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.
@@ -0,0 +1,93 @@
1
+ Copyright 2024 The Funnel Project Authors (https://github.com/Dicotype/Funnel)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://openfontlicense.org
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,5 @@
1
+ The mark, onboarding images and fonts are the same approved assets as the macOS app.
2
+ Publisher marks are unchanged Claude and OpenAI assets. Font licenses accompany the fonts.
3
+ `shardflux-icon.png` and `shardflux-tray.png` are rasterizations of the adjacent approved
4
+ SVGs (librsvg `rsvg-convert -w 128 -h 128` and `-w 32 -h 32` respectively) for native
5
+ notification and StatusNotifierItem hosts that do not load SVG images.
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" fill="#edebe7"><rect x="0" y="0" width="32" height="32" rx="7" fill="#111113"/><g transform="rotate(18 16 16)"><polygon points="16,1 11.13,12.4 20.87,12.4 16,1"/><polygon points="10.71,13.4 9,17.4 9.77,18.9 22.23,18.9 23,17.4 21.29,13.4"/><polygon points="10.29,19.9 12.86,24.9 19.14,24.9 21.71,19.9" opacity="0.72"/><polygon points="13.38,25.9 16,31 16,31 18.63,25.9" opacity="0.45"/></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" fill="currentColor"><g transform="rotate(18 16 16)"><polygon points="16,1 11.13,12.4 20.87,12.4 16,1"/><polygon points="10.71,13.4 9,17.4 9.77,18.9 22.23,18.9 23,17.4 21.29,13.4"/><polygon points="10.29,19.9 12.86,24.9 19.14,24.9 21.71,19.9" opacity="0.72"/><polygon points="13.38,25.9 16,31 16,31 18.63,25.9" opacity="0.45"/></g></svg>
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" fill="#edebe7"><g transform="rotate(18 16 16)"><polygon points="16,1 11.13,12.4 20.87,12.4 16,1"/><polygon points="10.71,13.4 9,17.4 9.77,18.9 22.23,18.9 23,17.4 21.29,13.4"/><polygon points="10.29,19.9 12.86,24.9 19.14,24.9 21.71,19.9" opacity="0.72"/><polygon points="13.38,25.9 16,31 16,31 18.63,25.9" opacity="0.45"/></g></svg>
@@ -0,0 +1,129 @@
1
+ // Port of MenuBarCore/Onboarding.swift. These functions have no UI or platform dependencies.
2
+ export const steps = ['Sign in', 'Agents', 'Try it once', 'Your own project'];
3
+ export const samplePrompt = 'Start Daylight and test it in the browser, then tell me what works.';
4
+ export function progress(api) { return {version:4, api_url:api, step:0, dismissed:false, finished:false, apps:[], demo_copied:false, demo_finished:false, demo_changed:false, feed:[], completed:[]}; }
5
+ export function readProgress(value, api) {
6
+ if (value?.version !== 4 || value.api_url !== api) return progress(api);
7
+ return {...progress(api), ...value, apps:(value.apps ?? []).filter(a=>['claude','codex'].includes(a)),feed:(value.feed ?? []).filter(e=>e&&typeof e==='object'&&typeof e.text==='string')};
8
+ }
9
+ export const accountConnected = inspection => inspection?.connections?.some(c=>c.id==='account' && c.state==='verified') ?? false;
10
+ export const anyAgentConnected = inspection => accountConnected(inspection) && inspection.connections.some(c=>['claude','codex'].includes(c.id) && c.state==='verified');
11
+ export function canContinue(p, inspection) { return [accountConnected(inspection), anyAgentConnected(inspection), p.demo_finished, !!p.own_key][p.step]; }
12
+ export function demoPhase(p, launcher) { return p.demo_key ? p.demo_finished ? 'result' : 'watch' : !p.demo_copied ? 'copy' : launcher ? 'start' : 'launcher'; }
13
+ export function recordStart(p, key, path, app) {
14
+ const same = a=>a?.replace(/\/+$/, '') === path.replace(/\/+$/, '');
15
+ if (p.step===2 && !p.demo_key) {
16
+ if (!same(p.sample_path)) return 'otherProject';
17
+ Object.assign(p,{demo_key:key,demo_app:app,demo_finished:false,shot:null,demo_changed:false,feed:[],feed_baseline:undefined,demo_workspace:null});return 'demo';
18
+ }
19
+ if (p.step===3 && !p.own_key && !same(p.sample_path)) {Object.assign(p,{own_key:key,own_path:path,own_app:app});p.completed=[...new Set([...p.completed,3])];return 'own';}
20
+ return 'ignored';
21
+ }
22
+ export function cardState(c) {
23
+ if(!c) return {kind:'checking',fact:'Checking…'};
24
+ if(c.state==='verified')return {kind:'connected',fact:c.fact};
25
+ if(c.state==='pending')return {kind:'connecting',fact:c.fact};
26
+ if(c.state==='unknown'||c.action?.retry)return {kind:'retry',fact:c.fact,button:'Retry'};
27
+ if(c.state==='info')return {kind:'info',fact:c.fact};
28
+ const title=c.action?.title;
29
+ return {kind:'action',fact:c.fact,button:['Sign in to Claude','Connect Codex','Sign in to Codex'].includes(title)||!title?'Connect':title};
30
+ }
31
+ export function remainingConnecting(connecting, inspection, connected) {
32
+ return connecting.filter(app=>{const c=inspection.connections.find(c=>c.id===app);return app==='t3'||!c||!(c.state==='verified'||c.action?.retry||(connected&&app==='codex'&&c.state!=='pending'));});
33
+ }
34
+ export class WorkWatch {worked=false;observe(state){if(state==='working')this.worked=true;}finished(state,message){return state!=='working'&&(this.worked||!!message);} }
35
+ export class DemoTurn {
36
+ conversation=null;row=null;conversationWatch=new WorkWatch();rowWatch=new WorkWatch();
37
+ observeConversation(c){this.conversation=c;this.conversationWatch.observe(c.state);}
38
+ observeRow(r){this.row=r;this.rowWatch.observe(r.state);}
39
+ get finished(){if(this.conversation)return ['needs_you','unresolved','done','archived'].includes(this.conversation.state)&&this.conversationWatch.finished('waiting',this.conversation.summary);return this.row?this.rowWatch.finished(this.row.state,this.row.last_message):false;}
40
+ }
41
+ export const commands = {
42
+ inspect:(connect=false,retry=false)=>['setup','--inspect','--json','--apps','claude,codex',...(connect?['--connect']:[]),...(retry?['--retry-codex']:[])],
43
+ browser:name=>['auth','login','--browser','--accept-terms','--client-name',name,'--json'],
44
+ signIn:['signup','--accept-terms','--no-setup','--json'],
45
+ mfa:code=>{const v=code.trim(),digits=v.replace(/ /g,'');return ['auth','mfa',...(/^[0-9]{6}$/.test(digits)?['--code',digits]:['--recovery-code',v]),'--json'];},
46
+ setup:['setup','--json','--no-codex'],sample:['setup','--prepare-sample','--json'],
47
+ activity:key=>['setup','--inspect','--activity','--workspace',key,'--json'],
48
+ stream:key=>['workspaces','computer','stream',key,'--ttl','1h','--json'],
49
+ screenshot:(key,out)=>['workspaces','computer','screenshot',key,'--out',out,'--json'],
50
+ create:({app,path,promptFile,computer,on,model,effort,t3,images=[]})=>['agents','new','--app',app,'--path',path,...(t3?['--in','t3']:[]),...(on?['--on',on]:[]),...(promptFile?['--prompt-file',promptFile]:[]),...(computer?['--computer']:[]),...images.flatMap(path=>['--image',path]),...(model?['--model',model]:[]),...(effort?['--effort',effort]:[]),'--json'],
51
+ open:(key,target='auto')=>['agents','open',key,'--in',target,'--json']
52
+ };
53
+ export function reference(doc,joined=false) {
54
+ if(joined||doc.conversation?.route==='codex-cloud'){if(/^conv_[a-z0-9]{26}$/.test(doc.conversation?.id))return doc.conversation.id;}
55
+ if(doc.workspace?.workspace_key)return doc.workspace.workspace_key;
56
+ throw new Error('shard did not return a conversation destination. Update Shardflux and try Open again.');
57
+ }
58
+ export function browserEvent(line) {
59
+ try {const e=JSON.parse(line);if(e.event==='open' && e.user_code)return {...e,verification_uri_complete:safeHTTPS(e.verification_uri_complete)};if(['signed_in','error'].includes(e.event))return e;}catch{}return null;
60
+ }
61
+ export function safeHTTPS(text) {try{const u=new URL(text);return u.protocol==='https:'&&!u.username&&!u.password?u.href:null;}catch{return null;} }
62
+ export function streamURL(doc){const url=safeHTTPS(doc.url);if(doc.interactive!==false||!url)throw new Error('The live view didn’t open. Try again.');return url;}
63
+ export function fold(text){return text.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase();}
64
+ export function matches(text,query){text=fold(text);let i=0;for(const c of fold(query).replace(/\s/g,'')){i=text.indexOf(c,i);if(i<0)return false;i++;}return true;}
65
+ export function validChoice(choice,catalog) {const model=catalog?.models?.some(m=>m.id===choice.model)?choice.model:null;const efforts=catalog?.models?.find(m=>m.id===model)?.efforts??catalog?.efforts??[];return {model,effort:efforts.some(e=>(e.id??e)===choice.effort)?choice.effort:null};}
66
+ export const escape = value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
67
+ export function composePrompt(task,branch='',link='',files=[]){
68
+ if(!task.trim())throw new Error('Describe what the agent should do.');
69
+ const text=[task,...(branch?[`Branch context: ${branch}`]:[]),...(link?[`Link context: ${link}`]:[]),...files.map(f=>`File context: ${f.path.split('/').at(-1)}\n${f.text}`)].join('\n\n');
70
+ if(new TextEncoder().encode(text).length>262144||/[\0\x1b]/.test(text))throw new Error('Keep the task and context under 256 KiB, without terminal control characters.');
71
+ return text;
72
+ }
73
+ export function modelEfforts(catalog,model){const chosen=catalog?.models?.find(m=>m.id===(model||catalog.default?.model));return (catalog?.efforts||[]).filter(e=>!chosen?.efforts||chosen.efforts.includes(e.id));}
74
+ // MenuBarCore/DesktopStream.swift ActivityFeed: what the agent did in its own computer, as a person would say it.
75
+ export const listNames=items=>items.length<2?(items[0]??''):`${items.slice(0,-1).join(', ')} and ${items.at(-1)}`;
76
+ const blankPages=new Set(['Untitled','New Tab','about:blank']);
77
+ export function pageTitle(window){for(const suffix of [' - Google Chrome for Testing',' - Google Chrome',' - Chromium',' — Mozilla Firefox',' - Mozilla Firefox'])if(window.endsWith(suffix))return window.slice(0,-suffix.length);return window;}
78
+ export function observeActivity(feed,activity){
79
+ const events=feed.events??=[],same=(a,b)=>a.kind===b.kind&&a.text===b.text&&a.detail===b.detail,add=e=>{if(!events.some(x=>same(x,e)))events.push(e);};
80
+ for(const command of activity.commands||[])if(command.startsWith('sudo ')&&command.includes(' install')){const words=command.split(' '),packages=words.slice(words.indexOf('install')+1).filter(w=>w&&!w.startsWith('-')&&!w.includes('='));if(!packages.length)continue;const names=listNames(packages);if(!events.some(e=>e.kind==='install'&&e.detail===names))add({kind:'install',text:`Installing ${names} with sudo`,detail:names});}
81
+ for(const install of activity.installs||[]){const names=listNames(install.packages||[]),done={kind:'install',text:`Installed ${names}${install.sudo?' with sudo':''}`,detail:names},i=events.findIndex(e=>e.kind==='install'&&e.detail===names);if(i>=0)events[i]=done;else add(done);}
82
+ for(const command of (activity.commands||[]).slice(0,1))if(command.includes(' ')&&!command.startsWith('apt')&&!command.startsWith('sudo ')&&!command.startsWith('dpkg'))add({kind:'command',text:`Started ${command}`,detail:command});
83
+ for(const port of activity.ports||[])add({kind:'port',text:`Opened port ${port}`,detail:String(port)});
84
+ const page=pageTitle(activity.browser?.title||'');
85
+ if(page&&!blankPages.has(page)&&events.findLast(e=>e.kind==='browser')?.detail!==page){const [app,view='']=page.split(' · '),browsing=events.filter(e=>e.kind==='browser');
86
+ if(!browsing.length)add({kind:'browser',text:`Opened ${app} in its browser`,detail:page});
87
+ else if(browsing[0].detail===page||!view)events.push({kind:'browser',text:`Went back to ${app}’s first screen`,detail:page});
88
+ else events.push({kind:'browser',text:`Clicked ${view}`,detail:page});}
89
+ if(feed.baseline===undefined)feed.baseline=activity.changes?.commits??0;
90
+ if((activity.changes?.files??0)>0||(activity.changes?.commits??0)>feed.baseline)feed.changed=true;
91
+ return feed;
92
+ }
93
+ export function recap(feed){
94
+ const events=feed.events||[],lines=[],installs=events.filter(e=>e.kind==='install'&&e.text.startsWith('Installed'));
95
+ if(installs.length)lines.push(installs.map(e=>e.text).join('; '));
96
+ const command=events.find(e=>e.kind==='command'&&!e.detail.startsWith('sudo '))?.detail,port=events.find(e=>e.kind==='port')?.detail;
97
+ if(command&&port)lines.push(`Started ${command} on :${port}`);else if(command)lines.push(`Started ${command}`);else if(port)lines.push(`Opened port ${port}`);
98
+ const pages=new Set(events.filter(e=>e.kind==='browser').map(e=>e.detail)).size;
99
+ if(pages)lines.push(pages===1?'Tested it in its browser':`Tested ${pages} screens in its browser`);
100
+ return lines;
101
+ }
102
+ // OpenTarget.place, as Linux opens conversations: its terminal, its browser (claude.ai) or T3 Code.
103
+ export function openPlace(targets,app){const target=targets?.open_in?.[app]||(app==='codex'?'terminal':'terminal');return {terminal:'your terminal',web:'your browser',t3:'T3 Code'}[target]||'your terminal';}
104
+ // A GTK accelerator ("<Alt><Shift>space") as the app shows it ("Alt+Shift+Space").
105
+ export function shortcutLabel(accelerator='<Alt><Shift>space'){const mods=[...accelerator.matchAll(/<(\w+)>/g)].map(m=>({Control:'Ctrl',Primary:'Ctrl',Alt:'Alt',Shift:'Shift',Super:'Super'}[m[1]]||m[1]));const key=accelerator.replace(/<\w+>/g,'');return [...mods,key.length===1?key.toUpperCase():key[0].toUpperCase()+key.slice(1)].join('+');}
106
+ // MenuBarCore/AgentTargets.swift T3Failure: a refused T3 Code hand-off is one line and the one action that helps. When
107
+ // the thread already started (details.t3_thread_id), that action opens it: starting again would make a second one.
108
+ export function t3Failure(detail){
109
+ if(typeof detail?.code!=='string'||!detail.code.startsWith('t3_')||!detail.message)return null;
110
+ const d=detail.details||{},text=k=>typeof d[k]==='string'&&d[k]?d[k]:null,conversation=text('conversation_id');
111
+ const fix=typeof detail.fix==='string'?detail.fix:detail.fix?.message??detail.fix?.title??null;
112
+ return {code:detail.code,message:detail.message,fix:fix||null,threadID:text('t3_thread_id'),conversation:/^conv_[a-z0-9]{26}$/.test(conversation||'')?conversation:null,workspaceKey:text('workspace_key')};
113
+ }
114
+ export const failureReference=f=>f?.conversation??f?.workspaceKey??null;
115
+ export function t3Remedy(f,mode){
116
+ if(!f)return null;if(f.threadID)return 'openThread';
117
+ switch(f.code){case 't3_server_unreachable':return mode==='ssh'?null:'openT3';case 't3_version_unsupported':return f.fix?.includes('shard update')?'updateShardflux':'openT3';case 't3_not_connected':case 't3_not_installed':return 'settings';default:return null;}
118
+ }
119
+ export function t3RemedyTitle(f,mode){const r=t3Remedy(f,mode);return r?{openThread:'Open',openT3:f.code==='t3_version_unsupported'?'Update T3 Code':'Open T3 Code',updateShardflux:'Update Shardflux',settings:f.code==='t3_not_connected'?'Connect T3 Code':'Choose where it opens'}[r]:null;}
120
+ // T3EffortReason: why a picked effort reached T3 Code as none. The effort pill's tooltip, and the line after the hand-off.
121
+ export const t3EffortLine={not_offered:'T3 Code sets the effort for this model',models_not_known:'T3 Code hasn’t listed this model’s efforts yet'};
122
+ export function effortNotApplied(doc,picked){if(!picked||doc?.effort!=null)return null;return t3EffortLine[doc?.effort_reason]?doc.effort_reason:'not_offered';}
123
+ export const effortKey=(app,model)=>`${app}|${model||'default'}`;
124
+ // OpenTarget.effective: T3 Code only while it is installed and connected; otherwise the app's own place.
125
+ export function openTarget(targets,app){const chosen=targets?.open_in?.[app];if(chosen==='t3')return targets?.t3?.installed!==false&&targets?.t3?.connected?'t3':'terminal';return chosen||'terminal';}
126
+ // LauncherView.barEffort: "Extra high" is "XHigh" in the bar (the menu keeps the whole name).
127
+ export const barEffort=e=>e?.id==='xhigh'?'XHigh':e?.label??'Default';
128
+ // LauncherProjectList.Entry: the folder's name, and its parent folder as the detail.
129
+ export function projectEntry(path){const parts=path.replace(/\/+$/,'').split('/').filter(Boolean);return {name:parts.at(-1)||path,detail:parts.length>1?parts.at(-2):'',path};}
@@ -0,0 +1,63 @@
1
+ // Lucide 0.468.0 (ISC; sources and licence in vendor/lucide), drawn at 1.5 px like SF Symbols' regular weight.
2
+ // Each name notes the SF Symbol it stands in for in the Mac app.
3
+ const paths={
4
+ search:'<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>', // magnifyingglass
5
+ folder:'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
6
+ 'folder-plus':'<path d="M12 10v6"/><path d="M9 13h6"/><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>', // folder.badge.plus
7
+ 'chevron-down':'<path d="m6 9 6 6 6-6"/>',
8
+ 'chevron-right':'<path d="m9 18 6-6-6-6"/>',
9
+ 'chevron-left':'<path d="m15 18-6-6 6-6"/>',
10
+ plus:'<path d="M5 12h14"/><path d="M12 5v14"/>',
11
+ monitor:'<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
12
+ // desktopcomputer: Lucide monitor with the solid chin SF Symbols draws under the screen.
13
+ desktop:'<rect width="20" height="14" x="2" y="3" rx="2"/><path d="M2.75 13.25h18.5V15a1.25 1.25 0 0 1-1.25 1.25H4A1.25 1.25 0 0 1 2.75 15z" fill="currentColor" stroke="none"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
14
+ 'corner-down-left':'<polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/>', // return (the ↵ of ⌘↵)
15
+ // camera.viewfinder: Lucide scan's corners around Lucide camera at half size (its stroke doubled to stay 1.5 px).
16
+ capture:'<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><g transform="translate(6 6) scale(.5)" stroke-width="3"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></g>',
17
+ check:'<path d="M20 6 9 17l-5-5"/>', // checkmark
18
+ 'circle-check':'<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
19
+ // checkmark.circle.fill: the circle filled with the current colour, the check cut out in the card's ink.
20
+ 'circle-check-fill':'<circle cx="12" cy="12" r="10" fill="currentColor"/><path d="m8.25 12.25 2.5 2.5 5-5.25" stroke="#0b0b0c" stroke-width="2.25"/>',
21
+ ellipsis:'<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
22
+ settings:'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', // gearshape
23
+ 'arrow-right':'<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
24
+ 'arrow-up-right':'<path d="M7 7h10v10"/><path d="M7 17 17 7"/>',
25
+ 'square-arrow-up-right':'<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 8h8v8"/><path d="m8 16 8-8"/>', // arrow.up.forward.square
26
+ x:'<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', // xmark
27
+ 'circle-x':'<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>', // xmark.circle
28
+ 'circle-alert':'<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>', // exclamationmark.circle
29
+ info:'<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>', // info.circle
30
+ copy:'<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>', // doc.on.doc
31
+ archive:'<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>', // archivebox
32
+ file:'<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>', // doc
33
+ link:'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
34
+ 'git-branch':'<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>', // arrow.triangle.branch
35
+ bell:'<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
36
+ 'circle-user':'<circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/>', // person.crop.circle
37
+ 'messages-square':'<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/>', // bubble.left.and.bubble.right
38
+ command:'<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/>',
39
+ user:'<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
40
+ 'circle-arrow-down':'<circle cx="12" cy="12" r="10"/><path d="M12 8v8"/><path d="m8 12 4 4 4-4"/>', // arrow.down.circle
41
+ 'rotate-cw':'<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>', // arrow.clockwise
42
+ hourglass:'<path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/>',
43
+ // lock.shield: Lucide shield with Lucide lock at half size inside it.
44
+ 'shield-lock':'<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><g transform="translate(7.5 6.5) scale(.375)" stroke-width="4"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></g>',
45
+ 'message-circle':'<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>', // bubble.left
46
+ power:'<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>',
47
+ 'log-out':'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
48
+ };
49
+ export const iconNames=Object.keys(paths);
50
+ export const icon=(name,size=16)=>{if(!paths[name])throw new Error(`Unknown icon ${name}`);return `<svg class="ui-icon" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name]}</svg>`;};
51
+ // NSProgressIndicator's spinning style: eight spokes fading around, turned by CSS (static under reduced motion).
52
+ export const spinner=(size=14)=>`<svg class="ui-spinner" width="${size}" height="${size}" viewBox="0 0 24 24" aria-hidden="true">${[...Array(8)].map((_,i)=>`<line x1="12" y1="3" x2="12" y2="7.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" opacity="${(0.25+i*0.75/7).toFixed(2)}" transform="rotate(${i*45} 12 12)"/>`).join('')}</svg>`;
53
+ const appNames={claude:'Claude',codex:'Codex',t3:'T3 Code'};
54
+ // AgentAppIcon: the app's own icon. Claude's is its orange rounded tile with the official mark in white; Codex's is
55
+ // the official OpenAI app icon; T3 Code's comes from its installed .desktop entry. Tiles fill 82% of the frame, as a
56
+ // macOS app icon sits on its icon grid.
57
+ export function appMark(app,size=16,installed){
58
+ const label=appNames[app]||app;
59
+ if(app==='claude')return `<span class="app-mark claude" style="--mark:${size}px" role="img" aria-label="${label}"><i></i></span>`;
60
+ const src=app==='t3'?installed:app==='codex'?'assets/openai-mark.png':null;
61
+ if(!src)return '';
62
+ return `<span class="app-mark ${app}" style="--mark:${size}px" role="img" aria-label="${label}"><img src="${src}" alt=""></span>`;
63
+ }
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src https:; connect-src 'none'; object-src 'none'; base-uri 'none'"><title>Shardflux</title><link rel="stylesheet" href="tokens.css"><link rel="stylesheet" href="style.css"></head><body><main id="app" aria-live="polite"></main><script type="module" src="app.mjs"></script></body></html>
@@ -0,0 +1,35 @@
1
+ import {icon} from './icons.mjs';
2
+ import {escape as e} from './core.mjs';
3
+ export function deviceChoice(request,always){if(always&&request.outside)throw new Error('Access outside the conversation folder must be approved once.');return always?'always':'once';}
4
+ const preview=text=>String(text||'').split(/\s+/).filter(Boolean).join(' ').slice(0,280);
5
+ // NativeNotices.swift: one source for notification copy and actions. The first action is also the default (a click).
6
+ export function notificationFor(row){if(row.state==='sign-in')return {title:'Reconnect Claude',body:'Sign in once to continue your conversations.',actions:['settings','Sign in']};return {title:row.state==='done'?`${row.project} finished`:`${row.project} needs you`,subtitle:row.title,body:preview(row.last_message)||'Open the conversation to reply.',actions:[`open:${row.reference}`,'Open conversation']};}
7
+ export function deviceNotice(r){return {title:r.outside?'Review folder access':'Review computer access',subtitle:r.conversation_title||'',body:preview((r.outside?'Outside this conversation’s folder. ':'')+r.summary),actions:[`review:${r.request_id}`,'Review…',`deny:${r.request_id}`,'Deny']};}
8
+ export function credentialNotice(r){return {title:`Review ${new URL(r.site).host} sign-in`,subtitle:r.conversation_title||'',body:`Share ${r.fields.map(f=>f==='totp'?'a verification code':f).join(' and ')} from your chosen vault.`,actions:[`credential:${r.id}`,'Review…',`deny-credential:${r.id}`,'Deny']};}
9
+ export function safePlaceholder(file,root){return /^conv_[a-z0-9]{26}$/.test(file.conversation_id)&&file.path&&!file.path.startsWith('/')&&!file.path.split('/').some(p=>!p||p==='.'||p==='..')&&file.local_path?.startsWith(root+'/')&&!file.local_path.includes('/../')&&Number.isFinite(file.size)&&file.size>=0;}
10
+ export function installIntegrations({model,host,cli,render,button,registerActions,savePrefs}) {
11
+ const I={requests:[],credentials:[],files:{root:'',placeholders:[]},always:false,scope:'ask',remember:false,vault:'1password',item:'',loaded:false,checking:false,previous:new Map(),busy:false};model.integrations=I;
12
+ const prior=window.renderSurface;
13
+ function request(){return I.requests.find(r=>r.request_id===model.prefs.reviewDevice)||I.requests[0];}
14
+ function credential(){return I.credentials.find(r=>r.id===model.prefs.reviewCredential)||I.credentials[0];}
15
+ const path=(label,value)=>value?`<div class="permission-path"><small>${label}</small><code>${e(value)}</code></div>`:'';
16
+ function panel(){const r=request();if(!r)return `<div class="empty">This request is no longer waiting for approval.</div>`;return `<div class="permission"><header class="permission-heading"><span class="permission-icon">${icon(r.kind==='exec'?'monitor':'folder',24)}</span><div><h2>${r.outside?'Allow access outside this conversation?':({exec:'Run a command on your computer?',files_pull:'Share files from your computer?',files_push:'Save files to your computer?',try_locally:'Try this conversation on your computer?',port_forward:'Open a local preview?',port_reverse:'Connect to a service on your computer?'})[r.kind]||'Allow this conversation on your computer?'}</h2><p>${e([r.conversation_title,r.project_name].filter(Boolean).join(' · '))}</p></div></header><div class="permission-details">${r.argv?.length?`<pre>${r.argv.map(a=>e(/^[\w/=:.,@%+-]+$/.test(a)?a:JSON.stringify(a))).join(' ')}</pre>`:`<p>${e(r.summary)}</p>`}${path('Folder',r.local_path)}${path('In the conversation',r.remote_path)}${r.local_port?path('On your computer',`localhost:${r.local_port}`)+path('In the conversation',`Port ${r.remote_port}`):''}</div>${r.outside?'<p class="error">This folder is outside the conversation’s folder. Approval applies to this request only.</p>':`<label class="permission-always"><input type="checkbox" name="always-device" ${I.always?'checked':''}> <span>Allow these requests for this project<small>Applies to this type of request in this project.</small></span></label>`}<footer class="row spread">${button('Deny','deny-device','plain-text')}${button(I.always&&!r.outside?'Allow for project':'Allow once','allow-device','primary')}</footer>${model.error?`<p class="error">${e(model.error)}</p>`:''}</div>`;}
17
+ function credentialPanel(){const r=credential();if(!r)return '<div class="empty">This sign-in request is no longer waiting.</div>';return `<div class="permission"><header class="permission-heading"><span class="permission-icon">${icon('user',24)}</span><div><h2>Sign in to ${e(new URL(r.site).host)}</h2><p>Your conversation is asking for a login.</p></div></header><p>Share ${r.fields.map(e).join(' and ')} from your vault.</p><div class="permission-details inline"><label class="inline-row"><span>Vault</span><select name="vault"><option value="1password" ${I.vault==='1password'?'selected':''}>1Password</option><option value="bitwarden" ${I.vault==='bitwarden'?'selected':''}>Bitwarden</option><option value="hsec" ${I.vault==='hsec'?'selected':''}>hsec</option></select></label><label class="inline-row"><span>Login item</span><input name="vault-item" value="${e(I.item)}" placeholder="GitHub personal account"></label></div><label class="inline-row scope"><span>Future requests</span><select name="credential-scope">${[['ask','Ask every time'],['project','Allow for this project'],['machine','Allow on this machine']].map(([v,t])=>`<option value="${v}" ${I.scope===v?'selected':''}>${t}</option>`).join('')}</select></label><p>Only these fields on ${e(new URL(r.site).host)}.</p><label class="permission-always"><input type="checkbox" name="credential-remember" ${I.remember?'checked':''}><span>Remember for unattended sign-in<small>Save an encrypted copy for ${I.scope==='ask'?'this conversation':I.scope==='project'?'this project’s template':'your machine template'}.</small></span></label><footer class="row spread">${button('Deny','deny-credential','plain-text')}${button('Allow login','allow-credential','primary',!I.item.trim()||!!r.expires_at&&Date.parse(r.expires_at)<Date.now())}</footer>${model.error?`<p class="error">${e(model.error)}</p>`:''}</div>`;}
18
+ function files(){return `<section class="files-window"><h2>Shardflux files</h2><p>Your conversations’ committed files, together.</p><div class="row">${button('Open files','open-files','primary')}<code>${e(I.files.root)}</code></div>${I.files.placeholders.length?`<h3>Ready to download</h3><div class="setting-group">${I.files.placeholders.map((f,i)=>`<div class="setting-row"><div><b>${e(f.path.split('/').at(-1))}</b><p>${e(f.title||f.conversation)} · ${(f.size/1e6).toFixed(1)} MB</p></div>${button('Download',`download:${i}`,'small')}</div>`).join('')}</div>`:'<p>Files appear here as your conversations save them.</p>'}${model.error?`<p class="error">${e(model.error)}</p>`:''}</section>`;}
19
+ window.renderSurface=()=>{if(['approvals','credential','files'].includes(model.view)){document.querySelector('#app').innerHTML=model.view==='approvals'?panel():model.view==='credential'?credentialPanel():files();if(model.view!=='files')requestAnimationFrame(()=>void host('resize',{height:document.querySelector('.permission')?.scrollHeight||320}));}else prior();};
20
+ window.sfRefreshIntegrations=()=>refresh();
21
+ async function refresh(){if(I.checking)return;I.checking=true;try{const [requests,credentials,index,info]=await Promise.all([cli(['device','requests','--json']),cli(['credentials','list','--json']).catch(()=>({data:[]})),host('read',{name:'files.json'}),host('info')]);I.requests=requests.requests||[];I.credentials=(credentials.data||[]).filter(r=>r.state==='pending');I.files={root:index?.root||`${info.home}/Shardflux`,placeholders:(index?.placeholders||[]).filter(f=>safePlaceholder(f,index.root))};
22
+ if(model.prefs.notifyApprovals!==false)for(const r of I.requests)void host('notify',{key:`device:${r.request_id}`,...deviceNotice(r)}).catch(()=>{});
23
+ if(model.prefs.notifyApprovals!==false)for(const r of I.credentials)void host('notify',{key:`credential:${r.id}`,...credentialNotice(r)}).catch(()=>{});
24
+ if((model.view==='approvals'&&!I.requests.length)||(model.view==='credential'&&!I.credentials.length))await host('hide');I.loaded=true;if(['files','approvals','credential','tray'].includes(model.view))render();
25
+ }catch(err){if(['files','approvals'].includes(model.view)){model.error=err.message;render();}}finally{I.checking=false;}}
26
+ window.addEventListener('sf-view',ev=>{if(['files','approvals','credential'].includes(ev.detail))void refresh();});
27
+ document.querySelector('#app').addEventListener('change',ev=>{if(ev.target.name==='always-device')I.always=ev.target.checked;if(ev.target.name==='vault')I.vault=ev.target.value;if(ev.target.name==='credential-scope')I.scope=ev.target.value;if(ev.target.name==='credential-remember')I.remember=ev.target.checked;render();});
28
+ document.querySelector('#app').addEventListener('input',ev=>{if(ev.target.name==='vault-item'){I.item=ev.target.value;const b=document.querySelector('[data-action=allow-credential]');if(b)b.disabled=!I.item.trim();}});
29
+ async function approve(allow){const r=request();if(!r)throw new Error('The request is no longer waiting.');const always=allow&&deviceChoice(r,I.always)==='always';await cli(['device',allow?'approve':'deny',r.request_id,...(always?['--always']:[]),'--json']);I.always=false;await refresh();}
30
+ registerActions({'allow-device':()=>approve(true),'deny-device':()=>approve(false),'allow-credential':async()=>{const r=credential();if(!r)return;await cli(['credentials','approve',r.id,'--from',I.vault,'--item',I.item,'--scope',I.scope,...(I.remember?['--remember']:[]),'--json']);await refresh();},'deny-credential':async()=>{const r=credential();if(r)await cli(['credentials','deny',r.id,'--json']);await refresh();},'open-files':()=>host('open-folder',{path:I.files.root}),'update-now':async()=>{model.updating=true;model.prefs.updateStarted=Date.now();await savePrefs();await host('update');}});
31
+ registerActions({integration:async(a)=>{if(a.startsWith('review:')){model.prefs.reviewDevice=a.slice(7);await savePrefs();await host('view',{name:'approvals'});}else if(a.startsWith('credential:')){model.prefs.reviewCredential=a.slice(11);await savePrefs();await host('view',{name:'credential'});}else if(a.startsWith('always-credential:')){model.prefs.reviewCredential=a.slice(18);I.scope='project';await savePrefs();await host('view',{name:'credential'});}else if(a.startsWith('deny:')){await cli(['device','deny',a.slice(5),'--json']);await refresh();}else if(a.startsWith('deny-credential:')){await cli(['credentials','deny',a.slice(16),'--json']);await refresh();}else if(a.startsWith('download:')){const f=I.files.placeholders[Number(a.slice(9))];if(!f)return;const result=await cli(['files','pull',f.conversation_id,f.path,'--json']);if(!result.local_path)throw new Error('The file has not finished downloading.');await host('reveal',{path:result.local_path});await refresh();}}});
32
+ async function tick(){if(I.busy)return;I.busy=true;try{await refresh();const rows=model.rows||[];if(I.previous.size)for(const r of rows){const stamp=`${r.state}:${r.since}`,previous=I.previous.get(r.id);if(previous&&previous!==stamp&&(['needs-you','sign-in'].includes(r.state)&&model.prefs.notifyWaiting!==false||r.state==='done'&&model.prefs.notifyDone!==false))void host('notify',{key:`agent:${r.id}:${stamp}`,...notificationFor(r)}).catch(()=>{});}I.previous=new Map(rows.map(r=>[r.id,`${r.state}:${r.since}`]));}finally{I.busy=false;}}
33
+ setInterval(tick,4000);void tick();
34
+ setInterval(async()=>{try{if(await host('idle')){const status=await cli(['menubar','status','--json']);const info=await host('info');if(status.packaged_version&&status.packaged_version!==info.version)await host('refresh-app');}if(model.prefs.updateStarted){const update=await host('read',{name:'last-update.json'});if(update&&Date.parse(update.finished_at)>model.prefs.updateStarted){model.updating=false;delete model.prefs.updateStarted;await savePrefs();}}}catch{}},60000);
35
+ }
@@ -0,0 +1,41 @@
1
+ import {icon,appMark,spinner} from './icons.mjs';
2
+ import {escape as e,cardState,shortcutLabel} from './core.mjs';
3
+ const panes=['Account','General','Agents','Launcher','Notifications','Updates','About'];
4
+ // SettingsPane.icon and .tint: SF Symbols as their Lucide counterparts, each on a 13% tile of its tint.
5
+ const paneIcons={Account:['circle-user','var(--sf-glass-glow-cold)'],General:['settings','var(--sf-muted)'],Agents:['messages-square','var(--sf-molten)'],Launcher:['command','var(--sf-ink)'],Notifications:['bell','var(--sf-warning)'],Updates:['circle-arrow-down','var(--sf-success)'],About:['info','var(--sf-dim)']};
6
+ const descriptions={Account:'Your account and this month’s activity.',General:'Make Shardflux feel at home on your computer.',Agents:'Your conversations, in the apps you already use.',Launcher:'Start a conversation without leaving your work.',Notifications:'Hear from an agent when it needs you.',Updates:'Keep your Shardflux apps up to date.',About:'A place for your agents to work.'};
7
+ export function installSettings({model,host,cli,render,button,registerActions,refresh,savePrefs}) {
8
+ const S={pane:'General',info:{},identity:null,usage:null,spend:null,org:null,profiles:[],update:null,recording:false,confirmSignout:false};
9
+ model.settings=S;
10
+ const row=(title,detail,value)=>`<div class="setting-row"><div><b>${e(title)}</b><p>${e(detail)}</p></div><div>${value}</div></div>`;
11
+ const group=html=>`<section class="setting-group">${html}</section>`;
12
+ const toggle=(label,key,on)=>`<input type="checkbox" role="switch" aria-label="${e(label)}" data-pref="${key}" ${on?'checked':''}>`;
13
+ const link=(label,url)=>`<a href="${url}">${e(label)} ${icon('arrow-up-right',12)}</a>`;
14
+ function agents(){return `<div class="settings-cards">${['claude','codex',...(model.inspection?.connections?.some(c=>c.id==='t3')?['t3']:[])].map(id=>{const c=model.inspection?.connections?.find(c=>c.id===id),state=cardState(c),title={claude:'Claude',codex:'Codex',t3:'T3 Code'}[id];let targets=id==='claude'?['terminal','web']:['terminal'];if(model.launcherState?.targets?.t3?.installed)targets.push('t3');const open=model.launcherState?.targets?.open_in?.[id]||'terminal';return group(`<div class="connector ${state.kind}">${id==='t3'&&!model.appIcons?.t3?'<span class="app-mark missing-mark"></span>':appMark(id,36,model.appIcons?.[id])}<div><b>${title}</b><p class="fact">${e(state.fact)}</p></div><span class="status">${state.kind==='connected'?`${icon('circle-check-fill',14)} Connected`:state.button?button(e(state.button),`connect:${id}`,'small'):''}</span></div>${id==='t3'?row('Open a project','Use this project in T3 Code.',button('Choose project…','t3-project','small')):row('Open conversations in',`Where ${title} conversations open on this computer.`,`<select data-open-in="${id}" aria-label="Open ${title} conversations in">${targets.map(v=>`<option value="${v}" ${open===v?'selected':''}>${{terminal:'Terminal',web:'Browser (claude.ai)',t3:'T3 Code'}[v]}</option>`).join('')}</select>`)}${id==='claude'?row('Remote Control','Continue work in the Claude app.',state.kind==='connected'?'Connected':'Not connected'):''}`);}).join('')}</div>`;}
15
+ function body(){switch(S.pane){
16
+ case 'Account':{const cpu=S.usage?.allowances?.find(a=>a.key==='cpu_hours');const spend=S.spend?.estimated_period_total_minor;return group(`<div class="account-heading"><img src="assets/shardflux-tray.svg" alt=""><div><h2>${e(S.identity?.display_name||S.identity?.email||'Welcome to Shardflux')}</h2><p>${e(S.identity?.email||'Sign in to get started')}</p></div></div>${S.org?`<p class="account-plan">${e(S.usage?.plan?.plan_key||'')} · ${e(S.org.name)}</p>`:''}`)+group(row('This month','CPU time used by your conversations.',cpu?`<span class="mono">${cpu.used.toFixed(1)}${cpu.included!==undefined?' / '+cpu.included:''} CPU-h</span>`:'Usage unavailable')+row('Estimated spend','Subscription and usage charges this period.',typeof spend==='number'?new Intl.NumberFormat(undefined,{style:'currency',currency:S.spend.currency}).format(spend/100):'Spend unavailable'))+`<div class="row spread">${link('Open dashboard','https://app.shardflux.dev')}${button('Sign out…','signout','link')}</div>${S.confirmSignout?group(`<p>Sign out of Shardflux on this computer? Your conversations keep running. Saved project keys remain available.</p><div class="row">${button('Cancel','cancel-signout')}${button('Sign out','confirm-signout')}</div>`):''}`;}
17
+ case 'General':return group(row('Launch at login','Available when you sign in.',toggle('Launch at login','autostart',model.prefs.autostart!==false))+row('Profile','Used for new conversations.',`<select data-profile aria-label="Profile">${[...new Set([model.api,...S.profiles])].filter(Boolean).map(url=>`<option value="${e(url)}" ${url===model.api?'selected':''}>${url==='https://api.shardflux.dev'?'Production':e(new URL(url).host)}</option>`).join('')}</select>`))+group(row('Onboarding','Start, step away, reply anywhere, land.',button('Show onboarding','onboarding','small'))+row('Command center','Workspaces, files and account access.',button('Open','command-center','small'))+row('Archived conversations','Conversations hidden from this menu.',button('Restore','restore-archived','small')));
18
+ case 'Agents':return agents();
19
+ case 'Launcher':return group(`<div class="shortcut-recorder"><b>Open Shardflux from any app</b><button class="keycap ${S.recording?'recording':''}" data-action="record-shortcut" aria-label="Change launcher shortcut">${e(S.recording?'Press shortcut…':shortcutLabel(model.prefs.shortcut).replace(/\+/g,' '))}</button><p>${S.recording?'Press your shortcut. Esc cancels.':'Click the keys to record another shortcut.'}</p>${S.shortcutNotice?`<p class="notice">${icon('circle-alert',13)}<span>${e(S.shortcutNotice)}</span></p>`:''}</div>`)+group(row('Detect my project','Choose the last project automatically.',toggle('Detect project','detectProject',model.prefs.detectProject!==false))+row('Position','Drag the launcher by its background to move it. It reopens there.',button('Reset launcher position','reset-position','small')))+`<p class="setting-note">Other app shortcuts stay available. Shardflux checks for conflicts before saving.</p>`;
20
+ case 'Notifications':return group(row('Agent needs you','When an agent is waiting for a reply.',toggle('Agent needs you','notifyWaiting',model.prefs.notifyWaiting!==false))+row('Agent finished','When an agent finishes its work.',toggle('Agent finished','notifyDone',model.prefs.notifyDone!==false))+row('Approvals and sign-in','Permission and sign-in requests.',toggle('Approvals and sign-in','notifyApprovals',model.prefs.notifyApprovals!==false)))+group(row('Desktop notifications','Manage Shardflux notifications in GNOME.',button('Open Settings','notification-settings','small')));
21
+ case 'Updates':{const failed=S.lastUpdate?.ok===false,checking=S.checking,updating=model.updating;
22
+ const notice=checking||updating?`${spinner(14)}<span>${checking?'Checking for updates…':'Updating Shardflux…'}</span>`:failed?`${icon('circle-alert',15)}<span class="warning">${e(S.lastUpdate.message||'The update didn’t finish. Try again.')}</span>`:S.update?.update_available?`${icon('circle-arrow-down',15)}<span>An update is ready to install.</span>`:`${icon('circle-arrow-down',15)}<span class="${S.update?'success':'secondary'}">${S.update?'You’re up to date.':'Check for the latest Shardflux release.'}</span>`;
23
+ const title=updating?'Updating…':failed?'Try again':S.update?.update_available?'Update now':'Check for updates';
24
+ return group(row('Desktop app','Conversations from your top bar.',`<span class="mono">${e(S.info.version||'—')}</span>`)+(S.update?.components||[]).filter(c=>!/desktop-linux|menubar-darwin/.test(c.package||'')).map(c=>row(c.package==='@shardflux/cli'?'shard':c.label||c.package,c.package==='@shardflux/cli'?'Your command center and agent launcher.':'Installed with your Shardflux tools.',`<span class="mono">${e(c.current)}</span>`)).join(''))
25
+ +`<div class="update-block"><p class="update-notice">${notice}</p><div class="row spread">${button(title,S.update?.update_available&&!failed?'update-now':'check-updates','primary regular',checking||updating)}${S.checkedAt?`<small>Checked ${e(new Date(S.checkedAt).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}))}</small>`:''}</div></div>`;}
26
+ case 'About':return `<div class="account-heading"><img src="assets/shardflux-tray.svg" alt=""><div><h2>Shardflux</h2><p>Version ${e(S.info.version||'Reading version…')}</p></div></div>`+group(row('Documentation','Guides for conversations, workspaces and the CLI.',link('Open','https://docs.shardflux.dev'))+row('Service status','Check the current service response.',link('Open','https://api.shardflux.dev/healthz'))+row('Support','Talk to the people building Shardflux.',link('Email','mailto:shardflux@heliosone.fi')));
27
+ }}
28
+ const accountName=()=>S.identity?.display_name||S.identity?.email||'Welcome to Shardflux';
29
+ const accountLine=()=>[S.usage?.plan?.plan_key&&S.usage.plan.plan_key[0].toUpperCase()+S.usage.plan.plan_key.slice(1),S.org?.name].filter(Boolean).join(' · ');
30
+ function sidebar(){
31
+ return `<aside><button class="sidebar-account" data-action="pane:Account"><img src="assets/shardflux-mark.svg" alt=""><span><b>${e(accountName())}</b><small>${e(accountLine()||(S.identity?'Personal':'Sign in to get started'))}</small></span></button><nav>${panes.map(p=>`${p==='Agents'||p==='Updates'?'<div class="nav-rule"></div>':''}<button class="${p===S.pane?'selected':''}" data-action="pane:${p}" aria-current="${p===S.pane?'page':'false'}"><span class="pane-icon" style="--tint:${paneIcons[p][1]}">${icon(paneIcons[p][0],14)}</span>${e(p)}</button>`).join('')}</nav></aside>`;
32
+ }
33
+ const prior=window.renderSurface;window.renderSurface=()=>{if(model.view!=='settings')return prior();document.querySelector('#app').innerHTML=`<div class="settings">${sidebar()}<section class="settings-content"><header><h2>${S.pane}</h2><p class="pane-description">${descriptions[S.pane]}</p></header>${body()}${model.error?`<p class="settings-error">${icon('circle-alert',13)}<span>${e(model.error)}</span></p>`:''}</section></div>`;};
34
+ async function load(){const api=model.api;const results=await Promise.allSettled([host('info'),host('profiles'),cli(['account','status','--json']),cli(['orgs','list','--json']),cli(['usage','--json']),cli(['usage','--estimate','--json']),cli(['agents','targets','--json']),cli(['menubar','status','--json'])]);if(api!==model.api)return;const value=i=>results[i].status==='fulfilled'?results[i].value:null;S.info=value(0)||{};if(value(7))model.prefs.autostart=value(7).login==='on';S.profiles=value(1)||[];S.identity=value(2)?.user;S.org=value(3)?.data?.[0];S.usage=value(4);S.spend=value(5);try{S.lastUpdate=await host('read',{name:'last-update.json'});}catch{}if(model.prefs.settingsPane&&panes.includes(model.prefs.settingsPane)){S.pane=model.prefs.settingsPane;delete model.prefs.settingsPane;await savePrefs();}if(!S.update&&model.settings?.update)S.checkedAt=S.checkedAt||Date.now();if(model.launcherState)model.launcherState.targets=value(6)||{};await refresh();render();}
35
+ window.addEventListener('sf-view',ev=>{if(ev.detail==='settings')void load();});
36
+ document.querySelector('#app').addEventListener('change',async ev=>{try{if(ev.target.dataset.pref){const key=ev.target.dataset.pref;model.prefs[key]=ev.target.checked;await savePrefs();if(key==='autostart')await cli(['menubar','status','--login',ev.target.checked?'on':'off','--json']);}if(ev.target.dataset.openIn){await cli(['agents','targets','--open-in',`${ev.target.dataset.openIn}=${ev.target.value}`,'--json']);await load();}if(ev.target.hasAttribute('data-profile'))await host('profile',{url:ev.target.value});}catch(err){model.error=err.message;render();}});
37
+ document.querySelector('#app').addEventListener('click',ev=>{const a=ev.target.closest('[data-action]')?.dataset.action;if(a?.startsWith('pane:')){S.pane=a.slice(5);S.recording=false;model.error='';render();}});
38
+ document.addEventListener('keydown',async ev=>{if(!S.recording)return;ev.preventDefault();if(ev.key==='Escape'){S.recording=false;render();return;}if(['Alt','Control','Shift','Meta'].includes(ev.key))return;const key=ev.code==='Space'?'space':ev.key.toLowerCase(),shortcut=(ev.ctrlKey?'<Control>':'')+(ev.altKey?'<Alt>':'')+(ev.shiftKey?'<Shift>':'')+(ev.metaKey?'<Super>':'')+key;try{await host('shortcut',{accelerator:shortcut});model.prefs.shortcut=shortcut;await savePrefs();S.recording=false;S.shortcutNotice='';}catch(err){S.shortcutNotice=err.message;}render();},true);
39
+ registerActions({'record-shortcut':()=>{S.recording=true;},'reset-position':()=>host('reset-position'),'command-center':()=>host('terminal',{argv:['shard','agents','menu']}),'notification-settings':()=>host('desktop-settings',{panel:'notifications'}),'restore-archived':async()=>{model.prefs.archived={};await savePrefs();},signout:()=>S.confirmSignout=true,'cancel-signout':()=>S.confirmSignout=false,'confirm-signout':async()=>{await cli(['auth','logout','--json']);S.identity=null;S.confirmSignout=false;await host('view',{name:'onboarding'});},'check-updates':async()=>{S.checking=true;render();try{S.update=await cli(['update','--check','--json']);S.checkedAt=Date.now();}finally{S.checking=false;}},'update-now':async()=>{model.updating=true;try{S.update=await cli(['update','--json'],{timeout:540});}finally{model.updating=false;}},'t3-project':async()=>{const path=await host('choose',{kind:'folder'});if(path){const d=await cli(['agents','new','--in','t3','--path',path,'--json']);model.phone=d.t3_link;await refresh();}}});
40
+ if(model.view==='settings')void load();
41
+ }