You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

15 KiB

G-001 Pluggable console backends for non-detectable terminals

Status: achieved 2026-07-11 Scope: src/modules/opunk/console-999999.0a1.0.tm, src/modules/punk/console-999999.0a1.0.tm, src/modules/punk/repl-999999.0a1.0.tm, src/lib/app-punkshell/punkshell.tcl Goal: an interactive REPL can be launched against a non-detectable terminal-like device (ssh channel, tk text widget) via an ::opunk::Console subclass, with no edits to the base class or punk::console. Acceptance: a subshell started with an ssh-channel-backed and a tk-widget-backed ::opunk::Console subclass runs an interactive REPL that reads/writes through that console; size, at_eof, and can_respond are answered by the subclass overrides; the base ::opunk::Console and punk::console module are unchanged.

Context

The Punk REPL talks to its user through a console object. The base ::opunk::Console class (src/modules/opunk/console-999999.0a1.0.tm) represents a console as an in/out channel pair with settled capability facts, and is designed from the outset for subclassing: it is declared -virtual so that "channel environments that respond like terminals but aren't platform consoles can subclass (-extends ::opunk::Console) and override the capability/size methods, with existing holders of console values dispatching correctly." The size method comment names the example explicitly: "Non-channel subclasses (e.g a tk text widget acting as a terminal) override this method entirely."

Standard terminal detection (is_console_or_tty, lines 151-176 of opunk/console-999999.0a1.0.tm) uses twapi::GetConsoleMode on Windows, chan configure -inputmode/-mode cross-platform, and environment hints (MSYSTEM, TERM_PROGRAM) as a fallback for mintty-without-winpty. The class comment already flags the limitation: this is a heuristic that trades false positives (piped-but-open input treated as a terminal) for false negatives, and only works for devices that either expose channel mode flags or set those environment variables.

Terminal-like devices that don't expose any of those signals cannot be auto-detected. Two concrete cases motivate this goal:

  1. SSH-channel consoles. A Punk subshell reachable over an ssh connection presents a socket channel, not a platform tty. There is no -inputmode/-mode, no twapi handle, and no inherited MSYSTEM/TERM_PROGRAM. Standard detection returns 0, so ANSI cursor-report queries are suppressed and the REPL degrades to a default 80x24 with no capability settling.
  2. Tk-widget consoles. A Tk text widget acting as a terminal is not a channel at all. The base size/at_eof/is_console_or_tty/can_respond methods are all channel-shaped; the class comment concedes these subclasses "override this method entirely - none of the channel/provider logic is imposed on them."

The seam for both already exists: punk::console::console_spec_resolve accepts an ::opunk::Console object value as a console spec, so a REPL can in principle be pointed at any subclass instance. What is missing is (a) the concrete subclasses and (b) the launch-time wiring that selects a non-default console for a given REPL/subshell.

Pluggable hooks already in place that backends may use or ignore:

  • ::opunk::console::waiting_chunks_arrayvar — redirects probe-consumed bytes to a cooperative store so active readers see them. Channel-backed subclasses reusing the base at_eof probe benefit from this; non-channel subclasses ignore it.
  • ::opunk::console::size_query_provider — a command prefix registered by punk::console to give the base class access to ANSI cursor-report mechanisms. Channel-backed subclasses gain this for free when the integrating layer registers it; non-channel subclasses override size and never touch it.

Approach

Add two reference ::opunk::Console subclasses, each in its own module under src/modules/opunk/ following the -999999.0a1.0.tm naming convention:

  1. opunk::console::ssh — wraps an ssh socket channel pair as a console. Overrides is_console_or_tty to return 1 (the backend is constructed knowing the channel is a terminal), can_respond to settle to 1, at_eof to check the channel's eof without the base class's pipe-probe (the channel is a known terminal, not a probed pipe), and size to either delegate to the registered size_query_provider (ANSI cursor report over the ssh channel) or query a backend-specific size if the ssh transport exposes one. Reuses waiting_chunks_arrayvar redirection if the probe path is exercised.
  2. opunk::console::tk — wraps a Tk text widget as a console. Overrides size (widget dimensions), at_eof (a backend-specific eof marker, not a channel eof), is_console_or_tty (returns 1), and can_respond (returns 1). Does not touch waiting_chunks_arrayvar or size_query_provider — non-channel subclass.

