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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run build
- name: Install Chromium for rendered-text tests
run: bun x playwright install chromium
- run: bun run test

browser:
Expand Down
2 changes: 1 addition & 1 deletion docs/ci-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

`.github/workflows/ci.yml` reports `check` as the aggregate gate for three parallel macOS lanes:

- `frontend`: frozen Bun install, frontend lint, typecheck/build, and Vitest.
- `frontend`: frozen Bun install, frontend lint, typecheck/build, Chromium install, and Vitest. The rendered transcript-search projection/range tests launch headless Chromium; local `bun run test` also requires `bun x playwright install chromium` once.
- `browser`: frozen Bun install, Chromium/WebKit install, isolated e2e bundle, functional tests in both browsers.
- `native`: frozen Bun install, stable Rust, formatting, Vite bundle, Clippy (all targets, warnings denied), and Cargo tests. Tauri's `generate_context!` needs real `dist` assets even during checks/tests; Bun also runs the Pi stdio adapter in native tests.

Expand Down
43 changes: 33 additions & 10 deletions docs/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ safety rules above. State pairs share one binding.
| Composer | Queue follow-up | `⌘Enter` in composer |
| Composer | Stop Pi | `⌘.` |
| Recovery | Review unsent messages | `⌘⇧D` |
| Transcript | Find in transcript | `⌘F` |
| Transcript | Load earlier messages | `⌘⇧B` |
| Remote | Reconnect active session | `⌘⇧C` |
| Admin | Open issue reporter | `⌘⇧I` |
Expand All @@ -101,23 +102,45 @@ only within their sublists; individual rows are not root commands.
These remain scoped to their existing surface and are excluded from the global
palette unless their launcher appears above.

| Surface | Target bindings |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Rename | Enter or `⌘Enter` commits; Escape cancels. Opening the palette does not blur-commit. |
| Menus and context menus | Arrows, Enter, and their existing typeahead; Escape dismisses that menu. |
| Remote project dialog | Enter/`⌘Enter` submits where applicable; `⌘↑` goes to parent; Escape closes only when dismissible. |
| Pi prompts | Existing arrows/Enter selection; `⌘Enter` primary submit; `⌘⇧Enter` negative confirmation; Escape cancels only that prompt when eligible. |
| Model and effort lists | Existing list navigation and Enter selection; Escape closes the current list. |
| Queue and unsent review | Escape dismisses the focused status/review; `⌘Enter` restores a focused draft when eligible. |
| Feedback, quit, and update UI | `⌘Enter` activates the eligible primary action; `⌘⇧Enter` skips an update; Escape closes only a dismissible surface. |
| Issue reporter | `⌘⇧S` toggles Include Current Session within the report; `⌘Enter` submits; Escape retains unsent text. |
| Surface | Target bindings |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Transcript Find | Enter / Shift+Enter selects next / previous occurrence and wraps while Find has focus; Escape closes only when Find is the topmost eligible surface. |
| Rename | Enter or `⌘Enter` commits; Escape cancels. Opening the palette does not blur-commit. |
| Menus and context menus | Arrows, Enter, and their existing typeahead; Escape dismisses that menu. |
| Remote project dialog | Enter/`⌘Enter` submits where applicable; `⌘↑` goes to parent; Escape closes only when dismissible. |
| Pi prompts | Existing arrows/Enter selection; `⌘Enter` primary submit; `⌘⇧Enter` negative confirmation; Escape cancels only that prompt when eligible. |
| Model and effort lists | Existing list navigation and Enter selection; Escape closes the current list. |
| Queue and unsent review | Escape dismisses the focused status/review; `⌘Enter` restores a focused draft when eligible. |
| Feedback, quit, and update UI | `⌘Enter` activates the eligible primary action; `⌘⇧Enter` skips an update; Escape closes only a dismissible surface. |
| Issue reporter | `⌘⇧S` toggles Include Current Session within the report; `⌘Enter` submits; Escape retains unsent text. |

An inline Pi prompt blocks global palette and shortcut actions. Explicit sidebar
row actions to mark a session read/unread or archive an eligible session remain
available, including for another project; other blocking dialogs and workspace
lockouts still apply. Escape from a row menu dismisses only the menu, not the
prompt.

## Transcript Find

