Skip to content

feat: add human-like smooth typing with optional typo injection#201

Open
ulziibay-kernel wants to merge 3 commits intomainfrom
ulziibay-kernel/smooth-typing
Open

feat: add human-like smooth typing with optional typo injection#201
ulziibay-kernel wants to merge 3 commits intomainfrom
ulziibay-kernel/smooth-typing

Conversation

@ulziibay-kernel
Copy link
Copy Markdown
Contributor

@ulziibay-kernel ulziibay-kernel commented Apr 3, 2026

Summary

  • Adds smooth and typo_chance fields to POST /computer/type for human-like typing via xdotool
  • When smooth=true, text is typed in word-sized chunks with variable intra-word delays ([50, 120]ms) and inter-word pauses ([80, 200]ms, 1.5x at sentence boundaries)
  • When typo_chance is set (0.0-0.10), realistic typos are injected using geometric gap sampling (O(typos) random calls, not O(chars)) and corrected with backspace after a "realization" pause

New package: server/lib/typinghumanizer

  • SplitWordChunks -- word chunking with trailing delimiters attached to preceding word
  • UniformJitter -- random duration in a range, clamped to minimum
  • IsSentenceEnd -- sentence boundary detection for longer pauses
  • AdjacentKey -- QWERTY neighbor lookup from static [26][]byte array, O(1)
  • GenerateTypoPositions -- geometric gap sampling for typo placement

Typo types (weighted distribution)

Type Weight Mechanism
Adjacent key 60% QWERTY neighbor substitution
Doubling 20% Character typed twice
Transpose 15% Swap with next character
Extra char 5% Random adjacent key inserted

Related: #169 (plan document)

Demo

smooth_typing_demo

Test plan

  • All 17 typinghumanizer tests pass (word chunking, adjacency, typo generation, distribution)
  • Builds clean, no lint issues
  • Manual test with smooth: true on a running instance
  • Manual test with smooth: true, typo_chance: 0.03 to verify typo/correction behavior

Made with Cursor


Note

Medium Risk
Medium risk: changes POST /computer/type behavior and adds non-trivial timing/typo logic around xdotool, plus regenerates OpenAPI client/server bindings which can affect request parsing/optional fields.

Overview
Adds a new human-like typing mode to POST /computer/type via smooth and typo_chance, typing in word chunks with jittered delays, optional typo injection, and backspace-based correction.

Introduces server/lib/typinghumanizer (with tests) to handle chunking, timing jitter, QWERTY-adjacent key selection, and typo position generation; computer.go now routes to a new smooth-typing path when smooth=true.

Updates the OpenAPI spec and regenerates server/lib/oapi/oapi.go/go.sum accordingly, and adds a demo HTML page plus a Python script to record an instant-vs-smooth typing comparison video.

Reviewed by Cursor Bugbot for commit 99e2e79. Bugbot is set up for automated code reviews on this repo. Configure here.

Adds smooth typing mode to POST /computer/type that types text in
word-sized chunks with variable intra-word delays and natural
inter-word pauses via xdotool, following the same Go-side sleep
pattern as doMoveMouseSmooth.

Optionally injects realistic typos (adjacent-key, doubling, transpose)
using geometric gap sampling (O(typos) random calls, not O(chars))
with QWERTY adjacency lookup, then corrects them with backspace
after a "realization" pause.

New API fields on TypeTextRequest:
- smooth: boolean (default false) - enable human-like timing
- typo_chance: number 0.0-0.10 (default 0) - per-char typo rate

New package: server/lib/typinghumanizer with word chunking,
QWERTY adjacency map, and typo position generation.

Made-with: Cursor
- typing_demo.html: Dark-themed page with a textarea and keystroke
  timing visualization (bar chart of inter-key intervals)
- demo_smooth_typing.py: Records three phases (instant, smooth,
  smooth+typos) via the kernel API for comparison GIF/MP4

Made-with: Cursor
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
return
}
request.Body = &body
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codegen downgrade breaks endpoints accepting empty request bodies

High Severity