Then add launch-time console selection so a REPL or subshell can be started against a non-default console without hardcoding stdin/stdout. The selection seam lives at REPL init (repl::init in src/modules/punk/repl-999999.0a1.0.tm, lines ~3109-3128) where the "default" anchored console instance is currently constructed. The -console convention already being migrated through punk::console (per src/modules/punk/AGENTS.md "Work Guidance") is the natural extension point: accept a console spec (channel pair, instance name, or object value) at repl::init and resolve it via punk::console::console_spec_resolve, settling the resulting object's capability as appropriate for the backend.

Alternatives considered

  • Auto-detect ssh/tk consoles in is_console_or_tty. Rejected: the class comment already explains detection is deliberately heuristic because non-detectable devices don't expose the signals detection relies on. Adding more env-var hints or channel-shape sniffing for ssh/tk would compound the false-positive risk the comment warns about. Subclassing with explicit construction-time capability is what the -virtual design is for.
  • Put the ssh/tk subclasses inside punk::console rather than opunk. Rejected: punk::console is the integrating layer that registers providers with the base class; putting backends there would invert the dependency direction the class comment insists on ("the class depending on punk::console" is explicitly avoided). Backends belong in opunk alongside the base class, or in their own namespaces.
  • Make size pluggable per-instance instead of per-class. Rejected: the size_query_provider hook already covers the per-instance pluggable case for channel-based consoles. Non-channel subclasses need full method override, which is the -virtual dispatch path. A second per-instance hook would duplicate the existing mechanism.
  • Detect tk widgets by checking for a Tk window path. Rejected: a Tk text widget is not a channel and shouldn't be forced through channel detection. The subclass is constructed knowing what it wraps; detection is the wrong tool.

Notes

  • The -virtual voo dispatch means existing holders of an opunk::Console value (e.g. punk::console::console_spec_resolve callers, REPL init) dispatch to the subclass methods automatically once the value carries the subclass namespace tag. No call-site changes should be needed beyond passing the new console spec at launch.
  • punk::console::ensure_object_integration registers the size provider and redirects the probe-byte store. Channel-backed ssh subclass inherits this; tk subclass ignores it. Confirm during implementation that the tk subclass's size override does not accidentally fall through to the provider path.
  • The base class at_eof does a non-blocking 1-byte probe on pipe-like channels and parks the byte in waiting_chunks_waiting. The ssh subclass should override at_eof to use chan eof on the ssh channel directly — probing a socket would consume a byte the protocol layer may need.
  • The "first subshell asymmetry" TODO at repl-999999.0a1.0.tm:3130-3132 is G-002's concern, not G-001's, but G-001's launch-time console selection is a prerequisite for G-002's "target a named console" acceptance criterion. Sequence G-001 before G-002.
  • No persisted prior chat on this topic was found in project sessions; the motivation comes from the existing class design comments and the user's stated intent.

Progress (activated 2026-07-11)

Additional context since authoring: the G-044-detail repl characterization work (2026-07-11) identified this goal as the ENABLING refactor for repl/editbuf unit testing - class_editbuf is console-coupled and needs a deterministic console double. A third reference subclass (the test double) was therefore added ahead of ssh/tk in the build order.

Landed (increment 1)

Three backend modules under src/modules/opunk/console/, base ::opunk::Console and punk::console UNCHANGED (verified - no diffs):

  • opunk::console::test / ::opunk::TestConsole - deterministic channel-pair double: fixed size (-columns/-rows at construction, stored in the inherited default-size field), is_console_or_tty/can_respond 1 (settled wins), at_eof = plain chan eof with NO probe (a pending byte is never consumed - pinned by test).
  • opunk::console::ssh / ::opunk::SshConsole - per the Approach: construction-time capability, chan-eof without probe, size via the registered size_query_provider. Flagship verification: with a scripted "remote terminal" answering CSI 6n over a socket pair, ::opunk::Console::size on the subclass value resolves 100x30 through punk::console's ANSI cursor-report provider QUERYING OVER THE SOCKET - the entire value proposition proven without a real ssh connection.
  • opunk::console::tk / ::opunk::TkConsole - widget path in the in/out slots, terminal_class tk-text, size from widget char dims, at_eof via backend marker (::opunk::console::tk::set_eof) or widget destruction; no Tk require at module load. Verified live under punk91 src (the tk-loading experiment kit): size/eof flag/clear/destroy all correct.