**Find in Transcript** opens a floating bar for the active transcript. Repeating
`⌘F` focuses and selects the retained query; it never toggles the bar closed.
On macOS this is Command-F, not literal Control-F; elsewhere it is Control-F.
The command is unavailable on home, during initial session loading, or behind a
rename, blocking dialog, fullscreen viewer, or inline Pi prompt.

Find matches literal, case-insensitive occurrences in readable user and agent
text, including formatted prose and code. It searches all **loaded** messages,
including offscreen virtualized rows, not tool output, thinking, skill instructions,
drafts, titles, image/diagram internals, or hidden Markdown markup. It does not
fetch compacted history. “Loaded messages only” identifies an earlier-history
boundary; explicitly loading earlier messages adds them to the search.

Queries, open state, and active occurrences stay local to each live session and
are not persisted or recorded in telemetry. Query edits select the first result;
streaming refreshes counts without jumping. Closing removes highlights and keeps
the reader's current position and query. The palette can open over Find; its first
Escape restores Find rather than dismissing both surfaces.

## Discovery and tooltip policy

Shortcut hints use the same formatter as the palette and accurately identify
Expand Down
140 changes: 139 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@
@new-session="handleNewSession"
/>

<TranscriptSearch
v-if="find.current.value.open && !sessionLoading && !sessionIsEmpty"
ref="transcriptSearch"
:query="find.current.value.query"
:total="find.current.value.matches.length"
:current="findOrdinal"
:busy="find.current.value.busy"
:pending="find.current.value.pending"
:loaded-only="findLoadedOnly"
@query="updateFindQuery"
@navigate="navigateFind"
@close="closeFind"
/>

<div
v-if="sessionLoading"
class="session-loading"
Expand All @@ -150,6 +164,9 @@
:key="state.activeControllerKey"
ref="transcriptView"
:messages="messages"
:search-open="find.current.value.open"
:search-matches="find.current.value.matches"
:active-search-match="find.current.value.activeMatch ?? undefined"
:compacting="compacting"
:show-working-indicator="showWorkingIndicator"
:working-label="stopping ? 'Stopping' : 'Working'"
Expand Down Expand Up @@ -298,6 +315,7 @@ import QuitConfirmation from './components/QuitConfirmation.vue';
import ReconnectStatus from './components/ReconnectStatus.vue';
import RemoteDialog from './components/RemoteDialog.vue';
import SessionHeader from './components/SessionHeader.vue';
import TranscriptSearch from './components/TranscriptSearch.vue';
import TranscriptView from './components/TranscriptView.vue';
import UiButton from './components/ui/UiButton.vue';
import UiConfirmation from './components/ui/UiConfirmation.vue';
Expand All @@ -308,6 +326,7 @@ import UiSpinner from './components/ui/UiSpinner.vue';
import UiTooltip from './components/ui/UiTooltip.vue';
import { type ProjectSummary, type SessionSummary } from './composables/state';
import useTau from './composables/useTau';
import { useTranscriptFind } from './composables/useTranscriptFind';
import { createAdminCodeMatcher } from './lib/admin-code';
import { adminMode, toggleAdminMode } from './lib/admin-mode';
import {
Expand Down Expand Up @@ -348,6 +367,7 @@ interface QuitRequest {
}

const transcriptView = ref<InstanceType<typeof TranscriptView>>();
const transcriptSearch = ref<InstanceType<typeof TranscriptSearch>>();
const projectSidebar = ref<InstanceType<typeof ProjectSidebar>>();
const sessionHeader = ref<InstanceType<typeof SessionHeader>>();
const composerBar = ref<InstanceType<typeof ComposerBar>>();
Expand Down Expand Up @@ -523,6 +543,100 @@ const commandSession = (ctx: CommandContext): SessionSummary | undefined => {
project.sessions.find((s) => s.id === ctx.sessionId)
);
};
const find = useTranscriptFind({
activeController,
controllers: () => state.controllers,
messages,
});
const findOrigins = new Map<string, HTMLElement>();
const findOrdinal = computed(() => {
const current = find.current.value;
return current.activeMatch
? current.matches.indexOf(current.activeMatch) + 1
: 0;
});
const findLoadedOnly = computed(() =>
messages.value.some(
(entry) => entry.kind === 'compaction' && entry.historyAvailable,
),
);
watch(
() =>
state.controllers
.filter((controller) => !controller.disposed)
.map((controller) => controller.key),
(keys) => {
for (const key of findOrigins.keys())
if (!keys.includes(key)) findOrigins.delete(key);
},
);
watch(
() => state.activeControllerKey,
() => transcriptView.value?.cancelSearchNavigation(),
{ flush: 'sync' },
);
watch(find.revealRequest, (request) => {
if (
!request ||
request.key !== state.activeControllerKey ||
!find.current.value.open
)
return;
void nextTick(() => {
if (
request.key === state.activeControllerKey &&
find.revealRequest.value === request &&
find.current.value.open
)
void transcriptView.value?.revealSearchMatch(request.match);
});
});
function openFind(): void {
const origin =
document.activeElement instanceof HTMLElement &&
!document.activeElement.closest('.command-palette')
? document.activeElement
: paletteOrigin.value;
if (!find.current.value.open && origin && origin !== document.body)
findOrigins.set(state.activeControllerKey, origin);
find.open();
void nextTick(() => {
if (!context().blocked && !paletteOpen.value)
transcriptSearch.value?.focus(true);
});
}
function updateFindQuery(query: string): void {
transcriptView.value?.cancelSearchNavigation();
find.setQuery(query);
}
function navigateFind(direction: number): void {
if (context().blocked || find.current.value.pending) return;
find.navigate(direction);
transcriptSearch.value?.focus();
}
function closeFind(): void {
if (context().blocked || paletteOpen.value) return;
const key = state.activeControllerKey;
transcriptView.value?.cancelSearchNavigation();
find.close();
void nextTick(() => {
if (
key !== state.activeControllerKey ||
context().blocked ||
paletteOpen.value
)
return;
const origin = findOrigins.get(key);
if (
origin?.isConnected &&
!origin.closest('.transcript-search') &&
!origin.matches(':disabled')
)
origin.focus({ preventScroll: true });
else composerBar.value?.focus();
});
}

const commands = new CommandRegistry<CommandContext>();
function controlContext(id: string, target?: AppCommandTarget): CommandContext {
return {
Expand Down Expand Up @@ -826,6 +940,18 @@ register('focus.sidebar', 'Focus Sidebar', 'Mod+Shift+L', safe, () => {
if (document.activeElement === document.body)
projectSidebar.value.openProjectButton?.focus({ preventScroll: true });
});
register(
'transcript.find',
'Find in Transcript',
'Mod+F',
(ctx) =>
safe(ctx) &&
!sessionLoading.value &&
Boolean(
activeController.value && transcriptView.value && messages.value.length,
),
openFind,
);
register(
'focus.transcript',
'Focus Transcript',
Expand Down Expand Up @@ -1147,7 +1273,7 @@ async function selectPaletteRow(id: string): Promise<void> {
}
const result = await commands.dispatch(id, ctx);
if (result.status !== 'executed' && !nested) paletteOpen.value = true;
else if (!nested && id !== 'queue.review') {
else if (!nested && !['queue.review', 'transcript.find'].includes(id)) {
if (['session.new', 'session.next', 'session.previous'].includes(id))
focusAfterPaletteNavigation();
else if (!context().blocked) restorePaletteFocus();
Expand Down Expand Up @@ -1796,6 +1922,17 @@ function handleDocumentKeydown(event: KeyboardEvent): void {
return;
}
if (paletteOpen.value) return;
if (
event.key === 'Escape' &&
!event.isComposing &&
find.current.value.open &&
!context().blocked
) {
event.preventDefault();
event.stopPropagation();
closeFind();
return;
}
if (
!event.repeat &&
!event.isComposing &&
Expand Down Expand Up @@ -2021,6 +2158,7 @@ function isTitlebarControl(target: EventTarget | null): boolean {

.session-pane {
display: grid;
position: relative;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
Expand Down
9 changes: 6 additions & 3 deletions src/components/ComposerBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
ref="composer"
class="composer"
:class="{ disabled: !canDraft }"
@keydown.escape.prevent.stop="dismissCommandMenu"
@keydown.escape="dismissCommandMenu"
>
<CommandMenu
v-if="commandMenuActive"
Expand Down Expand Up @@ -392,8 +392,11 @@ function send(intent: 'steer' | 'followUp' = 'steer'): void {
emit('send', intent);
}

function dismissCommandMenu(): void {
if (commandMenuActive.value) commandMenuDismissed.value = true;
function dismissCommandMenu(event: KeyboardEvent): void {
if (!commandMenuActive.value || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
commandMenuDismissed.value = true;
}

function handleModelChange(value: string): void {
Expand Down
Loading
Loading