The oapi-codegen version downgrade from v2.6.0 to v2.5.1 removed the io.EOF graceful handling for empty request bodies in the generated middleware for TakeScreenshot, DeleteRecording, StartRecording, and StopRecording. Previously, empty bodies were allowed and request.Body was left nil. Now, any empty body triggers an error response. The TakeScreenshot handler explicitly handles request.Body == nil to allow bodyless calls, so this is a breaking regression — clients calling the screenshot endpoint without a JSON body will get an error instead of a full-screen capture.

Fix in Cursor Fix in Web

Copy link
Copy Markdown

@jarugupj jarugupj left a comment

Choose a reason for hiding this comment

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

Really cool stuff, the typinghumanizer package is super clean. The realization pause before backspace is a great detail, and the typo types cover the common cases really well -- adjacent key, doubling, transpose, extra char feels like it captures how people actually mess up.

One small thing I noticed -- in typeChunkWithTypo, the transpose branch and the else branch do the same thing:

if typo.Kind == typinghumanizer.TypoTranspose && typoLocalPos+1 < len(chunkRunes) {
    correctText = string(chunkRunes[typoLocalPos:])
} else {
    correctText = string(chunkRunes[typoLocalPos:])
}

Could probably just be correctText = string(chunkRunes[typoLocalPos:]) without the if/else?

Both branches produced identical correctText. Simplified to a single
expression as suggested by jarugupj.

Made-with: Cursor
@ulziibay-kernel
Copy link
Copy Markdown
Contributor Author

@jarugupj Thanks! Good catch — both branches were identical, leftover from when I considered different correction behavior for transposition. Simplified to a single expression in 99e2e79.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 99e2e79. Configure here.


// ExitCode Exit code if the process has exited.
ExitCode *int `json:"exit_code,omitempty"`
ExitCode *int `json:"exit_code"`
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Removed omitempty changes API JSON response format

Medium Severity

The regenerated oapi.go dropped omitempty from several pointer-typed JSON struct tags (e.g., ExitCode, AsUser, Cwd, TimeoutSec, FinishedAt, StartedAt). These fields will now serialize as null in JSON responses when nil, instead of being omitted entirely. This is a breaking change for API consumers that don't expect null values for these fields.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 99e2e79. Configure here.

chunkTypo = &t
break
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only first typo per chunk is applied

Medium Severity

doTypeTextSmooth generates typo positions across the full text via GenerateTypoPositions, but the per-chunk loop breaks after finding the first typo in each chunk. Any additional typos falling within the same chunk are silently dropped. For long words or text without spaces (single chunk), this means only one typo is ever injected regardless of how many were generated, making the effective typo rate lower than requested.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 99e2e79. Configure here.

type: number
description: |
Probability (0.0-0.10) of a typo per character, which is then
corrected with backspace. Requires smooth to be true. Set to 0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: "Requires smooth to be true" — this wording implies the server would return an error, but it actually silently ignores it. consider "Ignored when smooth is false" to match the actual behavior. no schema-level constraint enforces this either, so aligning the docs avoids confusion.

return adj
}

// GenerateTypoPositions computes typo positions using geometric gap sampling.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: comment says "geometric gap sampling" but the implementation uses uniform gap sampling — halfGap + Intn(avgGap) produces a uniform distribution over [halfGap, halfGap+avgGap). geometric sampling would use exponentially distributed gaps (e.g. rng.ExpFloat64()). the behavior is fine, just the label is inaccurate.

}
total := counts[TypoAdjacentKey] + counts[TypoDoubling] + counts[TypoTranspose] + counts[TypoExtraChar]
require.Greater(t, total, 0)
adjPct := float64(counts[TypoAdjacentKey]) / float64(total)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this only asserts TypoAdjacentKey (~60%). the other three kinds (TypoDoubling ~20%, TypoTranspose ~15%, TypoExtraChar ~5%) are counted but never checked — if two kinds got accidentally merged the test would still pass. consider asserting all four buckets.

Copy link
Copy Markdown
Contributor

@masnwilliams masnwilliams left a comment

Choose a reason for hiding this comment

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

solid feature — clean separation between the humanizer library and the integration layer. main notes are nits on docs wording and test coverage. the identical if/else branches in typeChunkWithTypo were already flagged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants