diff --git a/CHANGELOG.md b/CHANGELOG.md index aad06779e..1cfb1776d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The agent can wait for its browser page to change instead of reading it again in a loop. `browser_info` takes `wait_for` (text that should appear) and `wait_gone` (text that should disappear), matched anywhere on the page, and answers as soon as the page shows it, or after 20 seconds with the page as it stands and a line saying the wait timed out. - The agent hears when the page in its session's browser breaks. Each page it reads, clicks or presses a key on answers with the console errors since its last look, once each: `console.error` calls, uncaught exceptions and resources that failed to load, cut to one short line, the first five and how many more. Warnings and other messages stay out. - An agent can close a project, with `close_project` or `lich close-project`: the tab leaves the window and its sessions stop, kept for a reopen as when you close the tab. The project the agent runs in and the home project are refused. +- The agent and you answer the page's JavaScript dialogs in a session's browser. An alert, a confirm, a prompt or a "Leave this page?" the page opens shows as a card over the top of the page in the session's browser tab, naming the site that opened it, with its buttons and, for a prompt, a field. You answer it there, or the agent does with `browser_dialog`, and its cursor presses the card's button. The agent's click that opened a dialog answers at once with the dialog in it, and the agent hears of one you answered. Before, a button that showed an alert froze the page: the agent's click and every read after it waited thirty seconds and failed. ### Changed - The window holds less memory with many sessions open. A terminal off screen lets go of the pixels it drew, a few megabytes each, and redraws them in one frame when it comes back. +- The browser tools answer an agent in the session's prompt language: the legend of the element list, the note that more elements wait below, the filled and checked marks, a download still running, a wait that timed out, the count of further console errors, and what an open dialog asks of it. The line tags (`url:`, `text:`, `downloaded:`, `console error:`, `dialog open:` and the rest) stay as they are in every language. ### Fixed diff --git a/api/browser.go b/api/browser.go index 1aa965ba8..bccc98a97 100644 --- a/api/browser.go +++ b/api/browser.go @@ -20,6 +20,9 @@ var ( BrowserScroll = define[BrowserScrollOptions, None]("browser.Scroll") BrowserList = define[SessionIDOptions, []BrowserHandle]("browser.List") BrowserClose = define[SessionIDOptions, None]("browser.Close") + // BrowserAnswerDialog answers the JavaScript dialog the page holds open, + // and answers with the page as it stands after it. + BrowserAnswerDialog = define[BrowserDialogOptions, BrowserPageInfo]("browser.AnswerDialog") // The window's side: a pane that shows a session's page and passes the // person's input to it, beside the agent driving the same page. @@ -31,6 +34,9 @@ var ( BrowserKey = define[BrowserKeyOptions, None]("browser.Key") BrowserText = define[BrowserTextOptions, None]("browser.Text") BrowserNav = define[BrowserNavOptions, None]("browser.Nav") + // BrowserAnswer is the person answering the page's open dialog in the + // pane, the window's side of BrowserAnswerDialog. + BrowserAnswer = define[BrowserDialogOptions, None]("browser.Answer") // BrowserSelection is the text selected in the page, which the pane // copies to the system clipboard: the headless browser's own clipboard // is not the person's. @@ -89,11 +95,42 @@ type BrowserState struct { ByAgent bool `json:"byAgent"` URL string `json:"url"` Title string `json:"title"` + // Dialog is the JavaScript dialog the page holds open until the person + // or the agent answers it; nil when none is. + Dialog *BrowserDialog `json:"dialog,omitempty"` +} + +// BrowserDialog is a JavaScript dialog a session's page opened. Type is +// Chrome's: alert, confirm, prompt or beforeunload. Default is a prompt's +// default text; URL is the page that opened it. Answer, on one the person +// answered, is DialogAccepted or DialogDismissed. +type BrowserDialog struct { + Type string `json:"type"` + Message string `json:"message"` + Default string `json:"default,omitempty"` + URL string `json:"url"` + Answer string `json:"answer,omitempty"` +} + +// Dialog answers, as a page answer reports one the person gave. +const ( + DialogAccepted = "accepted" + DialogDismissed = "dismissed" +) + +// BrowserDialogOptions answers session ID's open dialog: Accept is OK (Leave +// on a beforeunload), false is Cancel. Text is what a prompt returns on OK; +// nil keeps its default text. +type BrowserDialogOptions struct { + ID string `json:"id"` + Accept bool `json:"accept"` + Text *string `json:"text,omitempty"` } // BrowserAgentAct is one agent action on its page. Action is click, type, // press or scroll at a point; back, forward, reload or navigate, which the -// pane's toolbar does; or "" for one that changes nothing on screen (reading +// pane's toolbar does; accept or dismiss, the buttons of the pane's dialog +// card; or "" for one that changes nothing on screen (reading // the page, a screenshot). X and Y are where it acted, in CSS pixels, when it // acted at a point; DY is a scroll's distance, positive down; URL is where a // navigate went. @@ -274,6 +311,13 @@ type BrowserPageInfo struct { // counts those past the cap. ConsoleErrors []string `json:"consoleErrors,omitempty"` ConsoleErrorsMore int `json:"consoleErrorsMore,omitempty"` + // Dialog is the JavaScript dialog the page holds open: until it is + // answered (BrowserAnswerDialog) the page runs no script, so the rest of the + // answer is the page as it stood when the dialog opened. + Dialog *BrowserDialog `json:"dialog,omitempty"` + // Dialogs are the ones the person answered in the pane since the agent's + // last page answer. + Dialogs []BrowserDialog `json:"dialogs,omitempty"` } // BrowserPointOptions is a point of session ID's page, in CSS pixels. diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index 5da8a1b07..0f54917ca 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -3,6 +3,8 @@ agentplugin.Status(None) []PluginStatus agentplugin.Update(PluginProviderOptions) None appupdate.Apply(None) None appupdate.Status(None) AppUpdateStatus +browser.Answer(BrowserDialogOptions) None +browser.AnswerDialog(BrowserDialogOptions) BrowserPageInfo browser.Attach(BrowserViewOptions) BrowserHandle browser.Back(SessionIDOptions) None browser.Click(BrowserClickOptions) BrowserPageInfo @@ -311,6 +313,26 @@ AppUpdateStatus { installCommand string } +BrowserDialogOptions { + id string + accept bool + text,omitempty *string +} + +BrowserPageInfo { + url string + title string + text string + interactive []BrowserInteractive + truncated bool + downloads,omitempty []BrowserDownload + waitTimedOut,omitempty bool + consoleErrors,omitempty []string + consoleErrorsMore,omitempty int + dialog,omitempty *BrowserDialog + dialogs,omitempty []BrowserDialog +} + BrowserViewOptions { id string w int @@ -336,18 +358,6 @@ BrowserClickOptions { ...BrowserTarget } -BrowserPageInfo { - url string - title string - text string - interactive []BrowserInteractive - truncated bool - downloads,omitempty []BrowserDownload - waitTimedOut,omitempty bool - consoleErrors,omitempty []string - consoleErrorsMore,omitempty int -} - BrowserPointOptions { id string x float64 @@ -428,6 +438,7 @@ BrowserState { byAgent bool url string title string + dialog,omitempty *BrowserDialog } BrowserScreenshotOptions { @@ -1761,13 +1772,6 @@ SessionExit { code,omitempty *int } -BrowserTarget { - index,omitempty *int - selector,omitempty string - x,omitempty *float64 - y,omitempty *float64 -} - BrowserInteractive { index int tag string @@ -1780,6 +1784,21 @@ BrowserInteractive { checked,omitempty *bool } +BrowserDialog { + type string + message string + default,omitempty string + url string + answer,omitempty string +} + +BrowserTarget { + index,omitempty *int + selector,omitempty string + x,omitempty *float64 + y,omitempty *float64 +} + ProfileUpdate { name,omitempty string color,omitempty *string diff --git a/docs/browser-tab.md b/docs/browser-tab.md index e41a16b80..8656e59e9 100644 --- a/docs/browser-tab.md +++ b/docs/browser-tab.md @@ -72,8 +72,38 @@ where it was thrown, or an error Chrome logs itself, such as a resource that failed to load. Each is one line cut to 200 characters; past the first five, a `(N more console errors)` line counts the rest. Warnings, info and log messages are left out. The text is what the page logs, so a page that logs a -form's value hands it to the agent; lich reads nothing else. These lines are -English, outside the prompt-language catalogs, like the rest of the page answer. +form's value hands it to the agent; lich reads nothing else. + +## Dialogs + +An `alert`, `confirm`, `prompt` or `beforeunload` the page opens stays open +until someone answers it, and until then the page runs no script and takes no +input. lich answers no dialog on its own: + +- The pane shows it as a card over the top of the page, naming the site that + opened it, with OK and Cancel (Leave and Stay for a `beforeunload`) and a + field for a `prompt`. The person answers there. +- The agent's call that opened it answers at once, rather than hanging, with + the page as it stood and a `dialog open:` line; any other call on that page + fails with a message naming the dialog. `browser_dialog` (`lich browser + dialog accept|dismiss`) answers it, and the pane plays the agent's cursor + pressing the card's button. `text` is what a `prompt` returns on OK; left + out, it returns its default text. +- Whichever answers first wins. A dialog the person answered reaches the + agent's next page answer as a `dialog answered:` line. + +Accepting a `beforeunload` less than a second after it opened waits out the +rest of that second: Chrome aborts the navigation it held when it is accepted +sooner, and reloads the page, which asks again. + +## Languages of the page answer + +The line tags of a page answer (`url:`, `title:`, `wait:`, `text:`, +`interactive`, `downloaded:`, `downloading:`, `console error:`, `dialog +open:`, `dialog answered:`) stay English: the tool descriptions name them. The +prose around them, the element list's legend, the filled and checked marks, +the note that more elements wait below, and what a held dialog asks of the +agent, follows the session's prompt language. ## Downloads @@ -104,5 +134,5 @@ window. Claude Code, Codex and opencode receive the tools at spawn. Antigravity, oh-my-pi, Crush, Cursor CLI and Kiro receive them from the lich MCP server the -plugin registers. Downloads reach all eight the same way: in the answer of the -same tools. +plugin registers. Downloads and dialogs reach all eight the same way: in the +answer of the same tools, and `browser_dialog` for all of them. diff --git a/docs/ceilings.md b/docs/ceilings.md index a5780d188..593ad58a5 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -1012,6 +1012,18 @@ work when nobody knows it and that the call site never shows. The mechanism and pane is not held. A download still running after ten seconds is reported in progress and named again when it ends. No tool lists past downloads or cancels one, nothing caps their size, and closing the browser cancels one still running. +- **A dialog a session's page opens holds the page until someone answers it** (`internal/browser/dialogs.go`, + `chrome_dialog.go`, `docs/browser-tab.md`): nothing answers it on its own, so an agent that never calls + `browser_dialog` and a person who never looks at the pane leave the page frozen, every other agent call failing + with the dialog's name, until the browser is closed. The agent's click that opened it answers with the page as it + stood, not as the dialog's answer left it. A dialog the page opens on a timer interrupts whichever call is + running. Accepting a `beforeunload` less than a second after it opened is held to that second: accepted sooner, + Chrome aborts the navigation it held and reloads the page, which asks again (3 of 3 at 300 ms, 3 of 6 at 400 ms, + none of 30 from 500 ms; Chrome for Testing 155 and Helium 0.19.2.1). The agent hears of a dialog the person + answered, at most twenty between two page answers, but not of one the agent itself answered. A held page cannot lay + out, so a pane opened or resized meanwhile shows it at the size it had, and lays it out at the pane's once the + dialog closes; a screenshot fails until then. + A window opened with Open in a window was not tried with a dialog. ## Languages diff --git a/internal/browser/actions.go b/internal/browser/actions.go index 481913401..ddb089774 100644 --- a/internal/browser/actions.go +++ b/internal/browser/actions.go @@ -70,13 +70,13 @@ func (s *Service) Info(id string) (PageInfo, error) { return e }) if err != nil { - s.drop(sess) - return PageInfo{}, err + return PageInfo{}, s.failed(sess, err) } s.remember(sess, info.URL, info.Title) s.agentActed(sess, api.BrowserAgentAct{}) info.Downloads = s.untold(sess) info.ConsoleErrors, info.ConsoleErrorsMore = s.consoleUntold(sess) + info.Dialogs = s.answeredUntold(sess) return info, nil } @@ -96,8 +96,7 @@ func (s *Service) Click(id string, t Target) error { return e }) if err != nil { - s.drop(sess) - return err + return s.failed(sess, err) } s.touch(sess) s.agentActed(sess, api.BrowserAgentAct{Action: "click", X: &x, Y: &y}) @@ -165,8 +164,7 @@ func (s *Service) Screenshot(id, dest string) (string, error) { return sess.page.Screenshot(ctx, dest) }) if err != nil { - s.drop(sess) - return "", err + return "", s.failed(sess, err) } s.touch(sess) s.agentActed(sess, api.BrowserAgentAct{}) @@ -198,8 +196,7 @@ func (s *Service) goTo(sess *session, u string) error { download, err = s.navigate(ctx, sess, u) return err }); err != nil { - s.drop(sess) - return err + return s.failed(sess, err) } if download { s.touch(sess) @@ -273,8 +270,7 @@ func (s *Service) drive(id string, fn func(Page, context.Context) error) (*sessi } err = runPage(func(ctx context.Context) error { return fn(sess.page, ctx) }) if err != nil { - s.drop(sess) - return nil, err + return nil, s.failed(sess, err) } s.touch(sess) return sess, nil diff --git a/internal/browser/browser.go b/internal/browser/browser.go index 02da3b106..0a38d7925 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -12,6 +12,7 @@ import ( "time" "github.com/chromedp/cdproto/network" + "github.com/lichdotdev/lich/api" ) // idleAfter is how long a headless browser no pane shows sits unused before @@ -81,8 +82,13 @@ type session struct { // first consoleErrorMax of them; consoleMore counts the rest. consoleErrors []string consoleMore int - page Page - lastUsed time.Time + // dialog is the JavaScript dialog the page holds open; answered are the + // ones the person answered since the agent's last page answer. + dialog *api.BrowserDialog + dialogAt time.Time + answered []api.BrowserDialog + page Page + lastUsed time.Time } // New starts a service that launches the system Chromium (chromium.FindBrowser) @@ -152,8 +158,7 @@ func (s *Service) OpenVisible(owner string) (Handle, error) { return s.promote(sess) } if err := runPage(sess.page.Focus); err != nil { - s.drop(sess) - return Handle{}, err + return Handle{}, s.failed(sess, err) } s.touch(sess) return s.handleOf(sess), nil @@ -170,8 +175,7 @@ func (s *Service) promote(sess *session) (Handle, error) { cookies, err = sess.page.Cookies(ctx) return err }); err != nil { - s.drop(sess) - return Handle{}, err + return Handle{}, s.failed(sess, err) } s.mu.Lock() s.detachLocked(sess) @@ -273,7 +277,7 @@ func (s *Service) start(spec launchSpec) (Handle, error) { id: newID(), owner: spec.owner, dir: spec.dir, url: spec.url, headed: spec.headed, byAgent: spec.byAgent, page: page, lastUsed: s.now(), } - page.Listen(s.frameSink(sess), s.infoSink(sess), s.downloadSink(sess), s.consoleSink(sess)) + page.Listen(s.frameSink(sess), s.infoSink(sess), s.downloadSink(sess), s.consoleSink(sess), s.dialogSink(sess)) if err := runPage(func(ctx context.Context) error { if len(spec.cookies) > 0 { if err := page.SetCookies(ctx, spec.cookies); err != nil { diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index e2a05d494..4218a6134 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -85,6 +85,8 @@ type chromePage struct { listen listeners // dl names the files the page downloads; nil when downloads are off. dl *downloads + // gate is held while the page has a JavaScript dialog open. + gate dialogGate } // downloadFolder is downloadDir, which the live tests point at a folder of @@ -108,7 +110,7 @@ func (p *chromePage) allowDownloads(profile string) error { return err } c := chromedp.FromContext(p.ctx) - err = p.run(nil, chromedp.ActionFunc(func(ctx context.Context) error { + err = p.runFree(nil, chromedp.ActionFunc(func(ctx context.Context) error { return browser.SetDownloadBehavior(browser.SetDownloadBehaviorBehaviorAllowAndName). WithDownloadPath(staging). WithEventsEnabled(true). @@ -136,10 +138,33 @@ func (p *chromePage) Close() error { return chromedp.Cancel(ctx) } -// run drives CDP on the allocator context chromedp attached, applying wait's -// deadline. Service callers pass a timeout derived from Background; chromedp.Run -// on that context is "invalid context" and the headed window is then closed. +// run is runFree for an action the page's script or input takes part in, +// which waits on an open JavaScript dialog: it fails with errDialogOpen at +// once while one is open, and as soon as one opens while it runs. func (p *chromePage) run(wait context.Context, actions ...chromedp.Action) error { + held := p.gate.held() + select { + case <-held: + return errDialogOpen + default: + } + ctx, cancel := withPageDeadline(p.ctx, wait) + defer cancel() + done := make(chan error, 1) + go func() { done <- chromedp.Run(ctx, actions...) }() + select { + case err := <-done: + return err + case <-held: + return errDialogOpen + } +} + +// runFree drives CDP on the allocator context chromedp attached, applying +// wait's deadline. Service callers pass a timeout derived from Background; +// chromedp.Run on that context is "invalid context" and the headed window is +// then closed. +func (p *chromePage) runFree(wait context.Context, actions ...chromedp.Action) error { ctx, cancel := withPageDeadline(p.ctx, wait) defer cancel() return chromedp.Run(ctx, actions...) @@ -333,12 +358,12 @@ func (p *chromePage) Scroll(ctx context.Context, at *point, dy int) (x, y float6 // Focus raises the window. window.focus() from the page does not: the OS // only lets the browser itself bring a window forward. func (p *chromePage) Focus(ctx context.Context) error { - return p.run(ctx, page.BringToFront()) + return p.runFree(ctx, page.BringToFront()) } func (p *chromePage) Cookies(ctx context.Context) ([]*network.CookieParam, error) { var cookies []*network.Cookie - err := p.run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + err := p.runFree(ctx, chromedp.ActionFunc(func(ctx context.Context) error { var err error cookies, err = storage.GetCookies().Do(ctx) return err @@ -354,7 +379,7 @@ func (p *chromePage) Cookies(ctx context.Context) ([]*network.CookieParam, error } func (p *chromePage) SetCookies(ctx context.Context, cookies []*network.CookieParam) error { - return p.run(ctx, storage.SetCookies(cookies)) + return p.runFree(ctx, storage.SetCookies(cookies)) } // cookieParam turns a cookie read back into one Chrome accepts. A session diff --git a/internal/browser/chrome_dialog.go b/internal/browser/chrome_dialog.go new file mode 100644 index 000000000..2648d418d --- /dev/null +++ b/internal/browser/chrome_dialog.go @@ -0,0 +1,81 @@ +package browser + +import ( + "context" + "log/slog" + "sync" + + "github.com/chromedp/cdproto/page" +) + +// dialogGate is held while the page has a JavaScript dialog open. Until it is +// answered, every script call and input event waits on it: an alert held the +// agent's click, and every read after it, for the whole pageWait (Chromium +// 155, measured 2026-10-27). +type dialogGate struct { + mu sync.Mutex + // ch is closed while a dialog is open. + ch chan struct{} + isOpen bool + // view is the size a pane asked for while a dialog was open. + view *Viewport +} + +// held is closed while a dialog is open, or as soon as one opens. +func (g *dialogGate) held() <-chan struct{} { + g.mu.Lock() + defer g.mu.Unlock() + if g.ch == nil { + g.ch = make(chan struct{}) + } + return g.ch +} + +func (g *dialogGate) open() { + g.mu.Lock() + defer g.mu.Unlock() + if g.ch == nil { + g.ch = make(chan struct{}) + } + if !g.isOpen { + close(g.ch) + g.isOpen = true + } +} + +// close lets calls through again and hands back the size a pane asked for +// meanwhile, nil for none. +func (g *dialogGate) close() *Viewport { + g.mu.Lock() + defer g.mu.Unlock() + if g.isOpen { + g.ch = make(chan struct{}) + g.isOpen = false + } + v := g.view + g.view = nil + return v +} + +// deferView keeps v for when the dialog closes; false when none is open +// anymore, and v is the caller's to lay out. +func (g *dialogGate) deferView(v Viewport) bool { + g.mu.Lock() + defer g.mu.Unlock() + if !g.isOpen { + return false + } + g.view = &v + return true +} + +// layOut applies a size a pane asked for while a dialog held the page. +func (p *chromePage) layOut(v Viewport) { + if err := p.Resize(nil, v); err != nil && p.Alive() { + slog.Warn("browser: lay the page out after its dialog", "err", err) + } +} + +func (p *chromePage) AnswerDialog(ctx context.Context, accept bool, text string) error { + return p.runFree(ctx, page.HandleJavaScriptDialog(accept).WithPromptText(text)) +} diff --git a/internal/browser/chrome_live_test.go b/internal/browser/chrome_live_test.go index 8c9384364..12ad9f8b7 100644 --- a/internal/browser/chrome_live_test.go +++ b/internal/browser/chrome_live_test.go @@ -148,7 +148,7 @@ func TestChromeScreencastSendsFramesAndPageInfo(t *testing.T) { case titles <- title: default: } - }, func(string, api.BrowserDownload) {}, func(string) {}) + }, func(string, api.BrowserDownload) {}, func(string) {}, func(*api.BrowserDialog) {}) ctx, cancel := context.WithTimeout(context.Background(), pageWait) defer cancel() if err := p.Navigate(ctx, srv.URL); err != nil { @@ -225,7 +225,7 @@ func TestChromeLinkToANewTabLoadsInThePageAndKeepsItResponding(t *testing.T) { t.Cleanup(srv.Close) p := pg.(*chromePage) urls := make(chan string, 8) - p.Listen(func(string) {}, func(url, _ string) { urls <- url }, func(string, api.BrowserDownload) {}, func(string) {}) + p.Listen(func(string) {}, func(url, _ string) { urls <- url }, func(string, api.BrowserDownload) {}, func(string) {}, func(*api.BrowserDialog) {}) ctx, cancel := context.WithTimeout(context.Background(), pageWait) defer cancel() if err := p.Navigate(ctx, srv.URL+"/"); err != nil { diff --git a/internal/browser/chrome_view.go b/internal/browser/chrome_view.go index 1277132fe..486bd5e84 100644 --- a/internal/browser/chrome_view.go +++ b/internal/browser/chrome_view.go @@ -2,6 +2,7 @@ package browser import ( "context" + "errors" "log/slog" "math" "sync" @@ -31,7 +32,7 @@ type listeners struct { onInfo func(url, title string) } -func (p *chromePage) Listen(onFrame func(data string), onInfo func(url, title string), onDownload func(guid string, d api.BrowserDownload), onConsoleError func(text string)) { +func (p *chromePage) Listen(onFrame func(data string), onInfo func(url, title string), onDownload func(guid string, d api.BrowserDownload), onConsoleError func(text string), onDialog func(*api.BrowserDialog)) { p.listen.mu.Lock() p.listen.onFrame, p.listen.onInfo = onFrame, onInfo p.listen.mu.Unlock() @@ -45,6 +46,16 @@ func (p *chromePage) Listen(onFrame func(data string), onInfo func(url, title st } case *page.EventNavigatedWithinDocument, *page.EventDomContentEventFired: go p.reportInfo() + case *page.EventJavascriptDialogOpening: + // The service holds the dialog before a call the gate releases + // asks it which one. + onDialog(&api.BrowserDialog{Type: string(e.Type), Message: e.Message, Default: e.DefaultPrompt, URL: e.URL}) + p.gate.open() + case *page.EventJavascriptDialogClosed: + if v := p.gate.close(); v != nil { + go p.layOut(*v) + } + onDialog(nil) default: if text, ok := consoleError(ev); ok { onConsoleError(text) @@ -91,7 +102,7 @@ func (p *chromePage) keepOneTab() { discover := chromedp.ActionFunc(func(ctx context.Context) error { return target.SetDiscoverTargets(true).Do(cdp.WithExecutor(ctx, c.Browser)) }) - if err := p.run(nil, discover); err != nil { + if err := p.runFree(nil, discover); err != nil { slog.Warn("browser: watch for new tabs", "err", err) return } @@ -120,10 +131,10 @@ func (p *chromePage) takeOver(id target.ID, url string) { closeTab := chromedp.ActionFunc(func(ctx context.Context) error { return target.CloseTarget(id).Do(cdp.WithExecutor(ctx, c.Browser)) }) - if err := p.run(nil, closeTab); err != nil { + if err := p.runFree(nil, closeTab); err != nil { slog.Warn("browser: close a tab the page opened", "url", url, "err", err) } - if err := p.run(nil, page.BringToFront()); err != nil { + if err := p.runFree(nil, page.BringToFront()); err != nil { slog.Warn("browser: bring the page back to the front", "err", err) } if _, err := allowURL(url); err != nil { @@ -150,7 +161,7 @@ func (p *chromePage) reportInfo() { Title string `json:"title"` } if err := p.run(nil, chromedp.Evaluate(`({url: location.href, title: document.title})`, &info)); err != nil { - if p.Alive() { + if p.Alive() && !errors.Is(err, errDialogOpen) { slog.Warn("browser: read the page's URL and title", "err", err) } return @@ -171,7 +182,7 @@ func (p *chromePage) deliver(frame *page.EventScreencastFrame) { ack := chromedp.ActionFunc(func(ctx context.Context) error { return page.ScreencastFrameAck(frame.SessionID).Do(ctx) }) - if err := p.run(nil, ack); err != nil && p.Alive() { + if err := p.runFree(nil, ack); err != nil && p.Alive() { slog.Warn("browser: ack a screencast frame", "err", err) } } @@ -179,14 +190,14 @@ func (p *chromePage) deliver(frame *page.EventScreencastFrame) { // Screencast starts the frames at v, or restarts them there: Chrome keeps // the size a screencast started with. func (p *chromePage) Screencast(ctx context.Context, v Viewport) error { - if err := p.run(ctx, page.StopScreencast()); err != nil { + if err := p.runFree(ctx, page.StopScreencast()); err != nil { return err } if err := p.Resize(ctx, v); err != nil { return err } w, h := v.devicePixels() - return p.run(ctx, page.StartScreencast(). + return p.runFree(ctx, page.StartScreencast(). WithFormat(page.ScreencastFormatJpeg). WithQuality(screencastQuality). WithMaxWidth(w). @@ -194,12 +205,23 @@ func (p *chromePage) Screencast(ctx context.Context, v Viewport) error { } func (p *chromePage) StopScreencast(ctx context.Context) error { - return p.run(ctx, page.StopScreencast()) + return p.runFree(ctx, page.StopScreencast()) } // Resize lays the page out at the pane's size, as a window of that size would. +// A page holding a dialog open cannot lay out, and the call waited on it for +// the whole pageWait: a pane opened then stayed black, its screencast never +// started (measured 2026-10-28). That size is laid out once the dialog closes; +// until then the frames come at the size the page had. func (p *chromePage) Resize(ctx context.Context, v Viewport) error { - return p.run(ctx, emulation.SetDeviceMetricsOverride(int64(v.W), int64(v.H), v.Scale, false)) + err := p.run(ctx, emulation.SetDeviceMetricsOverride(int64(v.W), int64(v.H), v.Scale, false)) + if !errors.Is(err, errDialogOpen) { + return err + } + if p.gate.deferView(v) { + return nil + } + return p.Resize(ctx, v) } func (p *chromePage) Mouse(ctx context.Context, m api.BrowserMouseOptions) error { diff --git a/internal/browser/dialogs.go b/internal/browser/dialogs.go new file mode 100644 index 000000000..a940ec98c --- /dev/null +++ b/internal/browser/dialogs.go @@ -0,0 +1,172 @@ +package browser + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/chromedp/cdproto/page" + "github.com/lichdotdev/lich/api" +) + +// beforeunload is Chrome's type for the dialog that asks to leave a page. +const beforeunload = string(page.DialogTypeBeforeunload) + +// answeredMax caps the dialogs the person answered that wait for the agent's +// next page answer, as consoleErrorMax caps errors. +const answeredMax = 20 + +// beforeunloadSettle is how long after a beforeunload opens accepting it +// waits for. Accepted sooner, the navigation it held is aborted about a second +// in (net::ERR_ABORTED) and Chrome reloads the page, which asks again: 3 of 3 +// at 300 ms and 3 of 6 at 400 ms aborted, none of 30 from 500 ms on (Chrome +// for Testing 155 and Helium 0.19.2.1, measured 2026-10-28). +const beforeunloadSettle = time.Second + +var errNoDialog = errors.New("the page has no JavaScript dialog open") + +// dialogSink keeps the dialog the page holds open, nil once it closes, and +// tells the window, whose pane shows it for the person to answer. +func (s *Service) dialogSink(sess *session) func(*api.BrowserDialog) { + return func(d *api.BrowserDialog) { + s.mu.Lock() + sess.dialog = d + sess.dialogAt = time.Now() + s.mu.Unlock() + s.emitState(sess, true) + } +} + +// AnswerDialog is the agent answering the page's open dialog, which the pane +// plays as its cursor pressing the dialog's button. It answers with the page +// as it stands after. +func (s *Service) AnswerDialog(_ context.Context, opts api.BrowserDialogOptions) (api.BrowserPageInfo, error) { + sess, d, err := s.openDialog(opts.ID) + if err != nil { + return api.BrowserPageInfo{}, err + } + action := "dismiss" + if opts.Accept { + action = "accept" + } + s.agentActed(sess, api.BrowserAgentAct{Action: action}) + if err := s.answerDialog(sess, d, opts); err != nil { + return api.BrowserPageInfo{}, err + } + return s.pageAfter(opts.ID) +} + +// Answer is the person answering the page's open dialog in the pane. The +// agent hears of it in its next page answer. A dialog the agent answered +// first leaves nothing to do, and that is no error. +func (s *Service) Answer(_ context.Context, opts api.BrowserDialogOptions) (api.None, error) { + sess, d, err := s.openDialog(opts.ID) + if errors.Is(err, errNoDialog) { + return api.None{}, nil + } + if err != nil { + return api.None{}, err + } + if err := s.answerDialog(sess, d, opts); err != nil { + return api.None{}, err + } + d.Answer = api.DialogDismissed + if opts.Accept { + d.Answer = api.DialogAccepted + } + s.mu.Lock() + if len(sess.answered) < answeredMax { + sess.answered = append(sess.answered, d) + } + s.mu.Unlock() + return api.None{}, nil +} + +func (s *Service) openDialog(id string) (*session, api.BrowserDialog, error) { + sess, err := s.live(id) + if err != nil { + return nil, api.BrowserDialog{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + if sess.dialog == nil { + return nil, api.BrowserDialog{}, errNoDialog + } + return sess, *sess.dialog, nil +} + +// settleBeforeunload holds an accept of a beforeunload until +// beforeunloadSettle after it opened. +func (s *Service) settleBeforeunload(sess *session, d api.BrowserDialog, accept bool) { + if !accept || d.Type != beforeunload { + return + } + s.mu.Lock() + at := sess.dialogAt + s.mu.Unlock() + time.Sleep(time.Until(at.Add(beforeunloadSettle))) +} + +// answerDialog answers d, a prompt with opts.Text or else its default text. +func (s *Service) answerDialog(sess *session, d api.BrowserDialog, opts api.BrowserDialogOptions) error { + text := d.Default + if opts.Text != nil { + text = *opts.Text + } + s.settleBeforeunload(sess, d, opts.Accept) + err := runPage(func(ctx context.Context) error { + return sess.page.AnswerDialog(ctx, opts.Accept, text) + }) + if err == nil { + s.touch(sess) + } + return err +} + +// failed is what a page call that failed tells its caller: that the page +// holds a dialog open, which leaves the browser as it is, or the error, after +// dropping a browser whose Chrome is gone. +func (s *Service) failed(sess *session, err error) error { + if !errors.Is(err, errDialogOpen) { + s.drop(sess) + return err + } + s.mu.Lock() + d := sess.dialog + s.mu.Unlock() + if d == nil { + return err + } + return fmt.Errorf("%w: a %s saying %q; answer it with browser_dialog first", errDialogOpen, d.Type, d.Message) +} + +// heldPage is the agent's page answer while the page holds a dialog open: +// the page as lich last saw it, and the dialog. +func (s *Service) heldPage(id string) (api.BrowserPageInfo, error) { + sess, err := s.live(id) + if err != nil { + return api.BrowserPageInfo{}, err + } + s.mu.Lock() + info := api.BrowserPageInfo{URL: sess.url, Title: sess.title} + if sess.dialog != nil { + d := *sess.dialog + info.Dialog = &d + } + s.mu.Unlock() + info.Downloads = s.untold(sess) + info.ConsoleErrors, info.ConsoleErrorsMore = s.consoleUntold(sess) + info.Dialogs = s.answeredUntold(sess) + return info, nil +} + +// answeredUntold is the dialogs the person answered that the agent has not +// heard of, which it now has. +func (s *Service) answeredUntold(sess *session) []api.BrowserDialog { + s.mu.Lock() + defer s.mu.Unlock() + out := sess.answered + sess.answered = nil + return out +} diff --git a/internal/browser/dialogs_live_test.go b/internal/browser/dialogs_live_test.go new file mode 100644 index 000000000..2ed3c67fd --- /dev/null +++ b/internal/browser/dialogs_live_test.go @@ -0,0 +1,135 @@ +package browser + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "testing" + "time" + + "github.com/chromedp/chromedp" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/chromium" +) + +// dialogPage opens one dialog per button and titles the page with what the +// dialog answered, so the test sees the page ran on past it. +const dialogPage = `Dialogs + + + +` + +func TestChromeADialogWaitsForTheAgentsAnswer(t *testing.T) { + if _, err := chromium.FindBrowser(exec.LookPath); err != nil { + t.Skip("no Chromium-family browser on this machine") + } + testDownloads(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, dialogPage) + })) + t.Cleanup(srv.Close) + s := newService(chromeLaunch, func(string, string) (string, error) { return t.TempDir(), nil }, time.Now, idleAfter) + t.Cleanup(func() { + if err := s.Cleanup(); err != nil { + t.Error(err) + } + }) + if _, err := s.Open("s", srv.URL); err != nil { + t.Fatal(err) + } + bob := "Bob" + for _, c := range []struct { + id, message, title string + answer api.BrowserDialogOptions + }{ + {"alert", "hi", "alert done", api.BrowserDialogOptions{ID: "s", Accept: true}}, + {"confirm", "Delete?", "confirm false", api.BrowserDialogOptions{ID: "s"}}, + {"prompt", "Name?", "prompt Bob", api.BrowserDialogOptions{ID: "s", Accept: true, Text: &bob}}, + } { + t.Run(c.id, func(t *testing.T) { + start := time.Now() + info, err := s.ClickAt(t.Context(), api.BrowserClickOptions{ID: "s", BrowserTarget: api.BrowserTarget{Selector: "#" + c.id}}) + if err != nil { + t.Fatalf("click after %s: %v", time.Since(start), err) + } + if since := time.Since(start); info.Dialog == nil || info.Dialog.Type != c.id || info.Dialog.Message != c.message || since > 5*time.Second { + t.Fatalf("click answered %+v after %s, want the open %s at once", info.Dialog, since, c.id) + } + after, err := s.AnswerDialog(t.Context(), c.answer) + if err != nil { + t.Fatal(err) + } + if after.Title != c.title || after.Dialog != nil { + t.Fatalf("after the answer: title %q, dialog %+v; want %q and none", after.Title, after.Dialog, c.title) + } + }) + } + t.Run("beforeunload", func(t *testing.T) { + next := srv.URL + "/next" + // The clicks above gave the page the user activation Chrome asks of a + // beforeunload dialog. + if _, err := s.Go(t.Context(), api.BrowserOpenOptions{ID: "s", URL: next}); !errors.Is(err, errDialogOpen) { + t.Fatalf("navigate: err = %v, want the beforeunload held open", err) + } + after, err := s.AnswerDialog(t.Context(), api.BrowserDialogOptions{ID: "s", Accept: true}) + if err != nil { + t.Fatal(err) + } + if after.URL != next || after.Dialog != nil { + t.Fatalf("after Leave: on %s with dialog %+v, want %s", after.URL, after.Dialog, next) + } + }) +} + +func TestChromeAPaneOpenedOnADialogShowsThePage(t *testing.T) { + p, srv := liveChrome(t) + frames := make(chan string, 8) + opened := make(chan struct{}, 1) + p.Listen(func(data string) { + select { + case frames <- data: + default: + } + }, func(string, string) {}, func(string, api.BrowserDownload) {}, func(string) {}, func(d *api.BrowserDialog) { + if d != nil { + opened <- struct{}{} + } + }) + ctx, cancel := context.WithTimeout(context.Background(), pageWait) + defer cancel() + if err := p.Navigate(ctx, srv.URL); err != nil { + t.Fatal(err) + } + if err := p.runFree(ctx, chromedp.Evaluate(`setTimeout(() => confirm('Delete?'), 0)`, nil)); err != nil { + t.Fatal(err) + } + <-opened + v := Viewport{W: 640, H: 400, Scale: 1} + start := time.Now() + if err := p.Screencast(ctx, v); err != nil { + t.Fatalf("screencast on a held page: %v after %s", err, time.Since(start)) + } + select { + case <-frames: + case <-time.After(5 * time.Second): + t.Fatal("no frame of the held page") + } + if err := p.AnswerDialog(ctx, true, ""); err != nil { + t.Fatal(err) + } + var width int + for deadline := time.Now().Add(5 * time.Second); width != v.W && time.Now().Before(deadline); { + if err := p.run(ctx, chromedp.Evaluate(`innerWidth`, &width)); err != nil && !errors.Is(err, errDialogOpen) { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + } + if width != v.W { + t.Fatalf("the page is %d wide after its dialog, want the pane's %d", width, v.W) + } +} diff --git a/internal/browser/dialogs_test.go b/internal/browser/dialogs_test.go new file mode 100644 index 000000000..42912fff8 --- /dev/null +++ b/internal/browser/dialogs_test.go @@ -0,0 +1,216 @@ +package browser + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/lichdotdev/lich/api" +) + +var confirmDelete = api.BrowserDialog{Type: "confirm", Message: "Delete?", URL: "https://example.com/"} + +func TestAClickThatOpensADialogAnswersWithTheDialogAndTellsTheWindow(t *testing.T) { + s, launch, out := viewService(t) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + p := launch.pages[0] + p.onClick = func() { p.openDialog(confirmDelete) } + info, err := s.ClickAt(t.Context(), api.BrowserClickOptions{ID: "s", BrowserTarget: api.BrowserTarget{Selector: "#del"}}) + if err != nil { + t.Fatal(err) + } + if info.Dialog == nil || *info.Dialog != confirmDelete || info.URL != "https://example.com" { + t.Fatalf("click answered %+v, want the page with the dialog it opened", info) + } + states := out.states() + if last := states[len(states)-1]; last.Dialog == nil || *last.Dialog != confirmDelete { + t.Fatalf("the window last heard %+v, want the open dialog", last) + } +} + +func TestACallOnAPageHoldingADialogSaysToAnswerIt(t *testing.T) { + s, launch, _ := viewService(t) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + p := launch.pages[0] + p.openDialog(confirmDelete) + p.shotErr = errDialogOpen + _, err := s.Screenshot("s", "") + if !errors.Is(err, errDialogOpen) || !strings.Contains(err.Error(), "browser_dialog") || !strings.Contains(err.Error(), `"Delete?"`) { + t.Fatalf("err = %v, want the dialog named and browser_dialog", err) + } + if _, err := s.live("s"); err != nil { + t.Fatalf("the browser was dropped: %v", err) + } +} + +func TestTheAgentAnswersAPromptWithItsDefaultText(t *testing.T) { + s, launch, out := viewService(t) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + p := launch.pages[0] + p.openDialog(api.BrowserDialog{Type: "prompt", Message: "Name?", Default: "Ada"}) + info, err := s.AnswerDialog(t.Context(), api.BrowserDialogOptions{ID: "s", Accept: true}) + if err != nil { + t.Fatal(err) + } + if want := []answer{{accept: true, text: "Ada"}}; !reflect.DeepEqual(p.answers, want) { + t.Fatalf("answers = %+v, want %+v", p.answers, want) + } + if info.Dialog != nil || len(info.Dialogs) != 0 { + t.Fatalf("the page after the answer still names %+v %+v", info.Dialog, info.Dialogs) + } + acts := out.named(api.BrowserAgentEvent.Name) + if len(acts) == 0 || acts[0].(api.BrowserAgentAct).Action != "accept" { + t.Fatalf("the window heard %+v, want the agent's accept", acts) + } + if last := out.states()[len(out.states())-1]; last.Dialog != nil { + t.Fatalf("the window last heard %+v, want the dialog gone", last) + } +} + +func TestTheAgentsTextAnswersAPrompt(t *testing.T) { + s, launch, _ := viewService(t) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + p := launch.pages[0] + p.openDialog(api.BrowserDialog{Type: "prompt", Message: "Name?", Default: "Ada"}) + empty := "" + if _, err := s.AnswerDialog(t.Context(), api.BrowserDialogOptions{ID: "s", Accept: true, Text: &empty}); err != nil { + t.Fatal(err) + } + if want := []answer{{accept: true, text: ""}}; !reflect.DeepEqual(p.answers, want) { + t.Fatalf("answers = %+v, want %+v", p.answers, want) + } +} + +func TestTheAgentReadsOnceTheDialogThePersonAnswered(t *testing.T) { + s, launch, out := viewService(t) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + p := launch.pages[0] + p.openDialog(confirmDelete) + if _, err := s.Answer(t.Context(), api.BrowserDialogOptions{ID: "s"}); err != nil { + t.Fatal(err) + } + if acts := out.named(api.BrowserAgentEvent.Name); len(acts) != 0 { + t.Fatalf("the person's answer played as the agent's: %+v", acts) + } + read, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"}) + if err != nil { + t.Fatal(err) + } + want := confirmDelete + want.Answer = api.DialogDismissed + if !reflect.DeepEqual(read.Dialogs, []api.BrowserDialog{want}) { + t.Fatalf("read reported %+v, want the person's Cancel", read.Dialogs) + } + again, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"}) + if err != nil { + t.Fatal(err) + } + if len(again.Dialogs) != 0 { + t.Fatalf("a second read repeated %+v", again.Dialogs) + } +} + +func TestAnsweringWithNoDialogOpenFails(t *testing.T) { + s, _, _ := viewService(t) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + if _, err := s.AnswerDialog(t.Context(), api.BrowserDialogOptions{ID: "s", Accept: true}); !errors.Is(err, errNoDialog) { + t.Fatalf("agent: err = %v, want errNoDialog", err) + } + if _, err := s.Answer(t.Context(), api.BrowserDialogOptions{ID: "s"}); err != nil { + t.Fatalf("person: err = %v, want nothing to do", err) + } +} + +func TestThePersonsInputOnAHeldPageIsNoError(t *testing.T) { + s, launch, _ := viewService(t) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + p := launch.pages[0] + p.openDialog(confirmDelete) + p.mouseErr = errDialogOpen + if _, err := s.MouseInput(t.Context(), api.BrowserMouseOptions{ID: "s", Type: "mousePressed"}); err != nil { + t.Fatalf("err = %v, want the click held off quietly", err) + } + if _, err := s.live("s"); err != nil { + t.Fatalf("the browser was dropped: %v", err) + } +} + +func TestReadKeepsTheFirstAnswersOfAPageThatAlertsInALoop(t *testing.T) { + s, launch, _ := viewService(t) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + p := launch.pages[0] + for range answeredMax + 5 { + p.openDialog(api.BrowserDialog{Type: "alert", Message: "again"}) + if _, err := s.Answer(t.Context(), api.BrowserDialogOptions{ID: "s", Accept: true}); err != nil { + t.Fatal(err) + } + } + read, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"}) + if err != nil { + t.Fatal(err) + } + if len(read.Dialogs) != answeredMax { + t.Fatalf("read reported %d answers, want %d", len(read.Dialogs), answeredMax) + } +} + +func TestTheGateIsHeldFromOpenToClose(t *testing.T) { + var g dialogGate + held := func() bool { + select { + case <-g.held(): + return true + default: + return false + } + } + waiting := g.held() + if held() { + t.Fatal("held before any dialog") + } + g.open() + g.open() + select { + case <-waiting: + default: + t.Fatal("a call waiting from before the dialog was not released") + } + g.close() + g.close() + if held() { + t.Fatal("still held after the dialog closed") + } +} + +func TestTheGateKeepsTheLastSizeAskedForWhileHeld(t *testing.T) { + var g dialogGate + if g.deferView(Viewport{W: 1}) { + t.Fatal("kept a size with no dialog open") + } + g.open() + g.deferView(Viewport{W: 1}) + g.deferView(Viewport{W: 2}) + if v := g.close(); v == nil || v.W != 2 { + t.Fatalf("close handed back %+v, want the last size", v) + } + if v := g.close(); v != nil { + t.Fatalf("a second close handed back %+v", v) + } +} diff --git a/internal/browser/fake_test.go b/internal/browser/fake_test.go index 8db01169f..ea478986c 100644 --- a/internal/browser/fake_test.go +++ b/internal/browser/fake_test.go @@ -33,6 +33,8 @@ type fakePage struct { onInfo func(url, title string) onDownload func(guid string, d api.BrowserDownload) onConsoleError func(text string) + onDialog func(*api.BrowserDialog) + answers []answer // onClick runs after a click, as what the page does in answer to it; // onNavigate as a navigation starts. onClick func() @@ -230,10 +232,28 @@ func (p *fakePage) SetCookies(_ context.Context, cookies []*network.CookieParam) return nil } -func (p *fakePage) Listen(onFrame func(string), onInfo func(url, title string), onDownload func(string, api.BrowserDownload), onConsoleError func(string)) { +func (p *fakePage) Listen(onFrame func(string), onInfo func(url, title string), onDownload func(string, api.BrowserDownload), onConsoleError func(string), onDialog func(*api.BrowserDialog)) { p.mu.Lock() defer p.mu.Unlock() - p.onFrame, p.onInfo, p.onDownload, p.onConsoleError = onFrame, onInfo, onDownload, onConsoleError + p.onFrame, p.onInfo, p.onDownload, p.onConsoleError, p.onDialog = onFrame, onInfo, onDownload, onConsoleError, onDialog +} + +// openDialog is the page opening d: reads fail as Chrome's do until it is +// answered. +func (p *fakePage) openDialog(d api.BrowserDialog) { + p.mu.Lock() + p.infoErr = errDialogOpen + p.mu.Unlock() + p.onDialog(&d) +} + +func (p *fakePage) AnswerDialog(_ context.Context, accept bool, text string) error { + p.mu.Lock() + p.answers = append(p.answers, answer{accept: accept, text: text}) + p.infoErr = nil + p.mu.Unlock() + p.onDialog(nil) + return nil } func (p *fakePage) Screencast(_ context.Context, v Viewport) error { @@ -365,3 +385,8 @@ func (p *fakePage) Locate(_ context.Context, selectors []string, _ string) ([]ap } return out, nil } + +type answer struct { + accept bool + text string +} diff --git a/internal/browser/page.go b/internal/browser/page.go index 0472d68d7..5a69d9193 100644 --- a/internal/browser/page.go +++ b/internal/browser/page.go @@ -10,6 +10,10 @@ import ( var errUnknownInput = errors.New("unknown input type") +// errDialogOpen is a page call that waits on an open JavaScript dialog: the +// page runs no script and takes no input until it is answered. +var errDialogOpen = errors.New("the page holds a JavaScript dialog open") + // Viewport is the pane a page is drawn into: W and H in CSS pixels, Scale the // device pixels per CSS pixel. type Viewport struct { @@ -77,6 +81,7 @@ type PageInfo struct { Downloads []api.BrowserDownload `json:"-"` ConsoleErrors []string `json:"-"` ConsoleErrorsMore int `json:"-"` + Dialogs []api.BrowserDialog `json:"-"` } // Page is one Chromium tab the service drives. chrome.go is the real one; @@ -108,8 +113,14 @@ type Page interface { SetCookies(ctx context.Context, cookies []*network.CookieParam) error // Listen hands the page what to call with each screencast frame (base64 // JPEG), with its URL and title whenever they change, with each step of a - // download it starts, and with each error its console shows. - Listen(onFrame func(data string), onInfo func(url, title string), onDownload func(guid string, d api.BrowserDownload), onConsoleError func(text string)) + // download it starts, with each error its console shows, and with each + // JavaScript dialog it opens, then nil once that dialog is closed. + Listen(onFrame func(data string), onInfo func(url, title string), onDownload func(guid string, d api.BrowserDownload), onConsoleError func(text string), onDialog func(*api.BrowserDialog)) + // AnswerDialog answers the open JavaScript dialog: OK with text (a + // prompt's answer) when accept, Cancel otherwise. Every other call that + // runs the page's script or input fails with errDialogOpen while one is + // open. + AnswerDialog(ctx context.Context, accept bool, text string) error Screencast(ctx context.Context, v Viewport) error StopScreencast(ctx context.Context) error Resize(ctx context.Context, v Viewport) error diff --git a/internal/browser/rpc.go b/internal/browser/rpc.go index 34109e716..a3953909f 100644 --- a/internal/browser/rpc.go +++ b/internal/browser/rpc.go @@ -2,6 +2,7 @@ package browser import ( "context" + "errors" "github.com/lichdotdev/lich/api" ) @@ -34,6 +35,9 @@ func (s *Service) Read(_ context.Context, opts api.BrowserInfoOptions) (api.Brow return api.BrowserPageInfo{}, err } info, err := s.Info(opts.ID) + if errors.Is(err, errDialogOpen) { + return s.heldPage(opts.ID) + } out := infoAPI(info) out.WaitTimedOut = timedOut return out, err @@ -42,7 +46,7 @@ func (s *Service) Read(_ context.Context, opts api.BrowserInfoOptions) (api.Brow // ClickAt clicks in this session's browser. func (s *Service) ClickAt(_ context.Context, opts api.BrowserClickOptions) (api.BrowserPageInfo, error) { if err := s.Click(opts.ID, targetOf(opts.BrowserTarget)); err != nil { - return api.BrowserPageInfo{}, err + return s.heldOr(opts.ID, err) } return s.pageAfter(opts.ID) } @@ -55,7 +59,7 @@ func (s *Service) TypeText(_ context.Context, opts api.BrowserTypeOptions) (api. // PressKey presses one named key in this session's browser. func (s *Service) PressKey(_ context.Context, opts api.BrowserPressOptions) (api.BrowserPageInfo, error) { if err := s.Press(opts.ID, opts.Key, targetOf(opts.BrowserTarget)); err != nil { - return api.BrowserPageInfo{}, err + return s.heldOr(opts.ID, err) } return s.pageAfter(opts.ID) } @@ -65,7 +69,7 @@ func (s *Service) PressKey(_ context.Context, opts api.BrowserPressOptions) (api // through, and the page may be between two documents. func (s *Service) pageAfter(id string) (api.BrowserPageInfo, error) { var info PageInfo - sess, err := s.steer(id, pageWait, func(p Page, ctx context.Context) error { + sess, err := s.onPage(id, pageWait, func(p Page, ctx context.Context) error { if err := p.Settle(ctx); err != nil { return err } @@ -74,14 +78,24 @@ func (s *Service) pageAfter(id string) (api.BrowserPageInfo, error) { return err }) if err != nil { - return api.BrowserPageInfo{}, err + return s.heldOr(id, err) } s.remember(sess, info.URL, info.Title) info.Downloads = s.downloadsAfter(sess, downloadWait) info.ConsoleErrors, info.ConsoleErrorsMore = s.consoleUntold(sess) + info.Dialogs = s.answeredUntold(sess) return infoAPI(info), nil } +// heldOr is the page an action that opened a dialog left, which it answers +// with rather than fail, or err. +func (s *Service) heldOr(id string, err error) (api.BrowserPageInfo, error) { + if errors.Is(err, errDialogOpen) { + return s.heldPage(id) + } + return api.BrowserPageInfo{}, err +} + // Capture writes a PNG of this session's page and returns its path. func (s *Service) Capture(_ context.Context, opts api.BrowserScreenshotOptions) (string, error) { return s.Screenshot(opts.ID, opts.Path) @@ -149,7 +163,7 @@ func handleAPI(h Handle) api.BrowserHandle { func infoAPI(p PageInfo) api.BrowserPageInfo { out := api.BrowserPageInfo{URL: p.URL, Title: p.Title, Text: p.Text, Truncated: p.Truncated, Downloads: p.Downloads, - ConsoleErrors: p.ConsoleErrors, ConsoleErrorsMore: p.ConsoleErrorsMore} + ConsoleErrors: p.ConsoleErrors, ConsoleErrorsMore: p.ConsoleErrorsMore, Dialogs: p.Dialogs} if p.Interactive == nil { return out } diff --git a/internal/browser/view.go b/internal/browser/view.go index 89043b58b..6ae8bfe2e 100644 --- a/internal/browser/view.go +++ b/internal/browser/view.go @@ -43,8 +43,7 @@ func (s *Service) Attach(owner string, v Viewport) (Handle, error) { return Handle{}, errInWindow } if err := runPage(func(ctx context.Context) error { return sess.page.Screencast(ctx, v) }); err != nil { - s.drop(sess) - return Handle{}, err + return Handle{}, s.failed(sess, err) } s.mu.Lock() sess.watching = true @@ -117,8 +116,20 @@ func (s *Service) Nav(owner, action, rawURL string) error { // steer runs one of the window's own calls on owner's page, bounded by wait. // Unlike an agent action, a call that fails leaves the browser running -// unless its process is gone: the person is still looking at the page. +// unless its process is gone: the person is still looking at the page. A +// call a dialog held off does nothing and is no error: the pane shows the +// dialog, and answering it is what the person can do. func (s *Service) steer(owner string, wait time.Duration, fn func(Page, context.Context) error) (*session, error) { + sess, err := s.onPage(owner, wait, fn) + if errors.Is(err, errDialogOpen) { + return sess, nil + } + return sess, err +} + +// onPage runs fn on owner's page, bounded by wait, and keeps the browser +// when it fails, unless its process is gone. +func (s *Service) onPage(owner string, wait time.Duration, fn func(Page, context.Context) error) (*session, error) { sess, err := s.live(owner) if err != nil { return nil, err @@ -126,8 +137,7 @@ func (s *Service) steer(owner string, wait time.Duration, fn func(Page, context. ctx, cancel := context.WithTimeout(context.Background(), wait) defer cancel() if err := fn(sess.page, ctx); err != nil { - s.drop(sess) - return nil, err + return sess, s.failed(sess, err) } s.touch(sess) return sess, nil @@ -204,5 +214,6 @@ func (s *Service) agentPoint(owner string) *point { func (s *session) stateLocked(open bool) api.BrowserState { return api.BrowserState{ Owner: s.owner, Open: open, Headed: s.headed, ByAgent: s.byAgent, URL: s.url, Title: s.title, + Dialog: s.dialog, } } diff --git a/internal/browser/wait.go b/internal/browser/wait.go index 7bc60bc40..2cd72d002 100644 --- a/internal/browser/wait.go +++ b/internal/browser/wait.go @@ -2,6 +2,7 @@ package browser import ( "context" + "errors" "time" "github.com/lichdotdev/lich/api" @@ -38,7 +39,9 @@ func (s *Service) waitText(opts api.BrowserInfoOptions) (timedOut bool, err erro // navigates leaves for a moment: the wait reads again. A Chrome that died // stops it, and the page read after reports that. for sess.page.Alive() { - if ok, err := textSettled(ctx, sess.page, opts); err == nil && ok { + ok, err := textSettled(ctx, sess.page, opts) + // A dialog runs no script until it is answered: the read says so. + if err == nil && ok || errors.Is(err, errDialogOpen) { return false, nil } select { diff --git a/internal/cli/browser.go b/internal/cli/browser.go index 002227b9d..4f58f29d2 100644 --- a/internal/cli/browser.go +++ b/internal/cli/browser.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/prompt" ) func (c *client) browser(args []string) error { @@ -41,6 +42,8 @@ func (c *client) browser(args []string) error { return c.browserID(api.BrowserForward, args[1:]) case "scroll": return c.browserScroll(args[1:]) + case "dialog": + return c.browserDialog(args[1:]) case "list": return c.browserList(args[1:]) case "close": @@ -82,7 +85,7 @@ func (c *client) browserInfo(args []string) error { // printPage prints a page the way the browser_* tools answer with it. func (c *client) printPage(info api.BrowserPageInfo) error { - _, err := fmt.Fprintln(c.stdout, pageText(info)) + _, err := fmt.Fprintln(c.stdout, cliPageText(info, c.lang())) return err } @@ -297,8 +300,36 @@ func (t *targetFlags) target() api.BrowserTarget { func (c *client) printHandle(h api.BrowserHandle) error { lines := []string{h.URL} for _, d := range h.Downloads { - lines = append(lines, downloadLine(d)) + lines = append(lines, downloadLine(d, prompt.For(c.lang()))) } _, err := fmt.Fprintln(c.stdout, strings.Join(lines, "\n")) return err } + +// browserDialog answers the JavaScript dialog the page holds open. +func (c *client) browserDialog(args []string) error { + flags := newFlagSet("browser") + text := flags.String("text", "", "What a prompt answers on accept; its default text when left out.") + if err := c.parse(flags, args); err != nil { + return err + } + verb := strings.Join(flags.Args(), " ") + if verb != "accept" && verb != "dismiss" { + return usageError("browser") + } + id, err := c.browserOwner() + if err != nil { + return err + } + opts := api.BrowserDialogOptions{ID: id, Accept: verb == "accept"} + flags.Visit(func(f *flag.Flag) { + if f.Name == "text" { + opts.Text = text + } + }) + info, err := callMethod(context.Background(), c, api.BrowserAnswerDialog, opts, openCall) + if err != nil { + return err + } + return c.printPage(info) +} diff --git a/internal/cli/browser_page.go b/internal/cli/browser_page.go index bac7924d6..66c3370ae 100644 --- a/internal/cli/browser_page.go +++ b/internal/cli/browser_page.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/prompt" ) // pageText is a page as an agent reads it: one line per interactive element @@ -14,40 +15,73 @@ import ( // same page (a session reading the Notacil app spent 3 to 4 thousand per // read, 93% of it the element list; measured 2026-10-26). Positions are left // out: an agent clicks by index, and a box is a screenshot's job. -func pageText(info api.BrowserPageInfo) string { +func pageText(info api.BrowserPageInfo, lang prompt.Lang) string { + text := prompt.For(lang) + return pageTextAsking(info, text, text.PageDialogWaits) +} + +// cliPageText is pageText for `lich browser`, whose answer to an open dialog +// is its own verb rather than the MCP tool. +func cliPageText(info api.BrowserPageInfo, lang prompt.Lang) string { + text := prompt.For(lang) + return pageTextAsking(info, text, text.PageDialogWaitsCLI) +} + +// pageTextAsking is the page answer, with waits as what an open dialog asks +// of the agent. +func pageTextAsking(info api.BrowserPageInfo, text *prompt.Catalog, waits string) string { var b strings.Builder fmt.Fprintf(&b, "url: %s\ntitle: %s\n", info.URL, info.Title) if info.WaitTimedOut { - b.WriteString("wait: timed out, this is the page as it stands\n") + b.WriteString(text.PageWaitTimedOut + "\n") } + writeDialogs(&b, info, waits) for _, d := range info.Downloads { - b.WriteString(downloadLine(d)) + b.WriteString(downloadLine(d, text)) b.WriteByte('\n') } for _, e := range info.ConsoleErrors { fmt.Fprintf(&b, "console error: %s\n", e) } - if info.ConsoleErrorsMore > 0 { - fmt.Fprintf(&b, "(%d more console errors)\n", info.ConsoleErrorsMore) + if n := info.ConsoleErrorsMore; n > 0 { + fmt.Fprintf(&b, text.Pick(n, text.PageConsoleMore)+"\n", n) } if info.Text != "" { fmt.Fprintf(&b, "text: %s\n", info.Text) } if len(info.Interactive) > 0 { - b.WriteString("interactive (index tag \"label\" -> link):\n") + b.WriteString(text.PageInteractive + "\n") } base, _ := url.Parse(info.URL) for _, n := range info.Interactive { - b.WriteString(elementLine(n, base)) + b.WriteString(elementLine(n, base, text)) b.WriteByte('\n') } if info.Truncated { - b.WriteString("(more elements below, scroll to reach them)\n") + b.WriteString(text.PageMore + "\n") } return strings.TrimSuffix(b.String(), "\n") } -func elementLine(n api.BrowserInteractive, base *url.URL) string { +// writeDialogs names the dialogs the user answered since the last page +// answer, then the one the page holds open, which the agent must answer +// before anything else. +func writeDialogs(b *strings.Builder, info api.BrowserPageInfo, waits string) { + for _, d := range info.Dialogs { + fmt.Fprintf(b, "dialog answered: %s %q -> %s\n", d.Type, d.Message, d.Answer) + } + d := info.Dialog + if d == nil { + return + } + fmt.Fprintf(b, "dialog open: %s %q", d.Type, d.Message) + if d.Default != "" { + fmt.Fprintf(b, " default %q", d.Default) + } + b.WriteString("\n" + waits + "\n") +} + +func elementLine(n api.BrowserInteractive, base *url.URL, text *prompt.Catalog) string { parts := []string{strconv.Itoa(n.Index), elementKind(n)} if n.Text != "" { parts = append(parts, strconv.Quote(n.Text)) @@ -56,10 +90,10 @@ func elementLine(n api.BrowserInteractive, base *url.URL) string { parts = append(parts, "-> "+shortHref(n.Href, base)) } if n.Filled != nil && *n.Filled { - parts = append(parts, "(filled)") + parts = append(parts, text.PageFilled) } if n.Checked != nil && *n.Checked { - parts = append(parts, "(checked)") + parts = append(parts, text.PageChecked) } return strings.Join(parts, " ") } @@ -91,12 +125,12 @@ func shortHref(href string, base *url.URL) string { } // downloadLine says where a file the page downloaded is, and how far it got. -func downloadLine(d api.BrowserDownload) string { +func downloadLine(d api.BrowserDownload, text *prompt.Catalog) string { switch d.State { case api.DownloadCompleted: return fmt.Sprintf("downloaded: %s (%s)", d.Path, sizeText(d.Bytes)) case api.DownloadInProgress: - return fmt.Sprintf("downloading: %s (%s so far)", d.Path, sizeText(d.Bytes)) + return fmt.Sprintf(text.PageDownloading, d.Path, sizeText(d.Bytes)) case api.DownloadFailed: return "download failed: " + d.Path default: diff --git a/internal/cli/browser_page_test.go b/internal/cli/browser_page_test.go index a35120c44..fda478cb6 100644 --- a/internal/cli/browser_page_test.go +++ b/internal/cli/browser_page_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/prompt" ) func TestPageTextIsOneLinePerElement(t *testing.T) { @@ -35,24 +36,24 @@ func TestPageTextIsOneLinePerElement(t *testing.T) { `5 input:checkbox "Lembrar" (checked)`, "(more elements below, scroll to reach them)", }, "\n") - if got := pageText(info); got != want { + if got := pageText(info, prompt.English); got != want { t.Fatalf("got\n%s\nwant\n%s", got, want) } raw, _ := json.Marshal(info) - if len(pageText(info)) >= len(raw)*2/3 { - t.Fatalf("text %d chars against %d for the JSON: not smaller enough", len(pageText(info)), len(raw)) + if len(pageText(info, prompt.English)) >= len(raw)*2/3 { + t.Fatalf("text %d chars against %d for the JSON: not smaller enough", len(pageText(info, prompt.English)), len(raw)) } } func TestDownloadLineSaysAFailedMove(t *testing.T) { d := api.BrowserDownload{Path: "/home/u/Downloads/report.csv", Bytes: 5, State: api.DownloadFailed} - if got := downloadLine(d); got != "download failed: /home/u/Downloads/report.csv" { + if got := downloadLine(d, prompt.For(prompt.English)); got != "download failed: /home/u/Downloads/report.csv" { t.Fatalf("got %q", got) } } func TestPageTextOfAnEmptyPage(t *testing.T) { - if got := pageText(api.BrowserPageInfo{URL: "about:blank"}); got != "url: about:blank\ntitle: " { + if got := pageText(api.BrowserPageInfo{URL: "about:blank"}, prompt.English); got != "url: about:blank\ntitle: " { t.Fatalf("got %q", got) } } @@ -73,7 +74,7 @@ func TestPageTextNamesTheDownloadsAfterTheTitle(t *testing.T) { "downloaded: /home/u/Downloads/tiny.txt (5 B)", "text: Reports", }, "\n") - if got := pageText(info); got != want { + if got := pageText(info, prompt.English); got != want { t.Fatalf("got\n%s\nwant\n%s", got, want) } } @@ -81,7 +82,7 @@ func TestPageTextNamesTheDownloadsAfterTheTitle(t *testing.T) { func TestPageTextSaysAWaitTimedOutBeforeThePage(t *testing.T) { info := api.BrowserPageInfo{URL: "https://x.test/", Title: "X", Text: "Loading", WaitTimedOut: true} want := "url: https://x.test/\ntitle: X\nwait: timed out, this is the page as it stands\ntext: Loading" - if got := pageText(info); got != want { + if got := pageText(info, prompt.English); got != want { t.Fatalf("got\n%s\nwant\n%s", got, want) } } @@ -89,7 +90,66 @@ func TestPageTextSaysAWaitTimedOutBeforeThePage(t *testing.T) { func TestPageTextNamesConsoleErrorsAndHowManyMore(t *testing.T) { info := api.BrowserPageInfo{URL: "about:blank", ConsoleErrors: []string{"Uncaught Error: x", "boom"}, ConsoleErrorsMore: 4} want := "url: about:blank\ntitle: \nconsole error: Uncaught Error: x\nconsole error: boom\n(4 more console errors)" - if got := pageText(info); got != want { + if got := pageText(info, prompt.English); got != want { t.Fatalf("got\n%s\nwant\n%s", got, want) } } + +func TestPageTextNamesTheAnsweredDialogsThenTheOpenOne(t *testing.T) { + info := api.BrowserPageInfo{URL: "https://x.test/", Title: "X", + Dialogs: []api.BrowserDialog{{Type: "confirm", Message: "Delete?", Answer: api.DialogDismissed}}, + Dialog: &api.BrowserDialog{Type: "prompt", Message: "Name?", Default: "Ada"}, + } + want := strings.Join([]string{ + "url: https://x.test/", + "title: X", + `dialog answered: confirm "Delete?" -> dismissed`, + `dialog open: prompt "Name?" default "Ada"`, + "The page runs nothing until this dialog is answered: call browser_dialog.", + }, "\n") + if got := pageText(info, prompt.English); got != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } +} + +// The prose of a page answer follows the prompt language; the line tags the +// tool descriptions name stay English in every one. +func TestPageTextInEveryLanguage(t *testing.T) { + yes := true + info := api.BrowserPageInfo{ + URL: "https://x.test/", Title: "X", Text: "Hi", Truncated: true, WaitTimedOut: true, + Downloads: []api.BrowserDownload{{Path: "/dl/big.iso", Bytes: 3, State: api.DownloadInProgress}}, + ConsoleErrors: []string{"boom"}, + ConsoleErrorsMore: 2, + Dialog: &api.BrowserDialog{Type: "alert", Message: "hi"}, + Interactive: []api.BrowserInteractive{{Index: 1, Tag: "input", Filled: &yes, Checked: &yes}}, + } + english := pageText(info, prompt.English) + for _, lang := range prompt.Langs { + got := pageText(info, lang) + if strings.Contains(got, "%!") { + t.Errorf("%s: a format argument is off:\n%s", lang, got) + } + for _, literal := range []string{ + "url: ", "title: ", "wait: ", "text: ", "downloading: /dl/big.iso", "3 B", "console error: boom", "2", + `dialog open: alert "hi"`, "browser_dialog", "interactive (", + } { + if !strings.Contains(got, literal) { + t.Errorf("%s: lost %q:\n%s", lang, literal, got) + } + } + if lang != prompt.English && got == english { + t.Errorf("%s: the page reads the same as in English", lang) + } + } +} + +func TestTheCommandLineAsksForItsOwnVerb(t *testing.T) { + info := api.BrowserPageInfo{URL: "https://x.test/", Dialog: &api.BrowserDialog{Type: "alert", Message: "hi"}} + for _, lang := range prompt.Langs { + got := cliPageText(info, lang) + if !strings.Contains(got, "lich browser dialog accept|dismiss") || strings.Contains(got, "browser_dialog") { + t.Errorf("%s: the command line answer names the wrong call:\n%s", lang, got) + } + } +} diff --git a/internal/cli/browser_test.go b/internal/cli/browser_test.go index 42320129a..54d40a4d2 100644 --- a/internal/cli/browser_test.go +++ b/internal/cli/browser_test.go @@ -115,3 +115,54 @@ func TestMCPBrowserOpenUsesTheCaller(t *testing.T) { t.Errorf("options = %+v", opts) } } + +func TestBrowserDialogAnswersThePagesDialog(t *testing.T) { + f := newFakeLich(t, `{"url":"https://example.com","title":"after"}`) + + code, stdout, stderr := run(t, f, "browser", "dialog", "--text", "Bob", "accept") + if code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr) + } + opts := optionsOf[api.BrowserDialogOptions](t, f.only(t)) + if opts.ID != "s1" || !opts.Accept || opts.Text == nil || *opts.Text != "Bob" { + t.Errorf("options = %+v", opts) + } + if !strings.Contains(stdout, "title: after") { + t.Errorf("stdout = %q", stdout) + } +} + +func TestBrowserDialogDismissKeepsThePromptsText(t *testing.T) { + f := newFakeLich(t, `{"url":"https://example.com"}`) + + if code, _, stderr := run(t, f, "browser", "dialog", "dismiss"); code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr) + } + opts := optionsOf[api.BrowserDialogOptions](t, f.only(t)) + if opts.Accept || opts.Text != nil { + t.Errorf("options = %+v", opts) + } +} + +func TestBrowserDialogWantsAcceptOrDismiss(t *testing.T) { + f := newFakeLich(t, `null`) + + if code, _, _ := run(t, f, "browser", "dialog", "ok"); code == 0 { + t.Fatal("dialog ok was taken") + } +} + +func TestMCPBrowserDialogAnswersWithThePage(t *testing.T) { + f := newFakeLich(t, `{"url":"https://example.com","title":"after"}`) + + replies := speak(t, f, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params": + {"name":"browser_dialog","arguments":{"accept":false}}}`) + text, failed := textOf(t, replies[0]) + if failed || !strings.Contains(text, "title: after") { + t.Fatalf("tool answered %q (failed %v)", text, failed) + } + opts := optionsOf[api.BrowserDialogOptions](t, f.only(t)) + if opts.ID != "s1" || opts.Accept || opts.Text != nil { + t.Errorf("options = %+v", opts) + } +} diff --git a/internal/cli/help.go b/internal/cli/help.go index f5711cfe9..6076b4ff6 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -145,7 +145,7 @@ var commands = []command{ args: " [args]", about: "Drive this session's browser — the page its tab in the lich window shows.\n" + "Verbs: open [url], info, click, type, press , screenshot [path], navigate ,\n" + - "reload, back, forward, scroll, list, close.", + "reload, back, forward, scroll, dialog [--text t] accept|dismiss, list, close.", }, { name: "worktrees", diff --git a/internal/cli/mcp_browser.go b/internal/cli/mcp_browser.go index c78876382..28b938a02 100644 --- a/internal/cli/mcp_browser.go +++ b/internal/cli/mcp_browser.go @@ -42,7 +42,9 @@ var browserTools = []mcpTool{ "one line per interactive element there: index, tag, label and link. Form values are " + "never included — (filled)/(checked) say that typing landed. click and press already " + "answer with this, so call it only to look again without acting. Also names a download " + - "that finished, and each console error, since the page was last read. wait_for and " + + "that finished, each console error, and each dialog the user answered in the browser tab " + + "(dialog answered: lines), since the page was last read, and a dialog the page holds " + + "open (dialog open:). wait_for and " + "wait_gone hold the read, up to 20 seconds, until that text is on the page or off it, " + "instead of reading again in a loop. Read-only.", Schema: schema(map[string]any{ @@ -60,7 +62,9 @@ var browserTools = []mcpTool{ "Answers with the page afterwards, as browser_info reads it, once a navigation the " + "click started has its document. A file the click downloads is saved to the user's " + "Downloads folder; the answer names its path in a downloaded: line. Console errors " + - "and uncaught exceptions since the last page answer come as console error: lines.", + "and uncaught exceptions since the last page answer come as console error: lines. A " + + "JavaScript dialog the click opens holds the page: the answer ends with a dialog " + + "open: line, and browser_dialog answers it.", Schema: schema(map[string]any{ "index": property("integer", "Index from the last browser_info."), "selector": property("string", "CSS selector."), @@ -106,7 +110,7 @@ var browserTools = []mcpTool{ "Real key event, not typed text — use browser_type for that. Optional target " + "focuses first, as for browser_type. Answers with the page afterwards, once a " + "navigation the key started (Enter on a form) has its document, and names a file it " + - "downloaded and console errors as browser_click does.", + "downloaded, console errors and a dialog it opened as browser_click does.", Schema: schema(map[string]any{ "key": property("string", "Named key: Enter, Tab, Escape, …"), "index": property("integer", "Index from the last browser_info."), @@ -125,7 +129,7 @@ var browserTools = []mcpTool{ if err != nil { return "", err } - return pageText(info), nil + return pageText(info, c.lang()), nil }, }, { @@ -209,6 +213,19 @@ var browserTools = []mcpTool{ return "ok", nil }, }, + { + Name: "browser_dialog", + Description: "Answer the JavaScript dialog (alert, confirm, prompt, beforeunload) the " + + "page holds open, which a page answer names in a dialog open: line. Until it is " + + "answered the page runs no script and every other browser tool fails. The user sees " + + "it in the session's browser tab and may answer it first. accept is OK (Leave on a " + + "beforeunload), false is Cancel (Stay). Answers with the page afterwards.", + Schema: schema(map[string]any{ + "accept": property("boolean", "OK or Leave when true; Cancel or Stay when false."), + "text": property("string", "What a prompt returns on accept. Omit to keep its default text."), + }, "accept"), + Run: mcpBrowserDialog, + }, { Name: "browser_list", Description: "This session's browser, or an empty list. Read-only.", @@ -253,7 +270,7 @@ func mcpBrowserClick(ctx context.Context, c *client, a mcpArgs) (string, error) if err != nil { return "", err } - return pageText(info), nil + return pageText(info, c.lang()), nil } func mcpBrowserID(ctx context.Context, c *client, method api.Method[api.SessionIDOptions, api.None]) (string, error) { @@ -286,7 +303,7 @@ func mcpBrowserInfo(ctx context.Context, c *client, a mcpArgs) (string, error) { if err != nil { return "", err } - return pageText(info), nil + return pageText(info, c.lang()), nil } func mcpBrowserList(ctx context.Context, c *client, _ mcpArgs) (string, error) { @@ -350,3 +367,19 @@ func (a mcpArgs) optionalFloat(key string) *float64 { } return &n } + +func mcpBrowserDialog(ctx context.Context, c *client, a mcpArgs) (string, error) { + id, err := c.browserOwner() + if err != nil { + return "", err + } + opts := api.BrowserDialogOptions{ID: id, Accept: a.flag("accept")} + if text, ok := a["text"].(string); ok { + opts.Text = &text + } + info, err := callMethod(ctx, c, api.BrowserAnswerDialog, opts, openCall) + if err != nil { + return "", err + } + return pageText(info, c.lang()), nil +} diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index 90a8acf65..e928c2fe0 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -254,6 +254,7 @@ func TestMCPListsEveryTool(t *testing.T) { "browser_back": {}, "browser_forward": {}, "browser_scroll": {}, + "browser_dialog": {"accept"}, "browser_list": {}, "browser_close": {}, } diff --git a/internal/prompt/catalog.go b/internal/prompt/catalog.go index 865560902..8d738421b 100644 --- a/internal/prompt/catalog.go +++ b/internal/prompt/catalog.go @@ -82,4 +82,20 @@ type Catalog struct { ClosedKept string ClosedPlain string ClosedProject string + + // internal/cli: a browser page as the browser_* tools answer with it. The + // line tags (url:, title:, wait:, text:, interactive, downloading:, dialog + // open:) and browser_dialog stay English in every locale: the tool + // descriptions name them. + PageInteractive string + PageMore string + PageFilled string + PageChecked string + PageDownloading string + PageWaitTimedOut string + PageConsoleMore Plural + PageDialogWaits string + // PageDialogWaitsCLI is PageDialogWaits for `lich browser`, which names + // its own verb instead of the MCP tool. + PageDialogWaitsCLI string } diff --git a/internal/prompt/en.go b/internal/prompt/en.go index 3511ca571..4dd2a7f4b 100644 --- a/internal/prompt/en.go +++ b/internal/prompt/en.go @@ -135,4 +135,16 @@ This session has a browser of its own (browser_* tools). It runs headless inside ClosedPlain: "Closed %q.\n", ClosedProject: "Closed the project %[1]q (%[2]s) and stopped its sessions. They stay with it: opening a session " + "on %[2]s again reopens the project with them.\n", + PageInteractive: "interactive (index tag \"label\" -> link):", + PageMore: "(more elements below, scroll to reach them)", + PageFilled: "(filled)", + PageChecked: "(checked)", + PageDownloading: "downloading: %[1]s (%[2]s so far)", + PageWaitTimedOut: "wait: timed out, this is the page as it stands", + PageConsoleMore: Plural{ + One: "(%d more console error)", + Other: "(%d more console errors)", + }, + PageDialogWaits: "The page runs nothing until this dialog is answered: call browser_dialog.", + PageDialogWaitsCLI: "The page runs nothing until this dialog is answered: run lich browser dialog accept|dismiss.", } diff --git a/internal/prompt/es.go b/internal/prompt/es.go index fd96d753c..89302b55b 100644 --- a/internal/prompt/es.go +++ b/internal/prompt/es.go @@ -136,4 +136,16 @@ Esta sesión tiene un navegador propio (herramientas browser_*). Corre sin venta ClosedPlain: "Se cerró %q.\n", ClosedProject: "Se cerró el proyecto %[1]q (%[2]s) y se detuvieron sus sesiones. Siguen con él: abrir una sesión " + "en %[2]s de nuevo reabre el proyecto con ellas.\n", + PageInteractive: "interactive (índice etiqueta \"texto\" -> enlace):", + PageMore: "(hay más elementos abajo, desplázate para llegar a ellos)", + PageFilled: "(rellenado)", + PageChecked: "(marcado)", + PageDownloading: "downloading: %[1]s (%[2]s hasta ahora)", + PageWaitTimedOut: "wait: se agotó el tiempo, esta es la página tal como está", + PageConsoleMore: Plural{ + One: "(%d error de consola más)", + Other: "(%d errores de consola más)", + }, + PageDialogWaits: "La página no ejecuta nada hasta que se responda este diálogo: llama a browser_dialog.", + PageDialogWaitsCLI: "La página no ejecuta nada hasta que se responda este diálogo: ejecuta lich browser dialog accept|dismiss.", } diff --git a/internal/prompt/ja.go b/internal/prompt/ja.go index 9109fd803..d34a5d5a4 100644 --- a/internal/prompt/ja.go +++ b/internal/prompt/ja.go @@ -133,4 +133,16 @@ var ja = Catalog{ ClosedPlain: "%q を閉じました。\n", ClosedProject: "プロジェクト %[1]q (%[2]s) を閉じ、そのセッションを停止しました。セッションはプロジェクトに残っています。" + "%[2]s でもう一度セッションを開くと、プロジェクトがセッションごと再び開きます。\n", + PageInteractive: "interactive (インデックス タグ \"ラベル\" -> リンク):", + PageMore: "(下にさらに要素があります。スクロールして表示してください)", + PageFilled: "(入力済み)", + PageChecked: "(チェック済み)", + PageDownloading: "downloading: %[1]s (現在 %[2]s)", + PageWaitTimedOut: "wait: タイムアウトしました。これは現時点のページです", + PageConsoleMore: Plural{ + One: "(ほかにコンソールエラーが %d 件)", + Other: "(ほかにコンソールエラーが %d 件)", + }, + PageDialogWaits: "このダイアログに応答するまでページは何も実行しません。browser_dialog を呼び出してください。", + PageDialogWaitsCLI: "このダイアログに応答するまでページは何も実行しません。lich browser dialog accept|dismiss を実行してください。", } diff --git a/internal/prompt/ko.go b/internal/prompt/ko.go index a8baa8173..0c0cb7595 100644 --- a/internal/prompt/ko.go +++ b/internal/prompt/ko.go @@ -128,4 +128,16 @@ var ko = Catalog{ ClosedPlain: "%q을(를) 닫았습니다.\n", ClosedProject: "프로젝트 %[1]q (%[2]s)을(를) 닫고 그 세션들을 멈췄습니다. 세션은 프로젝트에 남아 있습니다. " + "%[2]s에서 세션을 다시 열면 프로젝트가 세션과 함께 다시 열립니다.\n", + PageInteractive: "interactive (인덱스 태그 \"레이블\" -> 링크):", + PageMore: "(아래에 요소가 더 있습니다. 스크롤해서 확인하세요)", + PageFilled: "(입력됨)", + PageChecked: "(선택됨)", + PageDownloading: "downloading: %[1]s (지금까지 %[2]s)", + PageWaitTimedOut: "wait: 시간이 초과되었습니다. 지금 상태의 페이지입니다", + PageConsoleMore: Plural{ + One: "(콘솔 오류 %d개 더 있음)", + Other: "(콘솔 오류 %d개 더 있음)", + }, + PageDialogWaits: "이 대화상자에 응답할 때까지 페이지는 아무것도 실행하지 않습니다. browser_dialog를 호출하세요.", + PageDialogWaitsCLI: "이 대화상자에 응답할 때까지 페이지는 아무것도 실행하지 않습니다. lich browser dialog accept|dismiss를 실행하세요.", } diff --git a/internal/prompt/pt_br.go b/internal/prompt/pt_br.go index e3d478cc3..dd280cf22 100644 --- a/internal/prompt/pt_br.go +++ b/internal/prompt/pt_br.go @@ -136,4 +136,16 @@ Esta sessão tem um navegador próprio (ferramentas browser_*). Ele roda sem jan ClosedPlain: "%q fechada.\n", ClosedProject: "Projeto %[1]q (%[2]s) fechado e as sessões dele paradas. Elas ficam com ele: abrir uma sessão " + "em %[2]s de novo reabre o projeto com elas.\n", + PageInteractive: "interactive (índice tag \"rótulo\" -> link):", + PageMore: "(mais elementos abaixo, role a página para alcançá-los)", + PageFilled: "(preenchido)", + PageChecked: "(marcado)", + PageDownloading: "downloading: %[1]s (%[2]s até agora)", + PageWaitTimedOut: "wait: o tempo acabou, esta é a página como está", + PageConsoleMore: Plural{ + One: "(mais %d erro de console)", + Other: "(mais %d erros de console)", + }, + PageDialogWaits: "A página não roda nada até este diálogo ser respondido: chame browser_dialog.", + PageDialogWaitsCLI: "A página não roda nada até este diálogo ser respondido: rode lich browser dialog accept|dismiss.", } diff --git a/internal/prompt/ru.go b/internal/prompt/ru.go index 79fc64b75..36f171582 100644 --- a/internal/prompt/ru.go +++ b/internal/prompt/ru.go @@ -145,4 +145,18 @@ var ru = Catalog{ ClosedPlain: "Сессия %q закрыта.\n", ClosedProject: "Проект %[1]q (%[2]s) закрыт, его сессии остановлены. Они остаются при нём: если снова " + "открыть сессию в %[2]s, проект откроется вместе с ними.\n", + PageInteractive: "interactive (индекс тег \"подпись\" -> ссылка):", + PageMore: "(ниже есть ещё элементы, прокрутите страницу, чтобы до них добраться)", + PageFilled: "(заполнено)", + PageChecked: "(отмечено)", + PageDownloading: "downloading: %[1]s (пока %[2]s)", + PageWaitTimedOut: "wait: время ожидания истекло, это страница в нынешнем виде", + PageConsoleMore: Plural{ + One: "(ещё %d ошибка консоли)", + Few: "(ещё %d ошибки консоли)", + Many: "(ещё %d ошибок консоли)", + Other: "(ещё %d ошибки консоли)", + }, + PageDialogWaits: "Пока на этот диалог не ответят, страница ничего не выполняет: вызовите browser_dialog.", + PageDialogWaitsCLI: "Пока на этот диалог не ответят, страница ничего не выполняет: выполните lich browser dialog accept|dismiss.", } diff --git a/internal/prompt/zh_cn.go b/internal/prompt/zh_cn.go index d308c97f3..7a5092b8e 100644 --- a/internal/prompt/zh_cn.go +++ b/internal/prompt/zh_cn.go @@ -127,4 +127,16 @@ var zhCN = Catalog{ ClosedPlain: "已关闭 %q。\n", ClosedProject: "已关闭项目 %[1]q (%[2]s),并停止了它的会话。会话仍保留在项目中:" + "再次在 %[2]s 打开会话,会连同这些会话重新打开该项目。\n", + PageInteractive: "interactive (索引 标签 \"文字\" -> 链接):", + PageMore: "(下方还有更多元素,滚动页面即可看到)", + PageFilled: "(已填写)", + PageChecked: "(已勾选)", + PageDownloading: "downloading: %[1]s (目前 %[2]s)", + PageWaitTimedOut: "wait: 等待超时,以下是页面当前的样子", + PageConsoleMore: Plural{ + One: "(还有 %d 条控制台错误)", + Other: "(还有 %d 条控制台错误)", + }, + PageDialogWaits: "在回应此对话框之前,页面不会执行任何操作:请调用 browser_dialog。", + PageDialogWaitsCLI: "在回应此对话框之前,页面不会执行任何操作:请运行 lich browser dialog accept|dismiss。", } diff --git a/internal/routes/browser.go b/internal/routes/browser.go index 560386177..60dda6466 100644 --- a/internal/routes/browser.go +++ b/internal/routes/browser.go @@ -24,6 +24,7 @@ func Browser(d *rpc.Handler, s *browser.Service) { rpc.Handle(d, api.BrowserScroll, s.ScrollBy) rpc.Handle(d, api.BrowserList, s.Handles) rpc.Handle(d, api.BrowserClose, s.CloseOne) + rpc.Handle(d, api.BrowserAnswerDialog, s.AnswerDialog) rpc.Handle(d, api.BrowserRunning, s.RunningStates) rpc.Handle(d, api.BrowserAttach, s.AttachView) rpc.Handle(d, api.BrowserDetach, s.DetachView) @@ -32,6 +33,7 @@ func Browser(d *rpc.Handler, s *browser.Service) { rpc.Handle(d, api.BrowserKey, s.KeyInput) rpc.Handle(d, api.BrowserText, s.TextInput) rpc.Handle(d, api.BrowserNav, s.NavView) + rpc.Handle(d, api.BrowserAnswer, s.Answer) rpc.Handle(d, api.BrowserSelection, s.SelectedText) rpc.Handle(d, api.BrowserElementAt, s.ElementAtPoint) rpc.Handle(d, api.BrowserLocate, s.LocateElements) diff --git a/native/backend/browser.go b/native/backend/browser.go index ebbfa3e51..0ba6b9e3e 100644 --- a/native/backend/browser.go +++ b/native/backend/browser.go @@ -72,6 +72,12 @@ func (c *Client) BrowserNav(ctx context.Context, id, action, url string) error { return err } +// BrowserAnswer answers the JavaScript dialog session's page holds open. +func (c *Client) BrowserAnswer(ctx context.Context, o api.BrowserDialogOptions) error { + _, err := callMethod(ctx, c, api.BrowserAnswer, o) + return err +} + // BrowserElementAt is the element under x,y of session id's page. func (c *Client) BrowserElementAt(ctx context.Context, id string, x, y float64) (api.BrowserElement, error) { return callMethod(ctx, c, api.BrowserElementAt, api.BrowserPointOptions{ID: id, X: x, Y: y}) diff --git a/native/cmd/lich-native/browser_cursor.go b/native/cmd/lich-native/browser_cursor.go index 712f5df55..6b9e41b62 100644 --- a/native/cmd/lich-native/browser_cursor.go +++ b/native/cmd/lich-native/browser_cursor.go @@ -89,8 +89,8 @@ const cursorPending = 3 var toolbarActions = map[string]bool{"back": true, "forward": true, "reload": true, "navigate": true} // take queues the marks not seen yet: an action at a point of the page (a -// click, typing into or pressing on an element, a scroll) or on the toolbar -// is played by advance, any other only keeps the cursor up. +// click, typing into or pressing on an element, a scroll), on the toolbar or +// on the page's dialog is played by advance, any other only keeps the cursor up. func (c *agentCursor) take(marks []browserMark) { for _, m := range marks { if m.seq <= c.seen { @@ -100,11 +100,25 @@ func (c *agentCursor) take(marks []browserMark) { if m.at.After(c.last) { c.last = m.at } - if m.x != nil && m.y != nil || toolbarActions[m.action] { + if m.x != nil && m.y != nil || toolbarActions[m.action] || dialogActions[m.action] { c.pending = append(c.pending, m) } } - c.pending = c.pending[max(0, len(c.pending)-cursorPending):] + c.pending = trimPending(c.pending) +} + +// trimPending keeps the latest cursorPending marks, and every answer to the +// page's dialog older than those: the card stays on the pane until the +// cursor has pressed its button. +func trimPending(pending []browserMark) []browserMark { + cut := max(0, len(pending)-cursorPending) + var kept []browserMark + for _, m := range pending[:cut] { + if dialogActions[m.action] { + kept = append(kept, m) + } + } + return append(kept, pending[cut:]...) } // advance starts the next queued action once the one playing has finished, diff --git a/native/cmd/lich-native/browser_cursor_test.go b/native/cmd/lich-native/browser_cursor_test.go index aae7d3199..137a2a7a3 100644 --- a/native/cmd/lich-native/browser_cursor_test.go +++ b/native/cmd/lich-native/browser_cursor_test.go @@ -100,6 +100,20 @@ func TestAgentCursorKeepsOnlyTheLatestPendingActions(t *testing.T) { } } +func TestAgentCursorNeverDropsAnAnswerToTheDialog(t *testing.T) { + t0 := time.Unix(100, 0) + accept := clickMark(t0, 0, 0) + accept.action, accept.x, accept.y = "accept", nil, nil + pending := []browserMark{accept} + for i := range 5 { + pending = append(pending, clickMark(t0, float64(i+1), 0)) + } + got := trimPending(pending) + if len(got) != cursorPending+1 || got[0].action != "accept" || *got[1].x != 6-cursorPending { + t.Fatalf("kept %+v, want the accept and the latest %d clicks", got, cursorPending) + } +} + func TestAgentCursorStaysUpWhileTheAgentActsAndThenFades(t *testing.T) { var c agentCursor t0 := time.Unix(100, 0) diff --git a/native/cmd/lich-native/browser_dialog.go b/native/cmd/lich-native/browser_dialog.go new file mode 100644 index 000000000..9ec905ff5 --- /dev/null +++ b/native/cmd/lich-native/browser_dialog.go @@ -0,0 +1,295 @@ +package main + +import ( + "context" + "image" + "net/url" + "time" + + "gioui.org/f32" + "gioui.org/io/event" + "gioui.org/io/key" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" +) + +// The JavaScript dialog the page holds open, as a card floating over the top +// of the page, with no scrim: the page runs nothing until the person or the +// agent answers it. Option B of the approved mockup (2026-10-28). + +var ( + dialogCardWidth = unit.Dp(400) + dialogCardRadius = unit.Dp(12) + dialogCardPad = ui.Space(4) + dialogCardGap = ui.Space(2.5) + dialogCardTop = ui.Space(3) +) + +// dialogCardRing is the card's outline, ui.Dialog's ring-foreground/10. +const dialogCardRing = 0.1 + +// dialogMessageLines is how many lines of the page's message the card shows: +// a page can put anything in one, and the card must leave its buttons on screen. +const dialogMessageLines = 8 + +// dialogActions are the agent's answers to the page's dialog, which the +// cursor plays on the card's buttons. +var dialogActions = map[string]bool{"accept": true, "dismiss": true} + +// dialogCard is the card of one page pane: the dialog it shows, its widgets, +// and where its buttons were last drawn, in pixels of the pane, for the +// agent's cursor to press. +type dialogCard struct { + shown *api.BrowserDialog + ok, cancel widget.Clickable + field widget.Editor + focusField bool + okAt, noAt image.Rectangle +} + +// sync takes the page's dialog: a new one replaces the card's, focusing a +// prompt's field with its text selected. None takes the card away, unless the +// agent's cursor still has to press its answer on it (holding): the state +// without the dialog follows the agent's answer at once, before the cursor +// played it. +func (d *dialogCard) sync(page *api.BrowserDialog, holding bool) { + switch { + case page != nil: + if d.shown != nil && *d.shown == *page { + return + } + shown := *page + d.shown = &shown + d.field.SingleLine, d.field.Submit = true, true + d.field.SetText(page.Default) + d.field.SetCaret(d.field.Len(), 0) + d.focusField = page.Type == "prompt" + case !holding: + d.shown = nil + } +} + +// target is the button the agent's cursor presses for action: OK or Leave to +// accept, Cancel or Stay to dismiss; an alert has only OK. +func (d *dialogCard) target(action string) image.Rectangle { + if action == "dismiss" && (d.shown == nil || d.shown.Type != "alert") { + return d.noAt + } + return d.okAt +} + +// point is the middle of target(action), or the card's top middle over a pane +// width wide whose page starts at top, before the card was ever drawn. +func (d *dialogCard) point(action string, width, top int) f32.Point { + r := d.target(action) + if r.Empty() { + return f32.Pt(float32(width)/2, float32(top)) + } + mid := r.Min.Add(r.Max).Div(2) + return f32.Pt(float32(mid.X), float32(mid.Y)) +} + +// dialogKey is the answer a key gives a dialog of type kind: Enter accepts, Esc +// dismisses, but on an alert, whose only answer is OK, Esc accepts too. ok is +// false for any other key. +func dialogKey(kind string, name key.Name) (accept, ok bool) { + switch name { + case key.NameReturn, key.NameEnter: + return true, true + case key.NameEscape: + return kind == "alert", true + } + return false, false +} + +// dialogFrom is the card's muted first line: the host of the page that opened +// the dialog says it. +func dialogFrom(raw string) string { + if u, err := url.Parse(raw); err == nil && u.Host != "" { + return i18n.T("layouts.browser.dialogFrom", i18n.Args{"host": u.Host}) + } + return i18n.T("layouts.browser.dialogFromPage") +} + +// answering reports whether the agent's cursor still has an answer to the +// dialog to press at now: queued, or pressing. +func (p *browserPaneUI) answering(now time.Time) bool { + for _, m := range p.cursor.pending { + if dialogActions[m.action] { + return true + } + } + return dialogActions[p.flash.what] && now.Before(p.flash.at.Add(toolbarFlash)) +} + +// answerDialog is the person's answer to the dialog on the card: a prompt +// they accept returns its field's text. +func (u *window) answerDialog(p *browserPaneUI, session string, accept bool) { + o := api.BrowserDialogOptions{ID: session, Accept: accept} + if accept && p.dialog.shown != nil && p.dialog.shown.Type == "prompt" { + text := p.dialog.field.Text() + o.Text = &text + } + u.sendBrowser(session, "answer", func(ctx context.Context) error { return u.client.BrowserAnswer(ctx, o) }) +} + +// dialogPageKey takes the keys the page would get while the card is up: Enter +// and Esc answer it, and the rest go nowhere, since the page runs nothing. It +// reports whether it took e. +func (u *window) dialogPageKey(p *browserPaneUI, session string, e key.Event) bool { + if p.dialog.shown == nil { + return false + } + if accept, ok := dialogKey(p.dialog.shown.Type, e.Name); ok && e.State == key.Press { + u.answerDialog(p, session, accept) + } + return true +} + +// dialogCardAt draws the card over the page in gtx, whose top is top pixels +// down the pane. +func (u *window) dialogCardAt(gtx C, p *browserPaneUI, session string, top int) { + d := &p.dialog + if d.shown == nil { + return + } + u.dialogCardInput(gtx, p, session) + th := u.theme.On(u.theme.Popover) + page := gtx.Constraints.Max + width := min(gtx.Dp(dialogCardWidth), page.X-2*gtx.Dp(gutter)) + pad, gap := gtx.Dp(dialogCardPad), gtx.Dp(dialogCardGap) + if width <= 2*pad { + return + } + inner := gtx + inner.Constraints = layout.Constraints{Min: image.Pt(width-2*pad, 0), Max: image.Pt(width-2*pad, page.Y)} + bodyRec := op.Record(gtx.Ops) + body := u.dialogCardBody(inner, th, d) + bodyCall := bodyRec.Stop() + ok, no := dialogButtons(gtx, th, p) + rowY := pad + body.Size.Y + gap + size := image.Pt(width, rowY+ok.size.Y+pad) + at := image.Pt((page.X-width)/2, gtx.Dp(dialogCardTop)) + ok.at = image.Pt(width-pad-ok.size.X, rowY) + no.at = image.Pt(ok.at.X-gtx.Dp(ui.Space(2))-no.size.X, rowY) + d.okAt, d.noAt = ok.rect().Add(at).Add(image.Pt(0, top)), image.Rectangle{} + if no.size.X > 0 { + d.noAt = no.rect().Add(at).Add(image.Pt(0, top)) + } + + defer op.Offset(at).Push(gtx.Ops).Pop() + shape := clip.UniformRRect(image.Rectangle{Max: size}, gtx.Dp(dialogCardRadius)) + paint.FillShape(gtx.Ops, th.Popover, shape.Op(gtx.Ops)) + area := shape.Push(gtx.Ops) + // The card takes the pointer from the page under it. + event.Op(gtx.Ops, d) + for { + if _, ok := gtx.Event(pointer.Filter{Target: d, Kinds: pointer.Press | pointer.Release | pointer.Move | pointer.Drag | pointer.Scroll, ScrollY: noteScrollRange}); !ok { + break + } + } + placed{at: image.Pt(pad, pad), call: bodyCall}.add(gtx) + ok.add(gtx) + no.add(gtx) + area.Pop() + paint.FillShape(gtx.Ops, ui.Alpha(th.Foreground, dialogCardRing), clip.Stroke{Path: shape.Path(gtx.Ops), Width: float32(gtx.Dp(1))}.Op()) +} + +// placed is something laid out ahead, to draw at at. +type placed struct { + at image.Point + size image.Point + call op.CallOp +} + +func (pl placed) rect() image.Rectangle { return image.Rectangle{Min: pl.at, Max: pl.at.Add(pl.size)} } + +func (pl placed) add(gtx C) { + defer op.Offset(pl.at).Push(gtx.Ops).Pop() + pl.call.Add(gtx.Ops) +} + +// dialogCardInput takes the person's answer from the card's buttons and +// field: Enter in a prompt's field is OK, Esc is Cancel. +func (u *window) dialogCardInput(gtx C, p *browserPaneUI, session string) { + d := &p.dialog + if d.ok.Clicked(gtx) { + u.answerDialog(p, session, true) + } + if d.cancel.Clicked(gtx) { + u.answerDialog(p, session, false) + } + for { + ev, ok := d.field.Update(gtx) + if !ok { + break + } + if _, ok := ev.(widget.SubmitEvent); ok { + u.answerDialog(p, session, true) + } + } + for { + ev, ok := gtx.Event(key.Filter{Focus: &d.field, Name: key.NameEscape}) + if !ok { + break + } + if e, ok := ev.(key.Event); ok && e.State == key.Press { + u.answerDialog(p, session, false) + } + } + if d.focusField { + d.focusField = false + gtx.Execute(key.FocusCmd{Tag: &d.field}) + } +} + +// dialogButtons lays out the card's buttons ahead of placing them: OK, primary, +// and Cancel; Leave, destructive, and Stay on a beforeunload; OK alone on an +// alert, where no is empty. The one the agent's cursor presses is lit. +func dialogButtons(gtx C, th *ui.Theme, p *browserPaneUI) (ok, no placed) { + kind := p.dialog.shown.Type + okLabel, noLabel, okVariant := i18n.T("layouts.browser.dialogOK"), i18n.T("common.action.cancel"), ui.VariantDefault + if kind == "beforeunload" { + okLabel, noLabel, okVariant = i18n.T("layouts.browser.dialogLeave"), i18n.T("layouts.browser.dialogStay"), ui.VariantDestructive + } + gtx.Constraints.Min = image.Point{} + record := func(b ui.ButtonStyle) placed { + rec := op.Record(gtx.Ops) + dims := b.Layout(gtx) + return placed{size: dims.Size, call: rec.Stop()} + } + okBtn := th.Button(&p.dialog.ok, okLabel) + okBtn.Variant, okBtn.Lit = okVariant, p.flashing(gtx, "accept") || kind == "alert" && p.flashing(gtx, "dismiss") + ok = record(okBtn) + if kind != "alert" { + noBtn := th.Button(&p.dialog.cancel, noLabel) + noBtn.Variant, noBtn.Lit = ui.VariantOutline, p.flashing(gtx, "dismiss") + no = record(noBtn) + } + return ok, no +} + +// dialogCardBody is who says it, the message, and a prompt's field. +func (u *window) dialogCardBody(gtx C, th *ui.Theme, d *dialogCard) D { + msg := th.Text(ui.TextSM, d.shown.Message) + msg.MaxLines = dialogMessageLines + children := []layout.FlexChild{ + layout.Rigid(th.Text(ui.TextXS, dialogFrom(d.shown.URL)).Muted().Layout), + ui.VGap(0.5), + layout.Rigid(msg.Layout), + } + if d.shown.Type == "prompt" { + children = append(children, layout.Rigid(layout.Spacer{Height: dialogCardGap}.Layout), + layout.Rigid(th.Input(&d.field, "").Layout)) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} diff --git a/native/cmd/lich-native/browser_dialog_test.go b/native/cmd/lich-native/browser_dialog_test.go new file mode 100644 index 000000000..e5d5d3c08 --- /dev/null +++ b/native/cmd/lich-native/browser_dialog_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "image" + "testing" + "time" + + "gioui.org/f32" + "gioui.org/io/key" + + "github.com/lichdotdev/lich/api" +) + +func dialogMark(at time.Time, action string) browserMark { + markSeq++ + return browserMark{seq: markSeq, at: at, action: action} +} + +func TestDialogCardShowsThePagesDialogAndSelectsAPromptsDefault(t *testing.T) { + var d dialogCard + d.sync(&api.BrowserDialog{Type: "prompt", Message: "Name?", Default: "Ada", URL: "http://127.0.0.1:5173/items"}, false) + if d.shown == nil || d.shown.Message != "Name?" { + t.Fatalf("shown %+v, want the page's prompt", d.shown) + } + if !d.focusField || d.field.Text() != "Ada" || d.field.SelectedText() != "Ada" { + t.Fatalf("field %q selected %q focus %v, want the default selected and focused", d.field.Text(), d.field.SelectedText(), d.focusField) + } + d.focusField = false + d.field.SetText("Grace") + d.sync(&api.BrowserDialog{Type: "prompt", Message: "Name?", Default: "Ada", URL: "http://127.0.0.1:5173/items"}, false) + if d.field.Text() != "Grace" || d.focusField { + t.Fatalf("the same dialog again reset the field to %q", d.field.Text()) + } + d.sync(&api.BrowserDialog{Type: "confirm", Message: "Delete this item?"}, false) + if d.shown.Type != "confirm" || d.focusField { + t.Fatalf("a confirm after the prompt: shown %+v, focus %v", d.shown, d.focusField) + } +} + +func TestDialogCardLeavesWhenThePageHasNoDialog(t *testing.T) { + var d dialogCard + d.sync(&api.BrowserDialog{Type: "alert", Message: "Saved."}, false) + d.sync(nil, false) + if d.shown != nil { + t.Fatalf("shown %+v after the dialog closed", d.shown) + } +} + +func TestDialogCardStaysUntilTheAgentsAnswerHasPlayed(t *testing.T) { + p := &browserPaneUI{} + t0 := time.Unix(100, 0) + p.dialog.sync(&api.BrowserDialog{Type: "confirm", Message: "Delete this item?"}, p.answering(t0)) + p.dialog.okAt, p.dialog.noAt = image.Rect(300, 100, 340, 132), image.Rect(220, 100, 290, 132) + + // The agent's act arrives, then the state without the dialog. + p.cursor.take([]browserMark{dialogMark(t0, "accept")}) + p.dialog.sync(nil, p.answering(t0)) + if p.dialog.shown == nil { + t.Fatal("the card left before the cursor pressed its button") + } + m, ok := p.cursor.advance(t0, func(m browserMark) f32.Point { return p.dialog.point(m.action, 800, 68) }) + if !ok || p.cursor.to != f32.Pt(320, 116) { + t.Fatalf("cursor went to %v (started %v), want OK's middle", p.cursor.to, ok) + } + p.flash = agentPress{what: m.action, at: p.cursor.landing()} + pressing := p.cursor.landing().Add(toolbarFlash / 2) + p.dialog.sync(nil, p.answering(pressing)) + if p.dialog.shown == nil { + t.Fatal("the card left while the cursor pressed OK") + } + after := p.cursor.landing().Add(toolbarFlash) + p.dialog.sync(nil, p.answering(after)) + if p.dialog.shown != nil { + t.Fatal("the card stayed after the cursor pressed OK") + } +} + +func TestDialogCardButtonTheAgentPresses(t *testing.T) { + ok, no := image.Rect(300, 100, 340, 132), image.Rect(220, 100, 290, 132) + for _, tc := range []struct { + kind, action string + want image.Rectangle + }{ + {"confirm", "accept", ok}, + {"confirm", "dismiss", no}, + {"prompt", "dismiss", no}, + {"beforeunload", "accept", ok}, + {"beforeunload", "dismiss", no}, + {"alert", "accept", ok}, + {"alert", "dismiss", ok}, + } { + d := dialogCard{shown: &api.BrowserDialog{Type: tc.kind}, okAt: ok, noAt: no} + if got := d.target(tc.action); got != tc.want { + t.Errorf("%s %s pressed %v, want %v", tc.kind, tc.action, got, tc.want) + } + } +} + +func TestDialogCardPointBeforeItWasDrawnIsItsTop(t *testing.T) { + var d dialogCard + if got := d.point("accept", 800, 68); got != f32.Pt(400, 68) { + t.Fatalf("point %v, want the page's top middle", got) + } +} + +func TestAgentCursorQueuesTheDialogsAnswers(t *testing.T) { + var c agentCursor + t0 := time.Unix(100, 0) + c.take([]browserMark{dialogMark(t0, "accept"), dialogMark(t0, "")}) + if len(c.pending) != 1 || c.pending[0].action != "accept" { + t.Fatalf("pending %+v, want the accept alone", c.pending) + } +} + +func TestDialogKeys(t *testing.T) { + for _, tc := range []struct { + kind string + name key.Name + accept, ok bool + }{ + {"confirm", key.NameReturn, true, true}, + {"confirm", key.NameEnter, true, true}, + {"confirm", key.NameEscape, false, true}, + {"prompt", key.NameEscape, false, true}, + {"beforeunload", key.NameReturn, true, true}, + {"beforeunload", key.NameEscape, false, true}, + {"alert", key.NameEscape, true, true}, + {"alert", key.NameReturn, true, true}, + {"confirm", "A", false, false}, + {"confirm", key.NameSpace, false, false}, + } { + accept, ok := dialogKey(tc.kind, tc.name) + if accept != tc.accept || ok != tc.ok { + t.Errorf("%s %q: accept %v ok %v, want %v %v", tc.kind, tc.name, accept, ok, tc.accept, tc.ok) + } + } +} + +func TestDialogFromNamesThePagesHost(t *testing.T) { + if got := dialogFrom("http://127.0.0.1:5173/items"); got != "127.0.0.1:5173 says" { + t.Errorf("got %q", got) + } + if got := dialogFrom("about:blank"); got != "This page says" { + t.Errorf("about:blank: got %q", got) + } +} diff --git a/native/cmd/lich-native/browser_pane.go b/native/cmd/lich-native/browser_pane.go index 6356bd3f3..dfae8a73d 100644 --- a/native/cmd/lich-native/browser_pane.go +++ b/native/cmd/lich-native/browser_pane.go @@ -75,15 +75,17 @@ type browserPaneUI struct { copied chan string cursor agentCursor onScreen, wasOnScreen bool - // flash is the toolbar button the agent's cursor pressed, and when; - // typing is the address it types into the field, from when. + // flash is the toolbar or dialog button the agent's cursor pressed, and + // when; typing is the address it types into the field, from when. flash agentPress typing agentPress notes browserNotes + dialog dialogCard } // agentPress is what the agent's cursor does on the toolbar, from at: the -// button it presses (back, forward, reload) or the address it types. +// button it presses (back, forward, reload, a dialog's accept or dismiss) or +// the address it types. type agentPress struct { what string at time.Time @@ -96,7 +98,9 @@ func (u *window) browserPane(session string) *browserPaneUI { p, ok := u.browserPanes[session] if !ok { p = &browserPaneUI{queue: newBrowserQueue(), copied: make(chan string, 1), notes: newBrowserNotes()} - go p.queue.run(u.ctx, func(err error) { log.Printf("browser of %s: %v", session, err) }) + go p.queue.run(u.ctx, func(err error) { + log.Printf("browser of %s: %v", session, err) + }) u.browserPanes[session] = p } return p @@ -130,6 +134,7 @@ func (u *window) browserPaneAt(gtx C, v stageView, session string, r image.Recta area := shape.Push(gtx.Ops) head, bar := gtx.Dp(paneHeader), gtx.Dp(ui.Space(9)) u.playAgentCursor(gtx, p, marks, head, bar) + p.dialog.sync(st.Dialog, p.answering(gtx.Now)) header := gtx header.Constraints = layout.Exact(image.Pt(size.X, head)) u.browserHeader(header, p, pane, focused) @@ -149,6 +154,7 @@ func (u *window) browserPaneAt(gtx C, v stageView, session string, r image.Recta u.browserPage(page, p, session, frame, focused) u.notesOverlay(page, p, session, st.URL) u.notesTray(page, p, session) + u.dialogCardAt(page, p, session, head+bar) off.Pop() u.paintAgentCursor(gtx, p, session) u.paneFocusTarget(gtx, pane) @@ -309,7 +315,7 @@ func (u *window) browserInput(gtx C, p *browserPaneUI, session string, scale flo key.Filter{Focus: &p.tag, Name: key.NameReturn, Required: key.ModShortcut}, key.Filter{Focus: &p.tag, Name: key.NameEnter, Required: key.ModShortcut}, transfer.TargetFilter{Target: &p.tag, Type: "application/text"}) - if focused && !gtx.Focused(&p.url) && !gtx.Focused(&p.tag) && !gtx.Focused(&p.notes.editor) { + if focused && !gtx.Focused(&p.url) && !gtx.Focused(&p.tag) && !gtx.Focused(&p.notes.editor) && !gtx.Focused(&p.dialog.field) { gtx.Execute(key.FocusCmd{Tag: &p.tag}) } writeCopied(gtx, p) @@ -324,7 +330,7 @@ func (u *window) browserInput(gtx C, p *browserPaneUI, session string, scale flo u.sendBrowser(session, "text", func(ctx context.Context) error { return u.client.BrowserText(ctx, session, text) }) } case key.Event: - if u.notesKey(p, session, e) { + if u.dialogPageKey(p, session, e) || u.notesKey(p, session, e) { continue } switch browserClipboardChord(e, runtime.GOOS) { @@ -497,7 +503,8 @@ const ( ) // playAgentCursor takes the agent's new actions and starts the next one, at -// its point of the page under the header and toolbar, or on the toolbar. +// its point of the page under the header and toolbar, on the toolbar, or on +// the dialog's card. func (u *window) playAgentCursor(gtx C, p *browserPaneUI, marks []browserMark, head, bar int) { p.cursor.take(marks) scale := gtx.Metric.PxPerDp @@ -505,6 +512,9 @@ func (u *window) playAgentCursor(gtx C, p *browserPaneUI, marks []browserMark, h if toolbarActions[m.action] { return toolbarPoint(gtx, m.action, head, bar) } + if dialogActions[m.action] { + return p.dialog.point(m.action, gtx.Constraints.Max.X, head+bar) + } return pagePoint(*m.x, *m.y, scale, image.Rect(0, head+bar, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)) } m, started := p.cursor.advance(gtx.Now, place) @@ -515,7 +525,7 @@ func (u *window) playAgentCursor(gtx C, p *browserPaneUI, marks []browserMark, h switch m.action { case "navigate": p.typing = agentPress{what: displayURL(m.url), at: landed} - case "back", "forward", "reload": + case "back", "forward", "reload", "accept", "dismiss": p.flash = agentPress{what: m.action, at: landed} } } @@ -553,8 +563,8 @@ func (u *window) paintAgentCursor(gtx C, p *browserPaneUI, session string) { u.drawAgentCursor(gtx, &p.cursor, tint, u.theme.PrimaryForeground, provider, icon) } -// flashing reports whether the agent's cursor lit the toolbar button for -// action at now. +// flashing reports whether the agent's cursor lit the toolbar or dialog +// button for action at now. func (p *browserPaneUI) flashing(gtx C, action string) bool { if p.flash.what != action || gtx.Now.Before(p.flash.at) { return false diff --git a/native/cmd/ui-gallery/sections.go b/native/cmd/ui-gallery/sections.go index ce0ab17de..c61ff040b 100644 --- a/native/cmd/ui-gallery/sections.go +++ b/native/cmd/ui-gallery/sections.go @@ -166,7 +166,19 @@ func (g *gallery) buttonsSection(gtx C, th *ui.Theme) D { } rows = append(rows, row(1.5, cells...)) } - return stack(2.5, append(rows, g.disabledButtons(th))...)(gtx) + return stack(2.5, append(rows, g.disabledButtons(th), g.litButtons(th))...)(gtx) +} + +// litButtons are drawn hovered with no pointer on them, as the browser pane +// plays the agent's cursor pressing its dialog card's button. +func (g *gallery) litButtons(th *ui.Theme) layout.Widget { + cells := []layout.Widget{minWidth(buttonLabelWidth, th.Text(ui.TextXS, "lit").Muted().Mono().Layout)} + for _, variant := range buttonVariants { + b := th.Button(&g.inert, "Run") + b.Variant, b.Icon, b.Lit = variant.variant, g.icon("play"), true + cells = append(cells, b.Layout) + } + return row(2, cells...) } func (g *gallery) disabledButtons(th *ui.Theme) layout.Widget { diff --git a/native/i18n/locales/en/layouts.json b/native/i18n/locales/en/layouts.json index 5b553c1a3..da1f71bc5 100644 --- a/native/i18n/locales/en/layouts.json +++ b/native/i18n/locales/en/layouts.json @@ -44,7 +44,12 @@ "clearNotes": "Clear", "sendNotes": "Send", "noNote": "No note", - "notesFailed": "Could not send the notes to {label}: {error}" + "notesFailed": "Could not send the notes to {label}: {error}", + "dialogFrom": "{host} says", + "dialogFromPage": "This page says", + "dialogOK": "OK", + "dialogStay": "Stay", + "dialogLeave": "Leave" }, "split": { "noRoom": "No room for another pane here. Make the window wider or take a pane out first." diff --git a/native/i18n/locales/es/layouts.json b/native/i18n/locales/es/layouts.json index 7fac0615f..4066ff930 100644 --- a/native/i18n/locales/es/layouts.json +++ b/native/i18n/locales/es/layouts.json @@ -44,7 +44,12 @@ "clearNotes": "Borrar", "sendNotes": "Enviar", "noNote": "Sin nota", - "notesFailed": "No se pudieron enviar las notas a {label}: {error}" + "notesFailed": "No se pudieron enviar las notas a {label}: {error}", + "dialogFrom": "{host} dice", + "dialogFromPage": "Esta página dice", + "dialogOK": "Aceptar", + "dialogStay": "Quedarse", + "dialogLeave": "Salir" }, "split": { "noRoom": "No hay espacio para otro panel aquí. Ensancha la ventana o saca antes un panel." diff --git a/native/i18n/locales/ja/layouts.json b/native/i18n/locales/ja/layouts.json index 78d3a2fdc..b12adfc00 100644 --- a/native/i18n/locales/ja/layouts.json +++ b/native/i18n/locales/ja/layouts.json @@ -41,7 +41,12 @@ "clearNotes": "クリア", "sendNotes": "送信", "noNote": "メモなし", - "notesFailed": "{label} にメモを送れませんでした: {error}" + "notesFailed": "{label} にメモを送れませんでした: {error}", + "dialogFrom": "{host} の内容", + "dialogFromPage": "このページの内容", + "dialogOK": "OK", + "dialogStay": "留まる", + "dialogLeave": "移動する" }, "split": { "noRoom": "ここには別のペインを置く余裕がありません。ウィンドウを広げるか、先にペインを外してください。" diff --git a/native/i18n/locales/ko/layouts.json b/native/i18n/locales/ko/layouts.json index f20afb690..8148b961e 100644 --- a/native/i18n/locales/ko/layouts.json +++ b/native/i18n/locales/ko/layouts.json @@ -41,7 +41,12 @@ "clearNotes": "지우기", "sendNotes": "보내기", "noNote": "메모 없음", - "notesFailed": "{label}에 메모를 보내지 못했습니다: {error}" + "notesFailed": "{label}에 메모를 보내지 못했습니다: {error}", + "dialogFrom": "{host} 내용:", + "dialogFromPage": "이 페이지 내용:", + "dialogOK": "확인", + "dialogStay": "머무르기", + "dialogLeave": "나가기" }, "split": { "noRoom": "여기에는 창을 하나 더 놓을 공간이 없습니다. 창을 넓히거나 먼저 창 하나를 빼세요." diff --git a/native/i18n/locales/pt-br/layouts.json b/native/i18n/locales/pt-br/layouts.json index b01f825b3..07476678b 100644 --- a/native/i18n/locales/pt-br/layouts.json +++ b/native/i18n/locales/pt-br/layouts.json @@ -44,7 +44,12 @@ "clearNotes": "Limpar", "sendNotes": "Enviar", "noNote": "Sem nota", - "notesFailed": "Não foi possível enviar as notas para {label}: {error}" + "notesFailed": "Não foi possível enviar as notas para {label}: {error}", + "dialogFrom": "{host} diz", + "dialogFromPage": "Esta página diz", + "dialogOK": "OK", + "dialogStay": "Ficar", + "dialogLeave": "Sair" }, "split": { "noRoom": "Não há espaço para outro painel aqui. Alargue a janela ou tire um painel antes." diff --git a/native/i18n/locales/ru/layouts.json b/native/i18n/locales/ru/layouts.json index 6ef1d14fb..ef7af3be5 100644 --- a/native/i18n/locales/ru/layouts.json +++ b/native/i18n/locales/ru/layouts.json @@ -50,7 +50,12 @@ "clearNotes": "Очистить", "sendNotes": "Отправить", "noNote": "Без заметки", - "notesFailed": "Не удалось отправить заметки в {label}: {error}" + "notesFailed": "Не удалось отправить заметки в {label}: {error}", + "dialogFrom": "{host} сообщает", + "dialogFromPage": "Эта страница сообщает", + "dialogOK": "ОК", + "dialogStay": "Остаться", + "dialogLeave": "Покинуть" }, "split": { "noRoom": "Здесь нет места для ещё одной панели. Сделайте окно шире или сначала уберите панель." diff --git a/native/i18n/locales/zh-cn/layouts.json b/native/i18n/locales/zh-cn/layouts.json index 1267cf729..b0ff4f689 100644 --- a/native/i18n/locales/zh-cn/layouts.json +++ b/native/i18n/locales/zh-cn/layouts.json @@ -41,7 +41,12 @@ "clearNotes": "清除", "sendNotes": "发送", "noNote": "无备注", - "notesFailed": "无法把备注发送给 {label}:{error}" + "notesFailed": "无法把备注发送给 {label}:{error}", + "dialogFrom": "{host} 显示", + "dialogFromPage": "此网页显示", + "dialogOK": "确定", + "dialogStay": "留下", + "dialogLeave": "离开" }, "split": { "noRoom": "这里没有空间再放一个窗格。请加宽窗口,或先移出一个窗格。" diff --git a/native/ui-gallery b/native/ui-gallery new file mode 100755 index 000000000..fe24cd8f5 Binary files /dev/null and b/native/ui-gallery differ diff --git a/native/ui/button.go b/native/ui/button.go index f83235d94..a1dca88e1 100644 --- a/native/ui/button.go +++ b/native/ui/button.go @@ -80,7 +80,10 @@ type ButtonStyle struct { Variant Variant Size Size Disabled bool - theme *Theme + // Lit draws it hovered: a press the window plays for someone else, the + // agent's cursor on a page's dialog. + Lit bool + theme *Theme } // Button is a default-variant, default-size button. @@ -136,7 +139,7 @@ func (b ButtonStyle) Layout(gtx C) D { if b.Disabled { return b.draw(gtx, false) } - return b.Click.Layout(gtx, func(gtx C) D { return b.draw(gtx, b.Click.Hovered()) }) + return b.Click.Layout(gtx, func(gtx C) D { return b.draw(gtx, b.Lit || b.Click.Hovered()) }) } func (b ButtonStyle) draw(gtx C, hovered bool) D {