Compare commits

...

5 Commits

Author SHA1 Message Date
Augustin
cbf623b98b fix(terminal): use absolute positioning for content panels
All checks were successful
Beta Release / beta (push) Successful in 50s
height:100% on .content>div fails because .content uses flex:1
without explicit height. Switch to position:absolute;inset:0 which
correctly fills the content area and gives xterm proper container
dimensions for fitAddon.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:13:20 +02:00
Augustin
b85ebb8e54 feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts
All checks were successful
Beta Release / beta (push) Successful in 48s
xterm captures all keyboard input which prevents standard clipboard
operations. Add custom key handler to intercept Ctrl+Shift+C for
copy (selection) and Ctrl+Shift+V for paste, without interfering
with Ctrl+C (SIGINT) or browser devtools shortcut.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:10:24 +02:00
Augustin
7cc206dc20 fix(shell): prevent Enter in AI chat from leaking to terminal
All checks were successful
Beta Release / beta (push) Successful in 48s
Stop propagation of Enter keydown in AI input and defer terminal
focus to next event loop tick to prevent xterm from capturing the
same key event.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:07:36 +02:00
Augustin
bf8c0fd380 fix(terminal): improve terminal dimensions and fit timing
All checks were successful
Beta Release / beta (push) Successful in 47s
Use min-height:0 on xterm-wrapper (flex child) instead of height:100%
to properly fill available space in flex layout. Add delayed fit()
calls after initialization to let the layout stabilize before
calculating terminal cell dimensions.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:35:49 +02:00
Augustin
08dc1fd53b fix(terminal): detect shell tab visibility via MutationObserver
All checks were successful
Beta Release / beta (push) Successful in 49s
Shell is always mounted inside a display:none parent when the app
loads on a different tab. Added MutationObserver on the wrapper to
detect when the shell tab becomes visible and initialize/fit all
pending terminals at that moment. Removed attempt limit so retries
continue until the tab is actually shown.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:28:02 +02:00
5 changed files with 87 additions and 14 deletions

View File

@@ -92,6 +92,8 @@ export default function App() {
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') }, { keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
], ],
shell: [ shell: [
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') }, { keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') }, { keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
], ],

View File

@@ -143,6 +143,32 @@ function createTerminal(container, settings = {}) {
const webLinksAddon = new WebLinksAddon() const webLinksAddon = new WebLinksAddon()
term.loadAddon(fitAddon) term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon) term.loadAddon(webLinksAddon)
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
if (ctrl && shift && e.key === 'C') {
e.preventDefault()
e.stopPropagation()
const selection = term.getSelection()
if (selection) navigator.clipboard.writeText(selection)
return false
}
if (ctrl && shift && e.key === 'V') {
e.preventDefault()
e.stopPropagation()
navigator.clipboard.readText().then(text => {
if (text) term.paste(text)
}).catch(() => {})
return false
}
return true
})
term.open(container) term.open(container)
fitAddon.fit() fitAddon.fit()
@@ -425,15 +451,42 @@ export default function Shell({ api }) {
} }
}, []) }, [])
const initPendingTabs = useCallback(() => {
for (const tab of tabsRef.current._tabList || []) {
if (!tabsRef.current[tab.id]) {
const container = document.getElementById(`terminal-${tab.id}`)
if (container && container.offsetHeight > 0) {
initTerminal(tab.id, tab)
}
}
}
requestAnimationFrame(() => {
for (const tab of tabsRef.current._tabList || []) {
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
}
setTimeout(() => {
for (const tab of tabsRef.current._tabList || []) {
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
}
}, 150)
})
}, [initTerminal])
useEffect(() => {
tabsRef.current._tabList = tabs
}, [tabs])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const pending = [] const pending = []
const tryInitTab = (tab, attempt) => { const tryInitTab = (tab, attempt) => {
if (cancelled || attempt > 30) return if (cancelled) return
const shellCol = document.querySelector('.shell-terminal-col') const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) { if (!shellCol || shellCol.offsetParent === null) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150)) pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 200))
return return
} }
const container = document.getElementById(`terminal-${tab.id}`) const container = document.getElementById(`terminal-${tab.id}`)
@@ -451,21 +504,33 @@ export default function Shell({ api }) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (cancelled) return if (cancelled) return
const entry = tabsRef.current[tab.id] const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit() if (entry) {
entry.fitAddon.fit()
setTimeout(() => { if (!cancelled) entry.fitAddon.fit() }, 100)
}
}) })
}
for (const tab of tabs) {
if (!tabsRef.current[tab.id]) { if (!tabsRef.current[tab.id]) {
tryInitTab(tab, 0) tryInitTab(tab, 0)
} }
} }
const wrapper = document.querySelector('.shell-layout')?.parentElement
let observer
if (wrapper) {
observer = new MutationObserver(() => {
if (!wrapper.classList.contains('tab-hidden') && wrapper.offsetParent !== null) {
initPendingTabs()
}
})
observer.observe(wrapper, { attributes: true, attributeFilter: ['class'] })
}
return () => { return () => {
cancelled = true cancelled = true
pending.forEach(clearTimeout) pending.forEach(clearTimeout)
observer?.disconnect()
} }
}, [tabs, initTerminal]) }, [tabs, initTerminal, initPendingTabs])
useEffect(() => { useEffect(() => {
const entry = tabsRef.current[activeTab] const entry = tabsRef.current[activeTab]
@@ -480,6 +545,8 @@ export default function Shell({ api }) {
useEffect(() => { useEffect(() => {
const iv = setInterval(() => { const iv = setInterval(() => {
const wrapper = document.querySelector('.shell-layout')?.parentElement
if (wrapper && wrapper.classList.contains('tab-hidden')) return
const entry = tabsRef.current[activeTabRef.current] const entry = tabsRef.current[activeTabRef.current]
if (entry) { if (entry) {
entry.fitAddon.fit() entry.fitAddon.fit()
@@ -652,7 +719,7 @@ export default function Shell({ api }) {
if (!fromEvent) { if (!fromEvent) {
setAiInput('') setAiInput('')
focusAiTerminal() setTimeout(() => focusAiTerminal(), 0)
} }
if (trimmed === '/clear') { if (trimmed === '/clear') {
@@ -896,7 +963,7 @@ export default function Shell({ api }) {
<input <input
value={aiInput} value={aiInput}
onChange={e => setAiInput(e.target.value)} onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend() } }}
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')} placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
disabled={aiAtLimit && aiInput !== '/clear'} disabled={aiAtLimit && aiInput !== '/clear'}
/> />

View File

@@ -16,6 +16,8 @@ const en = {
switchWindow: 'Switch window', switchWindow: 'Switch window',
sendMessage: 'Send message', sendMessage: 'Send message',
newLine: 'New line', newLine: 'New line',
copy: 'Copy',
paste: 'Paste',
runCommand: 'Run command', runCommand: 'Run command',
commandHistory: 'Command history', commandHistory: 'Command history',
}, },

View File

@@ -16,6 +16,8 @@ const fr = {
switchWindow: 'Changer de fen\u00eatre', switchWindow: 'Changer de fen\u00eatre',
sendMessage: 'Envoyer le message', sendMessage: 'Envoyer le message',
newLine: 'Nouvelle ligne', newLine: 'Nouvelle ligne',
copy: 'Copier',
paste: 'Coller',
runCommand: 'Ex\u00e9cuter', runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique', commandHistory: 'Historique',
}, },

View File

@@ -155,7 +155,7 @@ input::placeholder { color: var(--text-disabled); }
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; } .header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
.content { flex: 1; overflow: hidden; position: relative; } .content { flex: 1; overflow: hidden; position: relative; }
.content > div { height: 100%; } .content > div { position: absolute; inset: 0; overflow: hidden; }
.tab-hidden { display: none; } .tab-hidden { display: none; }
.statusbar { .statusbar {
@@ -276,8 +276,8 @@ input::placeholder { color: var(--text-disabled); }
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); } .sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; } .sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
.shell-layout { display: flex; height: 100%; } .shell-layout { display: flex; height: 100%; overflow: hidden; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } .shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.shell-tabs-bar { .shell-tabs-bar {
display: flex; align-items: center; background: var(--bg-surface); display: flex; align-items: center; background: var(--bg-surface);
@@ -382,7 +382,7 @@ input::placeholder { color: var(--text-disabled); }
} }
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } .shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; height: 100%; background: var(--bg); overflow: hidden; position: relative; } .shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance { .shell-xterm-instance {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -402,7 +402,7 @@ input::placeholder { color: var(--text-disabled); }
.shell-tab.ai-tab .shell-tab-name { color: var(--accent); } .shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
.shell-tab.ai-tab { border-bottom-color: var(--accent); } .shell-tab.ai-tab { border-bottom-color: var(--accent); }
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; } .shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; } .ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
.shell-analyze-btn { .shell-analyze-btn {
display: flex; align-items: center; gap: 4px; display: flex; align-items: center; gap: 4px;