ui: terminal prompt grows to six lines, Shift+Enter adds a line
3 files changed, +56 -16
+29-9src/lib/gcs/console.ts
| @@ -57,7 +57,7 @@ export function initConsole() { | ||
| 57 | 57 | const log = panel.querySelector<HTMLElement>('[data-term-log]')!; |
| 58 | 58 | const welcome = panel.querySelector<HTMLElement>('[data-term-welcome]')!; |
| 59 | 59 | const form = panel.querySelector<HTMLFormElement>('[data-term-form]')!; |
| 60 | - const input = panel.querySelector<HTMLInputElement>('#termInput')!; | |
| 60 | + const input = panel.querySelector<HTMLTextAreaElement>('#termInput')!; | |
| 61 | 61 | const menu = panel.querySelector<HTMLElement>('[data-term-menu]')!; |
| 62 | 62 | const hints = panel.querySelector<HTMLElement>('[data-term-hints]')!; |
| 63 | 63 | const launchers = Array.from(document.querySelectorAll<HTMLElement>('[data-term-open]')); |
| @@ -149,6 +149,17 @@ export function initConsole() { | ||
| 149 | 149 | |
| 150 | 150 | const isOpen = () => !panel.hidden; |
| 151 | 151 | |
| 152 | + // The prompt grows with its text up to about six lines, then scrolls. | |
| 153 | + const fit = () => { | |
| 154 | + input.style.height = 'auto'; | |
| 155 | + input.style.height = `${input.scrollHeight}px`; | |
| 156 | + }; | |
| 157 | + const setValue = (v: string) => { | |
| 158 | + input.value = v; | |
| 159 | + fit(); | |
| 160 | + input.setSelectionRange(v.length, v.length); | |
| 161 | + }; | |
| 162 | + | |
| 152 | 163 | const commands: Record<string, Command> = { |
| 153 | 164 | clear: { |
| 154 | 165 | help: 'start a new conversation', |
| @@ -358,12 +369,12 @@ export function initConsole() { | ||
| 358 | 369 | const [name, c] = matches[i] ?? []; |
| 359 | 370 | if (!name) return; |
| 360 | 371 | if (c!.usage || !run) { |
| 361 | - input.value = `/${name}${c!.usage ? ' ' : ''}`; | |
| 372 | + setValue(`/${name}${c!.usage ? ' ' : ''}`); | |
| 362 | 373 | hideMenu(); |
| 363 | 374 | if (!c!.usage) updateMenu(); |
| 364 | 375 | return; |
| 365 | 376 | } |
| 366 | - input.value = ''; | |
| 377 | + setValue(''); | |
| 367 | 378 | hideMenu(); |
| 368 | 379 | submit(`/${name}`, 'menu'); |
| 369 | 380 | } |
| @@ -399,33 +410,42 @@ export function initConsole() { | ||
| 399 | 410 | return pick(menuIndex, true); |
| 400 | 411 | } |
| 401 | 412 | const raw = input.value; |
| 402 | - input.value = ''; | |
| 413 | + setValue(''); | |
| 403 | 414 | hideMenu(); |
| 404 | 415 | submit(raw); |
| 405 | 416 | }); |
| 406 | 417 | |
| 407 | 418 | input.addEventListener('input', () => { |
| 408 | 419 | menuIndex = 0; |
| 420 | + fit(); | |
| 409 | 421 | updateMenu(); |
| 410 | 422 | }); |
| 411 | 423 | |
| 424 | + // Enter sends, Shift+Enter adds a line. Up and Down walk the history only | |
| 425 | + // from the first or last line, so they still move the cursor in long text. | |
| 412 | 426 | input.addEventListener('keydown', (e) => { |
| 413 | 427 | const menuOpen = !menu.hidden && matches.length > 0; |
| 414 | - if (e.key === 'Tab' && menuOpen) { | |
| 428 | + const caret = input.selectionStart ?? 0; | |
| 429 | + const onFirstLine = !input.value.slice(0, caret).includes('\n'); | |
| 430 | + const onLastLine = !input.value.slice(input.selectionEnd ?? caret).includes('\n'); | |
| 431 | + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { | |
| 432 | + e.preventDefault(); | |
| 433 | + form.requestSubmit(); | |
| 434 | + } else if (e.key === 'Tab' && menuOpen) { | |
| 415 | 435 | e.preventDefault(); |
| 416 | 436 | pick(menuIndex, false); |
| 417 | 437 | } else if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && menuOpen) { |
| 418 | 438 | e.preventDefault(); |
| 419 | 439 | menuIndex = (menuIndex + (e.key === 'ArrowUp' ? -1 : 1) + matches.length) % matches.length; |
| 420 | 440 | updateMenu(); |
| 421 | - } else if (e.key === 'ArrowUp' && inputHistory.length) { | |
| 441 | + } else if (e.key === 'ArrowUp' && onFirstLine && inputHistory.length) { | |
| 422 | 442 | e.preventDefault(); |
| 423 | 443 | hIndex = Math.min(inputHistory.length - 1, hIndex + 1); |
| 424 | - input.value = inputHistory[hIndex]; | |
| 425 | - } else if (e.key === 'ArrowDown' && hIndex > -1) { | |
| 444 | + setValue(inputHistory[hIndex]); | |
| 445 | + } else if (e.key === 'ArrowDown' && onLastLine && hIndex > -1) { | |
| 426 | 446 | e.preventDefault(); |
| 427 | 447 | hIndex -= 1; |
| 428 | - input.value = hIndex === -1 ? '' : inputHistory[hIndex]; | |
| 448 | + setValue(hIndex === -1 ? '' : inputHistory[hIndex]); | |
| 429 | 449 | } |
| 430 | 450 | }); |
| 431 | 451 |
+5-5src/pages/index.astro
| @@ -625,21 +625,21 @@ const pad = (n: number) => String(n).padStart(2, '0'); | ||
| 625 | 625 | <form class="term-form" data-term-form> |
| 626 | 626 | <label class="term-input" for="termInput"> |
| 627 | 627 | <span class="text-vol" aria-hidden="true">></span> |
| 628 | - <input | |
| 628 | + <textarea | |
| 629 | 629 | id="termInput" |
| 630 | - class="min-w-0 flex-1 bg-transparent caret-vol outline-none placeholder:text-muted/70" | |
| 630 | + class="term-textarea" | |
| 631 | + rows="1" | |
| 631 | 632 | autocomplete="off" |
| 632 | 633 | autocapitalize="off" |
| 633 | 634 | spellcheck="false" |
| 634 | 635 | enterkeyhint="send" |
| 635 | 636 | maxlength="600" |
| 636 | 637 | placeholder="Ask about Lane, or / for commands" |
| 637 | - aria-label="Ask about Lane, or type a slash command" | |
| 638 | - /> | |
| 638 | + aria-label="Ask about Lane, or type a slash command. Shift+Enter adds a line."></textarea> | |
| 639 | 639 | </label> |
| 640 | 640 | <div class="term-menu" data-term-menu role="listbox" aria-label="Commands" hidden></div> |
| 641 | 641 | <div class="term-hints" data-term-hints> |
| 642 | - <span>/ commands ↑ history esc close</span> | |
| 642 | + <span>/ commands <span class="term-hint-wide">shift+enter new line </span>↑ history esc close</span> | |
| 643 | 643 | </div> |
| 644 | 644 | </form> |
| 645 | 645 | </section> |
+22-2src/styles/site.css
| @@ -323,13 +323,32 @@ | ||
| 323 | 323 | } |
| 324 | 324 | .term-input { |
| 325 | 325 | display: flex; |
| 326 | - align-items: center; | |
| 326 | + align-items: flex-start; | |
| 327 | 327 | gap: 0.6rem; |
| 328 | 328 | padding: 0.4rem 0.6rem; |
| 329 | 329 | border: 1px solid var(--color-rock); |
| 330 | 330 | border-radius: 4px; |
| 331 | 331 | cursor: text; |
| 332 | 332 | } |
| 333 | +.term-textarea { | |
| 334 | + display: block; | |
| 335 | + flex: 1; | |
| 336 | + min-width: 0; | |
| 337 | + max-height: calc(6 * 1.55em); | |
| 338 | + padding: 0; | |
| 339 | + overflow-y: auto; | |
| 340 | + resize: none; | |
| 341 | + border: 0; | |
| 342 | + background: transparent; | |
| 343 | + font: inherit; | |
| 344 | + line-height: 1.55; | |
| 345 | + color: inherit; | |
| 346 | + caret-color: var(--color-vol); | |
| 347 | + outline: none; | |
| 348 | +} | |
| 349 | +.term-textarea::placeholder { | |
| 350 | + color: rgb(143 145 148 / 0.7); | |
| 351 | +} | |
| 333 | 352 | .term-input:focus-within { |
| 334 | 353 | border-color: rgb(255 130 0 / 0.6); |
| 335 | 354 | } |
| @@ -360,7 +379,8 @@ | ||
| 360 | 379 | color: var(--color-vol-ink); |
| 361 | 380 | } |
| 362 | 381 | @media (max-width: 639px) { |
| 363 | - .term-kbd { | |
| 382 | + .term-kbd, | |
| 383 | + .term-hint-wide { | |
| 364 | 384 | display: none; |
| 365 | 385 | } |
| 366 | 386 | } |