Make the terminal dock toggle, scroll, and remember commands honestly.

The desktop trigger now collapses through the shared toggle, the log stays on the newest line, Shift+K is left to the browser, history walks like a shell, and maximize keeps one accessible name.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 13:51:55 +02:00
parent 19d00da9bb
commit b8b9e57d06
7 changed files with 325 additions and 22 deletions

View File

@@ -11,7 +11,6 @@ export interface SignatureCopy {
readonly inputLabel: string;
readonly inputPlaceholder: string;
readonly collapseLabel: string;
readonly restoreLabel: string;
readonly maximizeLabel: string;
readonly outputLabel: string;
readonly unknownCommand: string;
@@ -59,7 +58,6 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
inputLabel: 'Befehl',
inputPlaceholder: 'Befehl eingeben',
collapseLabel: 'Terminal einklappen',
restoreLabel: 'Terminal verkleinern',
maximizeLabel: 'Terminal vergrößern',
outputLabel: 'Ausgabe',
unknownCommand: 'Unbekannter Befehl: {command}',
@@ -113,7 +111,6 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
inputLabel: 'Command',
inputPlaceholder: 'Type a command',
collapseLabel: 'Collapse the terminal',
restoreLabel: 'Restore the terminal',
maximizeLabel: 'Maximise the terminal',
outputLabel: 'Output',
unknownCommand: 'Unknown command: {command}',

View File

@@ -25,11 +25,11 @@
<button
type="button"
class="terminal-dock-control"
[attr.aria-label]="maximizeLabel()"
[attr.aria-label]="copy().maximizeLabel"
[attr.aria-pressed]="maximized()"
(click)="toggleMaximized()"
>
{{ maximizeLabel() }}
{{ copy().maximizeLabel }}
</button>
<button
type="button"
@@ -42,6 +42,7 @@
</div>
<div
#outputLog
class="terminal-dock-log"
[id]="logId"
role="log"

View File

@@ -90,6 +90,11 @@
font: inherit;
}
.terminal-dock-control[aria-pressed='true'] {
border-color: var(--surface-glass-border-strong);
background: var(--color-surface);
}
.terminal-dock-log {
overflow: auto;
min-height: 6rem;

View File

@@ -104,7 +104,12 @@ export class TerminalDockService {
}
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
if (
event.key.toLowerCase() !== 'k' ||
!(event.ctrlKey || event.metaKey) ||
event.altKey ||
event.shiftKey
) {
return;
}

View File

@@ -91,6 +91,42 @@ describe('TerminalDock', () => {
expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull();
});
it.each(APP_LOCALES)('toggles the trigger open, closed, then open again (%s)', async (locale) => {
const fixture = await createFixture(locale);
const dock = TestBed.inject(TerminalDockService);
await openViaTrigger(fixture);
expect(panel(fixture)).toBeTruthy();
expect(dock.state()).toBe('expanded');
expect(document.activeElement).toBe(input(fixture));
trigger(fixture).click();
await flush(fixture);
expect(panel(fixture)).toBeNull();
expect(dock.state()).toBe('collapsed');
expect(document.activeElement).toBe(trigger(fixture));
await openViaTrigger(fixture);
expect(panel(fixture)).toBeTruthy();
expect(dock.state()).toBe('expanded');
expect(document.activeElement).toBe(input(fixture));
});
it.each(APP_LOCALES)(
'returns focus to the trigger after a trigger-initiated collapse (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
expect(document.activeElement).toBe(input(fixture));
trigger(fixture).click();
await flush(fixture);
expect(panel(fixture)).toBeNull();
expect(document.activeElement).toBe(trigger(fixture));
},
);
it.each(APP_LOCALES)('moves through collapsed, expanded and maximized (%s)', async (locale) => {
const fixture = await createFixture(locale);
const dock = TestBed.inject(TerminalDockService);
@@ -111,6 +147,27 @@ describe('TerminalDock', () => {
expect(dock.state()).toBe('expanded');
});
it.each(APP_LOCALES)(
'keeps one stable maximize name across both aria-pressed states (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const label = SIGNATURE_COPY[locale].terminal.maximizeLabel;
const button = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
expect(button.getAttribute('aria-label')).toBe(label);
expect(button.textContent?.trim()).toBe(label);
expect(button.getAttribute('aria-pressed')).toBe('false');
button.click();
await flush(fixture);
expect(button.getAttribute('aria-label')).toBe(label);
expect(button.textContent?.trim()).toBe(label);
expect(button.getAttribute('aria-pressed')).toBe('true');
},
);
it.each(APP_LOCALES)('opens from Ctrl+K and Meta+K (%s)', async (locale) => {
const fixture = await createFixture(locale);
await flush(fixture);
@@ -138,6 +195,66 @@ describe('TerminalDock', () => {
}
});
it('does not toggle or preventDefault on Ctrl/Cmd+Shift+K', async () => {
const fixture = await createFixture();
await flush(fixture);
for (const modifier of [
{ ctrlKey: true, shiftKey: true },
{ metaKey: true, shiftKey: true },
] as const) {
const event = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
...modifier,
});
const prevent = vi.spyOn(event, 'preventDefault');
document.dispatchEvent(event);
await flush(fixture);
expect(prevent).not.toHaveBeenCalled();
expect(panel(fixture)).toBeNull();
}
const openEvent = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
ctrlKey: true,
});
const preventOpen = vi.spyOn(openEvent, 'preventDefault');
document.dispatchEvent(openEvent);
await flush(fixture);
expect(preventOpen).toHaveBeenCalled();
expect(panel(fixture)).toBeTruthy();
const shiftWhileOpen = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
ctrlKey: true,
shiftKey: true,
});
const preventShift = vi.spyOn(shiftWhileOpen, 'preventDefault');
document.dispatchEvent(shiftWhileOpen);
await flush(fixture);
expect(preventShift).not.toHaveBeenCalled();
expect(panel(fixture)).toBeTruthy();
const metaOpen = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
metaKey: true,
});
const preventMeta = vi.spyOn(metaOpen, 'preventDefault');
document.dispatchEvent(metaOpen);
await flush(fixture);
expect(preventMeta).toHaveBeenCalled();
expect(panel(fixture)).toBeNull();
});
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
@@ -211,6 +328,71 @@ describe('TerminalDock', () => {
},
);
it.each(APP_LOCALES)('scrolls the log to the newest line after output (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const logEl = fixture.nativeElement.querySelector('.terminal-dock-log') as HTMLElement;
const scrollTo = vi.fn();
Object.defineProperty(logEl, 'scrollHeight', { configurable: true, get: () => 480 });
Object.defineProperty(logEl, 'scrollTo', { configurable: true, value: scrollTo });
await submitQuery(fixture, 'help');
expect(scrollTo).toHaveBeenCalled();
expect(scrollTo.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
top: 480,
}),
);
});
it.each(APP_LOCALES)('walks command history like a conventional shell (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
await submitQuery(fixture, 'help');
await submitQuery(fixture, 'brew');
await submitQuery(fixture, 'rev');
const field = input(fixture);
expect(field?.value).toBe('');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('rev');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('brew');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('help');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('help');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('brew');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('rev');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('');
});
it.each(APP_LOCALES)('navigates a known target and opens the CV asset (%s)', async (locale) => {
const fixture = await createFixture(locale);
const router = TestBed.inject(Router);

View File

@@ -16,7 +16,11 @@ import { SIGNATURE_COPY } from '../../core/content/signature-copy';
import { SITE_CONFIG } from '../../core/content/site-config';
import { LocaleService } from '../../core/i18n/locale.service';
import { NavigationService } from '../../core/navigation/navigation.service';
import { isApplePlatform, isBrowserPlatform } from '../../core/platform/browser';
import {
isApplePlatform,
isBrowserPlatform,
prefersReducedMotion,
} from '../../core/platform/browser';
import {
applyCompletion,
COMMAND_IDS,
@@ -48,9 +52,11 @@ export class TerminalDock {
private readonly dock = inject(TerminalDockService);
private readonly isBrowser = isBrowserPlatform();
private readonly applePlatform = isApplePlatform();
private readonly reducedMotion = prefersReducedMotion();
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
private readonly logRef = viewChild<ElementRef<HTMLElement>>('outputLog');
protected readonly panelId = this.dock.panelId;
protected readonly inputId = this.dock.inputId;
@@ -68,9 +74,6 @@ export class TerminalDock {
);
protected readonly open = computed(() => this.state() !== 'collapsed');
protected readonly maximized = computed(() => this.state() === 'maximized');
protected readonly maximizeLabel = computed(() =>
this.maximized() ? this.copy().restoreLabel : this.copy().maximizeLabel,
);
constructor() {
afterNextRender(
@@ -96,12 +99,24 @@ export class TerminalDock {
{ injector: this.injector },
);
});
effect(() => {
const lines = this.log();
if (!this.isBrowser || !this.open() || lines.length === 0) {
return;
}
afterNextRender(
() => {
this.scrollLogToLatest();
},
{ injector: this.injector },
);
});
}
protected onTriggerClick(): void {
const trigger = this.triggerRef()?.nativeElement;
trigger?.focus();
this.dock.open(trigger ?? null);
this.dock.toggle(this.triggerRef()?.nativeElement ?? null);
}
protected onQueryInput(event: Event): void {
@@ -165,7 +180,7 @@ export class TerminalDock {
this.echo(raw);
this.entered.update((items) => [...items, raw]);
this.historyIndex.set(null);
this.query.set('');
this.writeQuery('');
switch (parsed.kind) {
case 'help': {
@@ -237,7 +252,7 @@ export class TerminalDock {
private completeCurrent(): void {
const result = applyCompletion(this.query());
if (result.candidates.length === 1) {
this.query.set(result.value);
this.writeQuery(result.value);
return;
}
@@ -253,15 +268,56 @@ export class TerminalDock {
}
const current = this.historyIndex();
const next =
current === null
? direction < 0
? commands.length - 1
: 0
: Math.min(commands.length - 1, Math.max(0, current + direction));
if (current === null) {
if (direction > 0) {
return;
}
const newest = commands.length - 1;
this.historyIndex.set(newest);
this.writeQuery(commands[newest] ?? '');
return;
}
const next = current + direction;
if (next < 0) {
return;
}
if (next >= commands.length) {
this.historyIndex.set(null);
this.writeQuery('');
return;
}
this.historyIndex.set(next);
this.query.set(commands[next] ?? '');
this.writeQuery(commands[next] ?? '');
}
private writeQuery(value: string): void {
this.query.set(value);
const field = this.inputRef()?.nativeElement;
if (field) {
field.value = value;
}
}
private scrollLogToLatest(): void {
const element = this.logRef()?.nativeElement;
if (!element) {
return;
}
const top = element.scrollHeight;
if (typeof element.scrollTo === 'function') {
element.scrollTo({
top,
behavior: this.reducedMotion ? 'auto' : 'smooth',
});
return;
}
element.scrollTop = top;
}
private echo(input: string): void {