voo -extends findings recorded in src/modules/opunk/AGENTS.md: child classes inherit public accessors + field INDEX variables but not parent-private my.* accessors - subclass constructors initialise inherited private fields via the index variables; method bodies use the parent's public accessor methods.

Tests: src/tests/modules/opunk/console/testsuites/console/backends.test (8 tests; 7 green + tk env-gated on tcl 9.0.3 and 8.7; tk case verified under punk91).

Landed (increment 2, 2026-07-11) - acceptance met

Repl launch wiring (punk::repl 0.4.0, base ::opunk::Console and punk::console still UNCHANGED - verified via VCS status at the flip):

  • repl::init -console <spec> resolves any console_spec_resolve form and stores per-repl channel state (repl::conin/conout/conerr; conerr==conout for a foreign console - per-repl err channels are G-011). repl::start's inchan is now optional, defaulting to the selected console's input. All existing callers (always explicit stdin) unchanged.
  • Output routing: rputs maps stdout/stderr (and the debug pseudo-channels) per-repl; doprompt writes conerr and prompts unconditionally for a foreign console (a selected console is a terminal by declaration - process tcl_interactive no longer gates it).
  • Code-interp output: for a foreign console the init_script (repltype punk/0) installs shellfilter 'var' JUNCTION stacks on the code interp's stdout/stderr (no pass-through to the process channels); the repl collects the pending vars after each runscript and emits them to the console via rputs. Interleaving between the two streams within a run is not preserved; emission is per-run (streaming is future work, see residue).
  • Console-object dispatch: new repl::console_at_eof / console_get_size / console_is_default helpers - eof checks at the reader loop and the error traps, and size for the raw-mode width path, dispatch to the selected object's (possibly overridden) at_eof/size; can_respond gates the ansi_wanted==2 settle without probing. Process-console behaviours (stdin reopen on eof, raw-mode re-enable, mode line at exit, the windows utf-16be line re-decode experiment) now apply only to the default console.
  • opunk::console::tk 0.2.0: TkConsole holds the widget in a subclass o_widget field (always the authority for size/eof/capability; new 'widget' accessor) and accepts -in/-out channels; new minimal wiring ::opunk::console::tk::console builds a reflected output channel rendering into the widget plus a Return-binding input pipe (submit/feed/teardown), so a channel-driven repl runs against the widget. Default widget-path construction unchanged (backends.test pins re-verified).

Acceptance verification (src/tests/modules/punk/repl/testsuites/repl/consolebackends.test, exec-driven child processes via src/tests/testsupport/repl_console_driver.tcl - a repl cannot run inside the shared runtests testinterp because codethread quit/exit callbacks thread::send to the thread's MAIN interp):

  • ssh: ::opunk::SshConsole over a socket pair; the scripted remote terminal answers CSI 6n (repl::console_get_size resolves 100x30 THROUGH THE SOCKET via punk::console's provider, exercising the subclass size + can_respond overrides) and converses prompt-by-prompt: results (7, 42), diverted code-interp stdout+stderr routed to the socket, clean exit 0 completion.
  • tk: ::opunk::TkConsole over a wired text widget; lines "typed" via feed echo in the widget, results and diverted output render into it, size from widget char dims (100x30), at_eof from the widget marker/existence, clean exit 0. (Runs un-gated: Tk loads only in the child process; self-gates when Tk is unavailable.)
  • test: ::opunk::TestConsole chan-pipe double - same conversation, the repl seam for future editbuf/repl characterization (G-044).

Residue / follow-on notes (non-gating)

  • Foreign-console code-interp output is emitted per-run (batch): a long-running command's incremental output and between-run background after writes appear at the next run boundary. A streaming bridge (junction-to-pipe + fileevent forwarding) is the natural refinement - fits the G-002 subshell-comms work.
  • Junction stacks are wired for repltype punk/0 only; safe/safebase/punksafe subshell types on a foreign console would still write code-interp output to the process std channels.
  • Line mode is the verified path; raw-mode/editbuf behaviour on a foreign console is untested (G-013 raw-default and G-044 completion work will exercise it; the width path already routes via console_get_size).
  • ansi_wanted/colour_disabled and the raw-state tsv remain process-global (documented punk::console design); a foreign-console repl settling ansi_wanted affects the whole process - scoped console state is G-008.
  • get_prompt_config still keys result/info prompt decoration on process tcl_interactive, so a foreign-console repl in a non-interactive process shows undecorated results (prompts themselves are emitted).