Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -815,14 +815,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
if (working && canSend) submit('/stop')
},
)
// The notice bar doubles as the ctrl+c prompt: armed, it says what a second
// press does, so the quit is never a surprise.
// Armed, the meta line below the composer becomes the ctrl+c prompt (it
// says what a second press does), and the arm expires after a few seconds.
// The browser is for reading, not sending: it drops the composer for the rows
// (a whole screen of conversation is the point of opening it) and says so on
// the notice line, which is where the app's one line of transient guidance
// already lives.
const browserNotice = '↑↓ scroll · → open · ← close · ctrl+r expand all · esc back to the chat'
const shownNotice = windowed ? browserNotice : ctrlCArmed ? CTRL_C_QUIT_HINT : notice
const shownNotice = windowed ? browserNotice : notice
const { viewBudget, padRows, composerRows, noticeRows, menuRows } = useMemo(() => {
// Both wrapping parts of the footer are measured as the rows they will
// actually OCCUPY, not as the newlines they contain: a notice ("stream
Expand Down Expand Up @@ -1479,9 +1479,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
const totalStr = `$${(serverCostUsd ?? cost.total ?? 0).toFixed(2)}`
// Sized to fit by construction: Ink measures the OSC-8 hyperlink's
// invisible URL bytes as width, so a linked id only ships when the whole
// line — escape bytes included — fits the pane; otherwise the id renders
// as plain text, shortened if even that overflows. Never rely on Ink
// wrapping/truncating this line: it's budgeted at exactly one row.
// line — escape bytes included — fits the pane; otherwise the line renders
// as plain text, cut to the pane with a … if even that overflows. Never
// rely on Ink wrapping/truncating this line: it's budgeted at exactly one
// row.
const metaParts = (id: string): string[] => [
`${statusWord} · ${totalStr} total`,
...(props.model ? [props.model] : []),
Expand All @@ -1491,12 +1492,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
]
const linked = metaParts(hyperlink(props.sessionUrl, sessionId)).join(' · ')
const plain = metaParts(sessionId).join(' · ')
const metaLine =
linked.length < cols
const metaLine = ctrlCArmed
? CTRL_C_QUIT_HINT
: linked.length < cols
? linked
: plain.length < cols
? plain
: metaParts(`${sessionId.slice(0, 20)}…`).join(' · ')
: `${plain.slice(0, Math.max(0, cols - 2))}…`
Comment on lines +1495 to +1501

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The armed branch bypasses the width fit every other branch applies: CTRL_C_QUIT_HINT is 26 columns, so on a terminal 26 columns or narrower it wraps to two rows while the footer math budgets the meta line at exactly one (const fixed = 1 /* meta line */, line 831) — the over-tall frame scrolls Ink's render region and smears stale rows, the failure the comment above warns about. Fit the hint like the other branches.

Reproduce by narrowing the terminal (or a tmux split) to <=26 columns and pressing ctrl+c: metaLine is emitted through a plain with no wrap="truncate" (line 1673), so Ink wraps it onto a second row that the viewBudget/padRows computation never reserved.

Suggested change
const metaLine = ctrlCArmed
? CTRL_C_QUIT_HINT
: linked.length < cols
? linked
: plain.length < cols
? plain
: metaParts(`${sessionId.slice(0, 20)}…`).join(' · ')
: `${plain.slice(0, Math.max(0, cols - 2))}…`
const metaLine = ctrlCArmed
? CTRL_C_QUIT_HINT.length < cols
? CTRL_C_QUIT_HINT
: `${CTRL_C_QUIT_HINT.slice(0, Math.max(0, cols - 2))}…`
: linked.length < cols
? linked
: plain.length < cols
? plain
: `${plain.slice(0, Math.max(0, cols - 2))}…`

return (
// Hosted panes pin BOTH dimensions: without the width the root sizes to
// its widest child (the unwrapped meta line) and smears rows across the
Expand Down
13 changes: 11 additions & 2 deletions src/ui/ctrlC.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useApp, useInput } from 'ink'

// ctrl+c, everywhere in the UI: the first press interrupts (whatever the pane
Expand All @@ -10,10 +10,19 @@ import { useApp, useInput } from 'ink'
// Every pane that owns the keyboard mounts this, and only the pane with focus
// is active — so the armed flag is per-pane, and one press can't arm a handler
// that a later press won't reach. Returns whether the quit is armed, for the
// pane to prompt with.
// pane to prompt with. The arm expires on its own after a few seconds, so the
// prompt never lingers and a much-later ctrl+c interrupts again instead of
// quitting.
const ARM_MS = 3000

export function useCtrlCQuit(active: boolean, onInterrupt?: () => void): boolean {
const { exit } = useApp()
const [armed, setArmed] = useState(false)
useEffect(() => {
if (!armed) return
const t = setTimeout(() => setArmed(false), ARM_MS)
return () => clearTimeout(t)
}, [armed])
useInput(
(ch, key) => {
if (key.ctrl && ch === 'c') {
Expand Down